summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authormojganii <mojgan.jelodar@codic.se>2025-03-10 10:40:28 +0100
committerBug Magnet <marco.nikic@mullvad.net>2025-03-18 09:05:07 +0100
commitb260625e65350af228528b9b76e55ef50be4b10d (patch)
treef712fb2db09047154779bd8e9e2be6c938a7dcec
parent4d92aff083ed802a53459bd3ca6ecf9254439d07 (diff)
downloadmullvadvpn-b260625e65350af228528b9b76e55ef50be4b10d.tar.xz
mullvadvpn-b260625e65350af228528b9b76e55ef50be4b10d.zip
Refactor RelayFilterDataSource
-rw-r--r--ios/MullvadREST/Relay/RelayCandidates.swift2
-rw-r--r--ios/MullvadREST/Relay/RelaySelector.swift2
-rw-r--r--ios/MullvadREST/Relay/RelayWithLocation.swift6
-rw-r--r--ios/MullvadTypes/RelayConstraint.swift7
-rw-r--r--ios/MullvadTypes/RelayFilter.swift4
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj4
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift71
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift79
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift343
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift83
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift6
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift226
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift6
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift7
-rw-r--r--ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift6
15 files changed, 439 insertions, 413 deletions
diff --git a/ios/MullvadREST/Relay/RelayCandidates.swift b/ios/MullvadREST/Relay/RelayCandidates.swift
index 42567690a4..9ebb765d18 100644
--- a/ios/MullvadREST/Relay/RelayCandidates.swift
+++ b/ios/MullvadREST/Relay/RelayCandidates.swift
@@ -6,7 +6,7 @@
// Copyright © 2025 Mullvad VPN AB. All rights reserved.
//
-public struct RelayCandidates {
+public struct RelayCandidates: Equatable {
public let entryRelays: [RelayWithLocation<REST.ServerRelay>]?
public let exitRelays: [RelayWithLocation<REST.ServerRelay>]
public init(
diff --git a/ios/MullvadREST/Relay/RelaySelector.swift b/ios/MullvadREST/Relay/RelaySelector.swift
index 620daba28d..14742aef09 100644
--- a/ios/MullvadREST/Relay/RelaySelector.swift
+++ b/ios/MullvadREST/Relay/RelaySelector.swift
@@ -15,7 +15,7 @@ public enum RelaySelector {
// MARK: - public
/// Determines whether a `REST.ServerRelay` satisfies the given relay filter.
- static func relayMatchesFilter(_ relay: AnyRelay, filter: RelayFilter) -> Bool {
+ public static func relayMatchesFilter(_ relay: AnyRelay, filter: RelayFilter) -> Bool {
if case let .only(providers) = filter.providers, providers.contains(relay.provider) == false {
return false
}
diff --git a/ios/MullvadREST/Relay/RelayWithLocation.swift b/ios/MullvadREST/Relay/RelayWithLocation.swift
index c4d82c07f8..4b3e7bac4e 100644
--- a/ios/MullvadREST/Relay/RelayWithLocation.swift
+++ b/ios/MullvadREST/Relay/RelayWithLocation.swift
@@ -60,8 +60,12 @@ public struct RelayWithLocation<T: AnyRelay> {
}
}
-extension RelayWithLocation: Equatable {
+extension RelayWithLocation: Hashable {
public static func == (lhs: RelayWithLocation<T>, rhs: RelayWithLocation<T>) -> Bool {
lhs.relay.hostname == rhs.relay.hostname
}
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(relay.hostname)
+ }
}
diff --git a/ios/MullvadTypes/RelayConstraint.swift b/ios/MullvadTypes/RelayConstraint.swift
index 9b01a513aa..96bf81f100 100644
--- a/ios/MullvadTypes/RelayConstraint.swift
+++ b/ios/MullvadTypes/RelayConstraint.swift
@@ -10,8 +10,8 @@ import Foundation
private let anyConstraint = "any"
-public enum RelayConstraint<T>: Codable, Equatable,
- CustomDebugStringConvertible where T: Codable & Equatable {
+public enum RelayConstraint<T>: Codable, Equatable, CustomDebugStringConvertible, Sendable
+ where T: Codable & Equatable & Sendable {
case any
case only(T)
@@ -34,7 +34,7 @@ public enum RelayConstraint<T>: Codable, Equatable,
return output
}
- private struct OnlyRepr: Codable {
+ private struct OnlyRepr: Codable, Sendable {
var only: T
}
@@ -46,7 +46,6 @@ public enum RelayConstraint<T>: Codable, Equatable,
self = .any
} else {
let onlyVariant = try container.decode(OnlyRepr.self)
-
self = .only(onlyVariant.only)
}
}
diff --git a/ios/MullvadTypes/RelayFilter.swift b/ios/MullvadTypes/RelayFilter.swift
index ae02fde5d3..59ed6be2fe 100644
--- a/ios/MullvadTypes/RelayFilter.swift
+++ b/ios/MullvadTypes/RelayFilter.swift
@@ -8,8 +8,8 @@
import Foundation
-public struct RelayFilter: Codable, Equatable {
- public enum Ownership: Codable {
+public struct RelayFilter: Codable, Equatable, Sendable {
+ public enum Ownership: Codable, Sendable {
case any
case owned
case rented
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index ada2b8e01d..7e97cdd207 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -928,6 +928,7 @@
F0164EBE2B4BFF940020268D /* ShadowsocksLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */; };
F0164EC32B4C49D30020268D /* ShadowsocksLoaderStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */; };
F0164ED12B4F2DCB0020268D /* AccessMethodIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */; };
+ F017F8E02D78AC020076EC01 /* RelayFilterDataSourceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */; };
F01DAE332C2B032A00521E46 /* RelaySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01DAE322C2B032A00521E46 /* RelaySelection.swift */; };
F022EBA62CF0C6AE009484B9 /* ConsolidatedApplicationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */; };
F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */; };
@@ -2346,6 +2347,7 @@
F0164EBD2B4BFF940020268D /* ShadowsocksLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoader.swift; sourceTree = "<group>"; };
F0164EC22B4C49D30020268D /* ShadowsocksLoaderStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksLoaderStub.swift; sourceTree = "<group>"; };
F0164ED02B4F2DCB0020268D /* AccessMethodIterator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodIterator.swift; sourceTree = "<group>"; };
+ F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterDataSourceItem.swift; sourceTree = "<group>"; };
F01DAE322C2B032A00521E46 /* RelaySelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelaySelection.swift; sourceTree = "<group>"; };
F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewController.swift; sourceTree = "<group>"; };
F028A56B2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCreditSucceededViewController.swift; sourceTree = "<group>"; };
@@ -4319,6 +4321,7 @@
F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */,
7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */,
7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */,
+ F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */,
7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */,
7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */,
7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */,
@@ -6385,6 +6388,7 @@
7A5869B92B56E7F000640D27 /* IPOverrideViewControllerDelegate.swift in Sources */,
586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */,
58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */,
+ F017F8E02D78AC020076EC01 /* RelayFilterDataSourceItem.swift in Sources */,
586C0D832B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift in Sources */,
58B26E262943522400D5980C /* NotificationProvider.swift in Sources */,
58CE5E64224146200008646E /* AppDelegate.swift in Sources */,
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift b/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift
index 51eb06d698..666fd12c98 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/FilterDescriptor.swift
@@ -13,16 +13,32 @@ struct FilterDescriptor {
let settings: LatestTunnelSettings
var isEnabled: Bool {
- let exitCount = relayFilterResult.exitRelays.count
- let entryCount = relayFilterResult.entryRelays?.count ?? 0
- let totalcount = exitCount + entryCount
+ // Check if multihop is enabled via settings
let isMultihopEnabled = settings.tunnelMultihopState.isEnabled
- return (isMultihopEnabled && totalcount > 1) || (!isMultihopEnabled && totalcount > 0)
+ let isSmartRoutingEnabled = settings.daita.isAutomaticRouting
+
+ /// Closure to check if there are enough relays available for multihoping
+ let hasSufficientRelays: () -> Bool = {
+ (relayFilterResult.entryRelays ?? []).count >= 1 &&
+ relayFilterResult.exitRelays.count >= 1 &&
+ numberOfServers > 1
+ }
+
+ if isMultihopEnabled {
+ // Multihop mode requires at least one entry relay, one exit relay,
+ // and more than one unique server.
+ return hasSufficientRelays()
+ } else if isSmartRoutingEnabled {
+ // Smart Routing mode: Enabled only if there is NO daita server in the exit relays
+ let isSmartRoutingNeeded = !relayFilterResult.exitRelays.contains { $0.relay.daita == true }
+ return isSmartRoutingNeeded ? hasSufficientRelays() : true
+ } else {
+ // Single-hop mode: The filter is enabled if at least one available exit relay exists.
+ return !relayFilterResult.exitRelays.isEmpty
+ }
}
var title: String {
- let exitCount = relayFilterResult.exitRelays.count
- let entryCount = relayFilterResult.entryRelays?.count ?? 0
guard isEnabled else {
return NSLocalizedString(
"RELAY_FILTER_BUTTON_TITLE",
@@ -31,29 +47,17 @@ struct FilterDescriptor {
comment: ""
)
}
- return createTitleForAvailableServers(
- entryCount: entryCount,
- exitCount: exitCount,
- isMultihopEnabled: settings.tunnelMultihopState.isEnabled,
- isDirectOnly: settings.daita.isDirectOnly
- )
+ return createTitleForAvailableServers()
}
var description: String {
- guard settings.daita.isDirectOnly else {
- return settings.daita.daitaState.isEnabled
- ? NSLocalizedString(
- "RELAY_FILTER_BUTTON_DESCRIPTION",
- tableName: "RelayFilter",
- value: "DAITA is enabled, affecting your filters.",
- comment: ""
- )
- : ""
+ guard settings.daita.daitaState.isEnabled else {
+ return ""
}
return NSLocalizedString(
"RELAY_FILTER_BUTTON_DESCRIPTION",
tableName: "RelayFilter",
- value: "Direct only DAITA is enabled, affecting your filters.",
+ value: "When using DAITA, one provider with DAITA-enabled servers is required.",
comment: ""
)
}
@@ -63,23 +67,14 @@ struct FilterDescriptor {
self.relayFilterResult = relayFilterResult
}
- private func createTitleForAvailableServers(
- entryCount: Int,
- exitCount: Int,
- isMultihopEnabled: Bool,
- isDirectOnly: Bool
- ) -> String {
- let displayNumber: (Int) -> String = { number in
- number > 100 ? "99+" : "\(number)"
- }
+ private var numberOfServers: Int {
+ Set(relayFilterResult.entryRelays ?? []).union(relayFilterResult.exitRelays).count
+ }
- if isMultihopEnabled && isDirectOnly {
- return String(
- format: "Show %@ entry & %@ exit servers",
- displayNumber(entryCount),
- displayNumber(exitCount)
- )
+ private func createTitleForAvailableServers() -> String {
+ let displayNumber: (Int) -> String = { number in
+ number >= 100 ? "99+" : "\(number)"
}
- return String(format: "Show %@ servers", displayNumber(exitCount))
+ return String(format: "Show %@ servers", displayNumber(numberOfServers))
}
}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift
index f6cba86133..c26c50655c 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterCellFactory.swift
@@ -13,78 +13,77 @@ struct RelayFilterCellFactory: @preconcurrency CellFactoryProtocol {
let tableView: UITableView
func makeCell(for item: RelayFilterDataSource.Item, indexPath: IndexPath) -> UITableViewCell {
- let cell = tableView.dequeueReusableCell(withIdentifier: item.reuseIdentifier.rawValue, for: indexPath)
+ let cell = tableView.dequeueReusableCell(
+ withIdentifier: RelayFilterDataSource.CellReuseIdentifiers.allCases[indexPath.section].rawValue,
+ for: indexPath
+ )
configureCell(cell, item: item, indexPath: indexPath)
return cell
}
func configureCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item, indexPath: IndexPath) {
- switch item {
+ switch item.type {
case .ownershipAny, .ownershipOwned, .ownershipRented:
- configureOwnershipCell(cell, item: item)
+ configureOwnershipCell(cell as? SelectableSettingsCell, item: item)
case .allProviders, .provider:
- configureProviderCell(cell, item: item)
+ configureProviderCell(cell as? CheckableSettingsCell, item: item)
}
}
- private func configureOwnershipCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item) {
- guard let cell = cell as? SelectableSettingsCell else { return }
-
- var title = ""
- switch item {
- case .ownershipAny:
- title = "Any"
- cell.setAccessibilityIdentifier(.ownershipAnyCell)
- case .ownershipOwned:
- title = "Mullvad owned only"
- cell.setAccessibilityIdentifier(.ownershipMullvadOwnedCell)
- case .ownershipRented:
- title = "Rented only"
- cell.setAccessibilityIdentifier(.ownershipRentedCell)
- default:
- assertionFailure("Item mismatch. Got: \(item)")
- }
+ private func configureOwnershipCell(_ cell: SelectableSettingsCell?, item: RelayFilterDataSource.Item) {
+ guard let cell = cell else { return }
cell.titleLabel.text = NSLocalizedString(
"RELAY_FILTER_CELL_LABEL",
tableName: "Relay filter ownership cell",
- value: title,
+ value: item.name,
comment: ""
)
+ let accessibilityIdentifier: AccessibilityIdentifier
+ switch item.type {
+ case .ownershipAny:
+ accessibilityIdentifier = .ownershipAnyCell
+ case .ownershipOwned:
+ accessibilityIdentifier = .ownershipMullvadOwnedCell
+ case .ownershipRented:
+ accessibilityIdentifier = .ownershipRentedCell
+ default:
+ assertionFailure("Unexpected ownership item: \(item)")
+ return
+ }
+
+ cell.setAccessibilityIdentifier(accessibilityIdentifier)
cell.applySubCellStyling()
}
- private func configureProviderCell(_ cell: UITableViewCell, item: RelayFilterDataSource.Item) {
- guard let cell = cell as? CheckableSettingsCell else { return }
-
- let title: String
-
- switch item {
- case .allProviders:
- title = "All providers"
- setFontWeight(.semibold, to: cell.titleLabel)
- case let .provider(name):
- title = name
- setFontWeight(.regular, to: cell.titleLabel)
- default:
- title = ""
- assertionFailure("Item mismatch. Got: \(item)")
- }
+ private func configureProviderCell(_ cell: CheckableSettingsCell?, item: RelayFilterDataSource.Item) {
+ guard let cell = cell else { return }
+ let alpha = item.isEnabled ? 1.0 : 0.5
cell.titleLabel.text = NSLocalizedString(
"RELAY_FILTER_CELL_LABEL",
tableName: "Relay filter provider cell",
- value: title,
+ value: item.name,
comment: ""
)
+ cell.detailTitleLabel.text = item.description
+
+ if item.type == .allProviders {
+ setFontWeight(.semibold, to: cell.titleLabel)
+ } else {
+ setFontWeight(.regular, to: cell.titleLabel)
+ }
cell.applySubCellStyling()
cell.setAccessibilityIdentifier(.relayFilterProviderCell)
+ cell.titleLabel.alpha = alpha
+ cell.detailTitleLabel.alpha = alpha
+ cell.detailTitleLabel.textColor = cell.titleLabel.textColor
}
private func setFontWeight(_ weight: UIFont.Weight, to label: UILabel) {
- label.font = UIFont.systemFont(ofSize: label.font.pointSize, weight: .semibold)
+ label.font = UIFont.systemFont(ofSize: label.font.pointSize, weight: weight)
}
}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift
index 4c1905cf53..d90bf865c0 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSource.swift
@@ -10,31 +10,14 @@ import Combine
import MullvadREST
import MullvadTypes
import UIKit
-
final class RelayFilterDataSource: UITableViewDiffableDataSource<
RelayFilterDataSource.Section,
RelayFilterDataSource.Item
> {
- private var tableView: UITableView?
+ private weak var tableView: UITableView?
private var viewModel: RelayFilterViewModel
- private var disposeBag = Set<Combine.AnyCancellable>()
private let relayFilterCellFactory: RelayFilterCellFactory
-
- var selectedOwnershipItem: Item {
- guard let selectedIndexPath = getSelectedIndexPaths(in: .ownership).first,
- let selectedItem = itemIdentifier(for: selectedIndexPath)
- else {
- return .ownershipAny
- }
-
- return selectedItem
- }
-
- var selectedProviderItems: [Item] {
- return getSelectedIndexPaths(in: .providers).compactMap { indexPath in
- itemIdentifier(for: indexPath)
- }
- }
+ private var disposeBag = Set<Combine.AnyCancellable>()
init(tableView: UITableView, viewModel: RelayFilterViewModel) {
self.tableView = tableView
@@ -47,72 +30,43 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource<
relayFilterCellFactory.makeCell(for: itemIdentifier, indexPath: indexPath)
}
- registerClasses()
+ registerCells()
createDataSnapshot()
-
tableView.delegate = self
+ setupBindings()
+ }
+
+ private func registerCells() {
+ CellReuseIdentifiers.allCases.forEach { tableView?.register(
+ $0.reusableViewClass,
+ forCellReuseIdentifier: $0.rawValue
+ ) }
+ HeaderFooterReuseIdentifiers.allCases.forEach { tableView?.register(
+ $0.reusableViewClass,
+ forHeaderFooterViewReuseIdentifier: $0.rawValue
+ ) }
+ }
- viewModel.$relayFilter
+ private func setupBindings() {
+ viewModel
+ .$relayFilter
+ .dropFirst()
+ .removeDuplicates()
+ .receive(on: DispatchQueue.main)
.sink { [weak self] filter in
self?.updateDataSnapshot(filter: filter)
}
.store(in: &disposeBag)
}
- func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
- switch getSection(for: indexPath) {
- case .ownership:
- if viewModel.ownership(for: itemIdentifier(for: indexPath)) == viewModel.relayFilter.ownership {
- cell.setSelected(true, animated: false)
- }
- case .providers:
- switch viewModel.relayFilter.providers {
- case .any:
- cell.setSelected(true, animated: false)
- case let .only(providers):
- switch itemIdentifier(for: indexPath) {
- case .allProviders:
- let allProvidersAreSelected = providers.count == viewModel.uniqueProviders.count
- if allProvidersAreSelected {
- cell.setSelected(true, animated: false)
- }
- case let .provider(name):
- if providers.contains(name) {
- cell.setSelected(true, animated: false)
- }
- default:
- break
- }
- }
- }
- }
-
- private func registerClasses() {
- CellReuseIdentifiers.allCases.forEach { cellIdentifier in
- tableView?.register(
- cellIdentifier.reusableViewClass,
- forCellReuseIdentifier: cellIdentifier.rawValue
- )
- }
-
- HeaderFooterReuseIdentifiers.allCases.forEach { reuseIdentifier in
- tableView?.register(
- reuseIdentifier.reusableViewClass,
- forHeaderFooterViewReuseIdentifier: reuseIdentifier.rawValue
- )
- }
- }
-
private func createDataSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases)
-
- applySnapshot(snapshot, animated: false)
+ apply(snapshot, animatingDifferences: false)
}
- func updateDataSnapshot(filter: RelayFilter? = nil) {
+ private func updateDataSnapshot(filter: RelayFilter) {
let oldSnapshot = snapshot()
-
var newSnapshot = NSDiffableDataSourceSnapshot<Section, Item>()
newSnapshot.appendSections(Section.allCases)
@@ -124,16 +78,14 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource<
}
case .providers:
if !oldSnapshot.itemIdentifiers(inSection: section).isEmpty {
- let ownership = (filter ?? viewModel.relayFilter).ownership
- let items = viewModel.availableProviders(for: ownership).map { Item.provider($0) }
-
- newSnapshot.appendItems([.allProviders], toSection: .providers)
- newSnapshot.appendItems(items, toSection: .providers)
+ newSnapshot.appendItems(
+ [Item.allProviders] + viewModel.availableProviders(for: filter.ownership),
+ toSection: .providers
+ )
+ applySnapshot(newSnapshot, animated: false)
}
}
}
-
- applySnapshot(newSnapshot, animated: false)
}
private func applySnapshot(
@@ -143,48 +95,117 @@ final class RelayFilterDataSource: UITableViewDiffableDataSource<
) {
apply(snapshot, animatingDifferences: animated) { [weak self] in
guard let self else { return }
-
updateSelection(from: viewModel.relayFilter)
completion?()
}
}
private func updateSelection(from filter: RelayFilter) {
- if let ownershipItem = viewModel.ownershipItem(for: filter.ownership) {
- selectRow(true, at: indexPath(for: ownershipItem))
+ tableView?.indexPathsForSelectedRows?.forEach { selectRow(false, at: $0) }
+
+ if let ownership = viewModel.ownershipItem(for: filter.ownership),
+ let ownershipIndexPath = indexPath(for: ownership) {
+ selectRow(true, at: ownershipIndexPath)
}
switch filter.providers {
case .any:
selectAllProviders(true)
case let .only(providers):
+ selectAllProviders(false)
providers.forEach { providerName in
- if let providerItem = viewModel.providerItem(for: providerName) {
- selectRow(true, at: indexPath(for: providerItem))
- }
+ selectRow(true, at: indexPath(for: viewModel.providerItem(for: providerName)))
}
-
updateAllProvidersSelection()
}
}
+ private func isItemSelected(_ item: Item, for filter: RelayFilter) -> Bool {
+ switch item.type {
+ case .ownershipAny, .ownershipOwned, .ownershipRented:
+ return viewModel.ownership(for: item) == filter.ownership
+ case .allProviders:
+ return filter.providers == .any
+ case .provider:
+ return switch filter.providers {
+ case .any:
+ true
+ case let .only(providers):
+ providers.contains(item.name)
+ }
+ }
+ }
+
private func updateAllProvidersSelection() {
let selectedCount = getSelectedIndexPaths(in: .providers).count
let providerCount = viewModel.availableProviders(for: viewModel.relayFilter.ownership).count
+ selectRow(selectedCount == providerCount, at: indexPath(for: .allProviders))
+ }
+
+ private func handleCollapseOwnership(isExpanded: Bool) {
+ var newSnapshot = snapshot()
+ if isExpanded {
+ newSnapshot.deleteItems(Item.ownerships)
+ } else {
+ newSnapshot.appendItems(Item.ownerships, toSection: .ownership)
+ }
+ applySnapshot(newSnapshot, animated: !isExpanded)
+ }
+
+ private func handleCollapseProviders(isExpanded: Bool) {
+ let currentSnapshot = snapshot()
+ var newSnapshot = currentSnapshot
+
+ if isExpanded {
+ let items = newSnapshot.itemIdentifiers(inSection: .providers)
+ newSnapshot.deleteItems(items)
+ } else {
+ newSnapshot.appendItems(
+ [Item.allProviders] + viewModel.availableProviders(for: viewModel.relayFilter.ownership),
+ toSection: .providers
+ )
+ }
+ applySnapshot(newSnapshot, animated: !isExpanded)
+ }
+
+ private func selectRow(_ select: Bool, at indexPath: IndexPath?) {
+ guard let indexPath else { return }
+
+ if select {
+ tableView?.selectRow(at: indexPath, animated: false, scrollPosition: .none)
+ } else {
+ tableView?.deselectRow(at: indexPath, animated: false)
+ }
+ }
- if selectedCount == providerCount {
- selectRow(true, at: indexPath(for: .allProviders))
+ private func selectAllProviders(_ select: Bool) {
+ let providerItems = snapshot().itemIdentifiers(inSection: .providers)
+
+ providerItems.forEach { providerItem in
+ selectRow(select, at: indexPath(for: providerItem))
}
}
+
+ private func getSelectedIndexPaths(in section: Section) -> [IndexPath] {
+ let sectionIndex = snapshot().indexOfSection(section)
+
+ return tableView?.indexPathsForSelectedRows?.filter { indexPath in
+ indexPath.section == sectionIndex
+ } ?? []
+ }
+
+ private func getSection(for indexPath: IndexPath) -> Section {
+ return snapshot().sectionIdentifiers[indexPath.section]
+ }
}
+// MARK: - UITableViewDelegate
+
extension RelayFilterDataSource: UITableViewDelegate {
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
switch getSection(for: indexPath) {
case .ownership:
- if let selectedIndexPath = self.indexPath(for: selectedOwnershipItem) {
- selectRow(false, at: selectedIndexPath)
- }
+ selectRow(false, at: getSelectedIndexPaths(in: .ownership).first)
case .providers:
break
}
@@ -203,36 +224,17 @@ extension RelayFilterDataSource: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let item = itemIdentifier(for: indexPath) else { return }
-
- switch getSection(for: indexPath) {
- case .ownership:
- break
- case .providers:
- if item == .allProviders {
- selectAllProviders(true)
- } else {
- updateAllProvidersSelection()
- }
- }
-
- viewModel.addItemToFilter(item)
+ viewModel.toggleItem(item)
}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
guard let item = itemIdentifier(for: indexPath) else { return }
+ viewModel.toggleItem(item)
+ }
- switch getSection(for: indexPath) {
- case .ownership:
- break
- case .providers:
- if item == .allProviders {
- selectAllProviders(false)
- } else {
- selectRow(false, at: self.indexPath(for: .allProviders))
- }
- }
-
- viewModel.removeItemFromFilter(item)
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ guard let item = itemIdentifier(for: indexPath) else { return }
+ cell.setSelected(isItemSelected(item, for: viewModel.relayFilter), animated: false)
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
@@ -263,18 +265,14 @@ extension RelayFilterDataSource: UITableViewDelegate {
view.didCollapseHandler = { [weak self] headerView in
guard let self else { return }
-
- var snapshot = snapshot()
-
switch sectionId {
case .ownership:
- handleCollapseOwnership(snapshot: &snapshot, isExpanded: headerView.isExpanded)
+ handleCollapseOwnership(isExpanded: headerView.isExpanded)
case .providers:
- handleCollapseProviders(snapshot: &snapshot, isExpanded: headerView.isExpanded)
+ handleCollapseProviders(isExpanded: headerView.isExpanded)
}
headerView.isExpanded.toggle()
- applySnapshot(snapshot, animated: true)
}
return view
@@ -287,109 +285,4 @@ extension RelayFilterDataSource: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return UIMetrics.TableView.separatorHeight
}
-
- private func selectRow(_ select: Bool, at indexPath: IndexPath?) {
- guard let indexPath else { return }
-
- if select {
- tableView?.selectRow(at: indexPath, animated: false, scrollPosition: .none)
- } else {
- tableView?.deselectRow(at: indexPath, animated: false)
- }
- }
-
- private func getSelectedIndexPaths(in section: Section) -> [IndexPath] {
- let sectionIndex = snapshot().indexOfSection(section)
-
- return tableView?.indexPathsForSelectedRows?.filter { indexPath in
- indexPath.section == sectionIndex
- } ?? []
- }
-
- private func getSection(for indexPath: IndexPath) -> Section {
- return snapshot().sectionIdentifiers[indexPath.section]
- }
-
- private func selectAllProviders(_ select: Bool) {
- let providerItems = snapshot().itemIdentifiers(inSection: .providers)
-
- providerItems.forEach { providerItem in
- selectRow(select, at: indexPath(for: providerItem))
- }
- }
-
- private func handleCollapseOwnership(
- snapshot: inout NSDiffableDataSourceSnapshot<RelayFilterDataSource.Section, RelayFilterDataSource.Item>,
- isExpanded: Bool
- ) {
- if isExpanded {
- snapshot.deleteItems(Item.ownerships)
- } else {
- snapshot.appendItems(Item.ownerships, toSection: .ownership)
- }
- }
-
- private func handleCollapseProviders(
- snapshot: inout NSDiffableDataSourceSnapshot<RelayFilterDataSource.Section, RelayFilterDataSource.Item>,
- isExpanded: Bool
- ) {
- if isExpanded {
- let items = snapshot.itemIdentifiers(inSection: .providers)
- snapshot.deleteItems(items)
- } else {
- let items = viewModel.availableProviders(for: viewModel.relayFilter.ownership).map { Item.provider($0) }
- snapshot.appendItems([.allProviders], toSection: .providers)
- snapshot.appendItems(items, toSection: .providers)
- }
- }
-}
-
-extension RelayFilterDataSource {
- enum CellReuseIdentifiers: String, CaseIterable {
- case ownershipCell
- case providerCell
-
- var reusableViewClass: AnyClass {
- switch self {
- case .ownershipCell:
- return SelectableSettingsCell.self
- case .providerCell:
- return CheckableSettingsCell.self
- }
- }
- }
-
- enum HeaderFooterReuseIdentifiers: String, CaseIterable {
- case section
-
- var reusableViewClass: AnyClass {
- return SettingsHeaderView.self
- }
- }
-
- enum Section: Hashable, CaseIterable {
- case ownership
- case providers
- }
-
- enum Item: Hashable {
- case ownershipAny
- case ownershipOwned
- case ownershipRented
- case allProviders
- case provider(_ name: String)
-
- static var ownerships: [Item] {
- return [.ownershipAny, .ownershipOwned, .ownershipRented]
- }
-
- var reuseIdentifier: CellReuseIdentifiers {
- switch self {
- case .ownershipAny, .ownershipOwned, .ownershipRented:
- return .ownershipCell
- case .allProviders, .provider:
- return .providerCell
- }
- }
- }
}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift
new file mode 100644
index 0000000000..cc590c0105
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterDataSourceItem.swift
@@ -0,0 +1,83 @@
+//
+// RelayFilterDataSourceItem.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2025-03-05.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+
+extension RelayFilterDataSource {
+ enum Section: CaseIterable { case ownership, providers }
+
+ struct Item: Hashable, Comparable {
+ let name: String
+ var description = ""
+ let type: ItemType
+ let isEnabled: Bool
+
+ enum ItemType: Hashable {
+ case ownershipAny, ownershipOwned, ownershipRented, allProviders, provider
+ }
+
+ static var ownerships: [Item] {
+ [
+ Item(name: NSLocalizedString(
+ "RELAY_FILTER_ANY_LABEL",
+ tableName: "RelayFilter",
+ value: "Any",
+ comment: ""
+ ), type: .ownershipAny, isEnabled: true),
+
+ Item(name: NSLocalizedString(
+ "RELAY_FILTER_OWNED_LABEL",
+ tableName: "RelayFilter",
+ value: "Owned",
+ comment: ""
+ ), type: .ownershipOwned, isEnabled: true),
+ Item(name: NSLocalizedString(
+ "RELAY_FILTER_RENTED_LABEL",
+ tableName: "RelayFilter",
+ value: "Rented",
+ comment: ""
+ ), type: .ownershipRented, isEnabled: true),
+ ]
+ }
+
+ static var allProviders: Item {
+ Item(name: NSLocalizedString(
+ "RELAY_FILTER_ALL_PROVIDERS_LABEL",
+ tableName: "RelayFilter",
+ value: "All Providers",
+ comment: ""
+ ), type: .allProviders, isEnabled: true)
+ }
+
+ static func < (lhs: Item, rhs: Item) -> Bool {
+ let nameComparison = lhs.name.caseInsensitiveCompare(rhs.name)
+ return nameComparison == .orderedAscending
+ }
+ }
+}
+
+// MARK: - Cell Identifiers
+
+extension RelayFilterDataSource {
+ enum CellReuseIdentifiers: String, CaseIterable {
+ case ownershipCell, providerCell
+
+ var reusableViewClass: AnyClass {
+ switch self {
+ case .ownershipCell: return SelectableSettingsCell.self
+ case .providerCell: return CheckableSettingsCell.self
+ }
+ }
+ }
+
+ enum HeaderFooterReuseIdentifiers: String, CaseIterable {
+ case section
+
+ var reusableViewClass: AnyClass { SettingsHeaderView.self }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
index 9873aa8dfa..510b617f8f 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewController.swift
@@ -31,7 +31,7 @@ class RelayFilterViewController: UIViewController {
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.font = .preferredFont(forTextStyle: .body)
- label.textColor = .secondaryTextColor
+ label.textColor = .primaryTextColor
label.textAlignment = .center
return label
}()
@@ -85,6 +85,7 @@ class RelayFilterViewController: UIViewController {
tableView.estimatedRowHeight = 60
tableView.estimatedSectionHeaderHeight = tableView.estimatedRowHeight
tableView.allowsMultipleSelection = true
+ tableView.isMultipleTouchEnabled = false
view.addSubview(tableView)
buttonContainerView.addArrangedSubview(descriptionLabel)
@@ -105,15 +106,14 @@ class RelayFilterViewController: UIViewController {
private func setupDataSource() {
viewModel
.$relayFilter
- .receive(on: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] filter in
guard let self else { return }
let filterDescriptor = viewModel.getFilteredRelays(filter)
+ descriptionLabel.alpha = filterDescriptor.isEnabled ? 1.0 : 0.5
applyButton.isEnabled = filterDescriptor.isEnabled
applyButton.setTitle(filterDescriptor.title, for: .normal)
descriptionLabel.text = filterDescriptor.description
- descriptionLabel.isEnabled = filterDescriptor.isEnabled
}
.store(in: &disposeBag)
dataSource = RelayFilterDataSource(tableView: tableView, viewModel: viewModel)
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift
index 267242cc8d..b5c64ffe6b 100644
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift
+++ b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterViewModel.swift
@@ -11,139 +11,187 @@ import MullvadREST
import MullvadSettings
import MullvadTypes
-class RelayFilterViewModel {
+final class RelayFilterViewModel {
+ @Published var relayFilter: RelayFilter
+
private var settings: LatestTunnelSettings
- private let relaysWithLocation: LocationRelays
private let relaySelectorWrapper: RelaySelectorWrapper
- @Published var relayFilter: RelayFilter
+ private let relaysWithLocation: LocationRelays
+ private var relayCandidatesForAny: RelayCandidates
init(settings: LatestTunnelSettings, relaySelectorWrapper: RelaySelectorWrapper) {
self.settings = settings
self.relaySelectorWrapper = relaySelectorWrapper
- relaysWithLocation = if let cachedResponse = try? relaySelectorWrapper.relayCache.read().relays {
- LocationRelays(relays: cachedResponse.wireguard.relays, locations: cachedResponse.locations)
+ self.relayFilter = settings.relayConstraints.filter.value ?? RelayFilter()
+
+ // Retrieve all available relays that satisfy the `any` constraint.
+ // This constraint ensures that the selected relays are associated with the current tunnel settings
+ // and serve as the primary source of truth for subsequent filtering operations.
+ // Further filtering will be applied based on specific criteria such as `ownership` or `provider`.
+ var copy = settings
+ copy.relayConstraints.filter = .any
+ if let relayCandidatesForAny = try? relaySelectorWrapper.findCandidates(tunnelSettings: copy) {
+ self.relayCandidatesForAny = relayCandidatesForAny
} else {
- LocationRelays(relays: [], locations: [:])
+ self.relayCandidatesForAny = RelayCandidates(entryRelays: nil, exitRelays: [])
}
- self.relayFilter = if case let .only(filter) = settings.relayConstraints.filter {
- filter
+ // Directly setting relaysWithLocation in constructor
+ if let cachedResponse = try? relaySelectorWrapper.relayCache.read().relays {
+ self.relaysWithLocation = LocationRelays(
+ relays: cachedResponse.wireguard.relays,
+ locations: cachedResponse.locations
+ )
} else {
- RelayFilter()
+ self.relaysWithLocation = LocationRelays(relays: [], locations: [:])
}
}
- private var relays: [REST.ServerRelay] {
- relaysWithLocation.relays
- }
+ private var relays: [REST.ServerRelay] { relaysWithLocation.relays }
var uniqueProviders: [String] {
- Set(relays.map { $0.provider }).caseInsensitiveSorted()
+ extractProviders(from: relays)
}
var ownedProviders: [String] {
- Set(relays.filter { $0.owned == true }.map { $0.provider }).caseInsensitiveSorted()
+ extractProviders(from: relays.filter { $0.owned == true })
}
var rentedProviders: [String] {
- Set(relays.filter { $0.owned == false }.map { $0.provider }).caseInsensitiveSorted()
+ extractProviders(from: relays.filter { $0.owned == false })
}
- func addItemToFilter(_ item: RelayFilterDataSource.Item) {
- switch item {
+ // MARK: - public Methods
+
+ func toggleItem(_ item: RelayFilterDataSource.Item) {
+ switch item.type {
case .ownershipAny, .ownershipOwned, .ownershipRented:
relayFilter.ownership = ownership(for: item) ?? .any
case .allProviders:
- relayFilter.providers = .any
- case let .provider(name):
- switch relayFilter.providers {
- case .any:
- relayFilter.providers = .only([name])
- case var .only(providers):
- if !providers.contains(name) {
- providers.append(name)
- providers.caseInsensitiveSort()
-
- if providers == availableProviders(for: relayFilter.ownership) {
- relayFilter.providers = .any
- } else {
- relayFilter.providers = .only(providers)
- }
- }
- }
+ relayFilter.providers = relayFilter.providers == .any ? .only([]) : .any
+ case .provider:
+ toggleProvider(item.name)
}
}
- func removeItemFromFilter(_ item: RelayFilterDataSource.Item) {
- switch item {
- case .ownershipAny, .ownershipOwned, .ownershipRented:
- break
- case .allProviders:
- relayFilter.providers = .only([])
- case let .provider(name):
- switch relayFilter.providers {
- case .any:
- var providers = availableProviders(for: relayFilter.ownership)
- providers.removeAll { $0 == name }
- relayFilter.providers = .only(providers)
- case var .only(providers):
- providers.removeAll { $0 == name }
- relayFilter.providers = .only(providers)
- }
- }
+ func availableProviders(for ownership: RelayFilter.Ownership) -> [RelayFilterDataSource.Item] {
+ providers(for: ownership)
+ .map {
+ providerItem(for: $0)
+ }.sorted()
}
- func providerItem(for providerName: String?) -> RelayFilterDataSource.Item? {
- return .provider(providerName ?? "")
+ func ownership(for item: RelayFilterDataSource.Item) -> RelayFilter.Ownership? {
+ let ownershipMapping: [RelayFilterDataSource.Item.ItemType: RelayFilter.Ownership] = [
+ .ownershipAny: .any,
+ .ownershipOwned: .owned,
+ .ownershipRented: .rented,
+ ]
+
+ return ownershipMapping[item.type]
}
- func availableProviders(for ownership: RelayFilter.Ownership) -> [String] {
- switch ownership {
- case .any:
- return uniqueProviders
- case .owned:
- return ownedProviders
- case .rented:
- return rentedProviders
- }
+ func ownershipItem(for ownership: RelayFilter.Ownership) -> RelayFilterDataSource.Item? {
+ let ownershipMapping: [RelayFilter.Ownership: RelayFilterDataSource.Item.ItemType] = [
+ .any: .ownershipAny,
+ .owned: .ownershipOwned,
+ .rented: .ownershipRented,
+ ]
+
+ return RelayFilterDataSource.Item.ownerships.first { $0.type == ownershipMapping[ownership] }
}
- func ownership(for item: RelayFilterDataSource.Item?) -> RelayFilter.Ownership? {
- switch item {
- case .ownershipAny:
- return .any
- case .ownershipOwned:
- return .owned
- case .ownershipRented:
- return .rented
- default:
- return nil
- }
+ func providerItem(for providerName: String) -> RelayFilterDataSource.Item {
+ let isDaitaEnabled = settings.daita.daitaState.isEnabled
+ let isProviderEnabled = isProviderEnabled(for: providerName)
+ let isFilterable = getFilteredRelays(relayFilter).isEnabled
+
+ let statusText = isDaitaEnabled
+ ? NSLocalizedString(
+ "ENABLED_LABEL",
+ tableName: "RelayFilter",
+ value: "enabled",
+ comment: ""
+ )
+ : ""
+
+ return RelayFilterDataSource.Item(
+ name: providerName,
+ description: isDaitaEnabled && isProviderEnabled
+ ? String(
+ format: NSLocalizedString(
+ "RELAY_FILTER_PROVIDER_DESCRIPTION_FORMAT_LABEL",
+ tableName: "RelayFilter",
+ value: "DAITA-%@",
+ comment: "Format for DAITA provider description"
+ ), statusText
+ )
+ : "",
+ type: .provider,
+ // If the current filter is valid, return true immediately.
+ // Otherwise, check if the provider is enabled when filtering specifically by the given provider name.
+ isEnabled: isFilterable || isProviderEnabled
+ )
}
- func ownershipItem(for ownership: RelayFilter.Ownership?) -> RelayFilterDataSource.Item? {
+ func getFilteredRelays(_ relayFilter: RelayFilter) -> FilterDescriptor {
+ return FilterDescriptor(
+ relayFilterResult: RelayCandidates(
+ entryRelays: relayCandidatesForAny.entryRelays?.filter {
+ RelaySelector.relayMatchesFilter($0.relay, filter: relayFilter)
+ },
+ exitRelays: relayCandidatesForAny.exitRelays.filter {
+ RelaySelector.relayMatchesFilter($0.relay, filter: relayFilter)
+ }
+ ),
+ settings: settings
+ )
+ }
+
+ // MARK: - private Methods
+
+ private func providers(for ownership: RelayFilter.Ownership) -> [String] {
switch ownership {
case .any:
- return .ownershipAny
+ uniqueProviders
case .owned:
- return .ownershipOwned
+ ownedProviders
case .rented:
- return .ownershipRented
- default:
- return nil
+ rentedProviders
}
}
- func getFilteredRelays(_ relayFilter: RelayFilter) -> FilterDescriptor {
- settings.relayConstraints.filter = .only(relayFilter)
- do {
- let result = try relaySelectorWrapper.findCandidates(tunnelSettings: settings)
- return FilterDescriptor(relayFilterResult: result, settings: settings)
- } catch {
- return FilterDescriptor(
- relayFilterResult: RelayCandidates(entryRelays: [], exitRelays: []),
- settings: settings
- )
+ private func toggleProvider(_ name: String) {
+ switch relayFilter.providers {
+ case .any:
+ // If currently "any", switch to only the selected provider
+ var providers = providers(for: relayFilter.ownership)
+ providers.removeAll { $0 == name }
+ relayFilter.providers = .only(providers.map { $0 })
+ case var .only(selectedProviders):
+ if selectedProviders.contains(name) {
+ // If provider exists, remove it
+ selectedProviders.removeAll { $0 == name }
+ } else {
+ // Otherwise, add it
+ selectedProviders.append(name)
+ }
+
+ // If all available providers are selected, switch back to "any"
+ relayFilter.providers = selectedProviders.isEmpty
+ ? .only([])
+ : (selectedProviders == providers(for: relayFilter.ownership) ? .any : .only(selectedProviders))
}
}
+
+ private func extractProviders(from relays: [REST.ServerRelay]) -> [String] {
+ Set(relays.map { $0.provider }).caseInsensitiveSorted()
+ }
+
+ private func isProviderEnabled(for providerName: String) -> Bool {
+ // Check if the provider is enabled when filtering specifically by the given provider name.
+ return getFilteredRelays(
+ RelayFilter(ownership: relayFilter.ownership, providers: .only([providerName]))
+ ).isEnabled
+ }
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift
index 9cb5f0bec4..40775cbea2 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift
@@ -28,7 +28,6 @@ class LocationSectionHeaderFooterView: UIView, UIContentView {
containerView.axis = .horizontal
containerView.spacing = 8
containerView.isLayoutMarginsRelativeArrangement = true
- containerView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
return containerView
}()
@@ -64,7 +63,7 @@ class LocationSectionHeaderFooterView: UIView, UIContentView {
containerView.addArrangedSubview(nameLabel)
containerView.addArrangedSubview(actionButton)
addConstrainedSubviews([containerView]) {
- containerView.pinEdgesToSuperview()
+ containerView.pinEdgesToSuperviewMargins()
actionButton.heightAnchor.constraint(equalTo: heightAnchor)
actionButton.widthAnchor.constraint(equalTo: actionButton.heightAnchor)
}
@@ -86,8 +85,7 @@ class LocationSectionHeaderFooterView: UIView, UIContentView {
}
private func applyAppearance() {
- let leadingInset = UIMetrics.locationCellLayoutMargins.leading + 6
- directionalLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: leadingInset, bottom: 8, trailing: 24)
+ directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
index 31cbea093e..5fa22383bf 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
@@ -60,10 +60,7 @@ final class LocationViewControllerWrapper: UIViewController {
var onNewSettings: ((LatestTunnelSettings) -> Void)?
private var relayFilter: RelayFilter {
- if case let .only(filter) = settings.relayConstraints.filter {
- return filter
- }
- return RelayFilter()
+ settings.relayConstraints.filter.value ?? RelayFilter()
}
init(
@@ -126,7 +123,7 @@ final class LocationViewControllerWrapper: UIViewController {
private func setRelaysWithLocation() {
let emptyResult = LocationRelays(relays: [], locations: [:])
- let relaysCandidates = try? relaySelectorWrapper.findCandidates(tunnelSettings: self.settings)
+ let relaysCandidates = try? relaySelectorWrapper.findCandidates(tunnelSettings: settings)
entryLocationViewController?.setDaitaChip(settings.daita.isDirectOnly)
exitLocationViewController.setDaitaChip(settings.daita.isDirectOnly && !settings.tunnelMultihopState.isEnabled)
entryLocationViewController?.toggleDaitaAutomaticRouting(isEnabled: settings.daita.isAutomaticRouting)
diff --git a/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift
index 9eb5dfe1ad..64812ff6c5 100644
--- a/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/CheckableSettingsCell.swift
@@ -11,6 +11,12 @@ import UIKit
class CheckableSettingsCell: SettingsCell {
let checkboxView = CheckboxView()
+ var isEnabled = true {
+ didSet {
+ titleLabel.isEnabled = isEnabled
+ }
+ }
+
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)