summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ios/MullvadSettings/CustomListRepository.swift31
-rw-r--r--ios/MullvadSettings/CustomListRepositoryProtocol.swift15
-rw-r--r--ios/MullvadTypes/RelayConstraints.swift25
-rw-r--r--ios/MullvadTypes/RelayLocation.swift25
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj12
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift19
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift16
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift20
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift16
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift100
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift153
-rw-r--r--ios/MullvadVPN/Coordinators/LocationCoordinator.swift105
-rw-r--r--ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift4
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift9
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift18
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift111
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift13
-rw-r--r--ios/MullvadVPNTests/Location/CustomListRepositoryTests.swift38
-rw-r--r--ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift5
-rw-r--r--ios/MullvadVPNTests/MigrationManagerTests.swift4
-rw-r--r--ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift14
-rw-r--r--ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift2
-rw-r--r--ios/MullvadVPNTests/RelayConstraintsTests.swift62
-rw-r--r--ios/MullvadVPNTests/RelaySelectorTests.swift24
-rw-r--r--ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift2
25 files changed, 645 insertions, 198 deletions
diff --git a/ios/MullvadSettings/CustomListRepository.swift b/ios/MullvadSettings/CustomListRepository.swift
index 5b0935faf8..4466b2f9aa 100644
--- a/ios/MullvadSettings/CustomListRepository.swift
+++ b/ios/MullvadSettings/CustomListRepository.swift
@@ -28,12 +28,7 @@ public enum CustomRelayListError: LocalizedError, Equatable {
}
public struct CustomListRepository: CustomListRepositoryProtocol {
- public var publisher: AnyPublisher<[CustomList], Never> {
- passthroughSubject.eraseToAnyPublisher()
- }
-
private let logger = Logger(label: "CustomListRepository")
- private let passthroughSubject = PassthroughSubject<[CustomList], Never>()
private let settingsParser: SettingsParser = {
SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder())
@@ -41,15 +36,17 @@ public struct CustomListRepository: CustomListRepositoryProtocol {
public init() {}
- public func create(_ name: String, locations: [RelayLocation]) throws -> CustomList {
+ public func save(list: CustomList) throws {
var lists = fetchAll()
- if lists.contains(where: { $0.name == name }) {
+
+ if let index = lists.firstIndex(where: { $0.id == list.id }) {
+ lists[index] = list
+ try write(lists)
+ } else if lists.contains(where: { $0.name == list.name }) {
throw CustomRelayListError.duplicateName
} else {
- let item = CustomList(id: UUID(), name: name, locations: locations)
- lists.append(item)
+ lists.append(list)
try write(lists)
- return item
}
}
@@ -72,18 +69,6 @@ public struct CustomListRepository: CustomListRepositoryProtocol {
public func fetchAll() -> [CustomList] {
(try? read()) ?? []
}
-
- public func update(_ list: CustomList) {
- do {
- var lists = fetchAll()
- if let index = lists.firstIndex(where: { $0.id == list.id }) {
- lists[index] = list
- try write(lists)
- }
- } catch {
- logger.error(error: error)
- }
- }
}
extension CustomListRepository {
@@ -97,7 +82,5 @@ extension CustomListRepository {
let data = try settingsParser.produceUnversionedPayload(list)
try SettingsManager.store.write(data, for: .customRelayLists)
-
- passthroughSubject.send(list)
}
}
diff --git a/ios/MullvadSettings/CustomListRepositoryProtocol.swift b/ios/MullvadSettings/CustomListRepositoryProtocol.swift
index 582111b15d..99b50443d5 100644
--- a/ios/MullvadSettings/CustomListRepositoryProtocol.swift
+++ b/ios/MullvadSettings/CustomListRepositoryProtocol.swift
@@ -10,12 +10,9 @@ import Combine
import Foundation
import MullvadTypes
public protocol CustomListRepositoryProtocol {
- /// Publisher that propagates a snapshot of persistent store upon modifications.
- var publisher: AnyPublisher<[CustomList], Never> { get }
-
- /// Persist modified custom list locating existing entry by id.
- /// - Parameter list: persistent custom list model.
- func update(_ list: CustomList)
+ /// Save a custom list. If the list doesn't already exist, it must have a unique name.
+ /// - Parameter list: a custom list.
+ func save(list: CustomList) throws
/// Delete custom list by id.
/// - Parameter id: an access method id.
@@ -26,12 +23,6 @@ public protocol CustomListRepositoryProtocol {
/// - Returns: a persistent custom list model upon success, otherwise `nil`.
func fetch(by id: UUID) -> CustomList?
- /// Create a custom list by unique name.
- /// - Parameter name: a custom list name.
- /// - Parameter locations: locations in a custom list.
- /// - Returns: a persistent custom list model upon success, otherwise throws `Error`.
- func create(_ name: String, locations: [RelayLocation]) throws -> CustomList
-
/// Fetch all custom list.
/// - Returns: all custom list model .
func fetchAll() -> [CustomList]
diff --git a/ios/MullvadTypes/RelayConstraints.swift b/ios/MullvadTypes/RelayConstraints.swift
index a756008e0b..21444a2658 100644
--- a/ios/MullvadTypes/RelayConstraints.swift
+++ b/ios/MullvadTypes/RelayConstraints.swift
@@ -29,14 +29,15 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible
public var filter: RelayConstraint<RelayFilter>
// Added in 2024.1
- public var locations: RelayConstraint<RelayLocations>
+ // Changed from RelayLocations to UserSelectedRelays in 2024.3
+ public var locations: RelayConstraint<UserSelectedRelays>
public var debugDescription: String {
- "RelayConstraints { locations: \(locations), port: \(port) }"
+ "RelayConstraints { locations: \(locations), port: \(port), filter: \(filter) }"
}
public init(
- locations: RelayConstraint<RelayLocations> = .only(RelayLocations(locations: [.country("se")])),
+ locations: RelayConstraint<UserSelectedRelays> = .only(UserSelectedRelays(locations: [.country("se")])),
port: RelayConstraint<UInt16> = .any,
filter: RelayConstraint<RelayFilter> = .any
) {
@@ -53,27 +54,27 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible
filter = try container.decodeIfPresent(RelayConstraint<RelayFilter>.self, forKey: .filter) ?? .any
// Added in 2024.1
- locations = try container.decodeIfPresent(RelayConstraint<RelayLocations>.self, forKey: .locations)
- ?? Self.migrateLocations(decoder: decoder)
- ?? .only(RelayLocations(locations: [.country("se")]))
+ locations = try container.decodeIfPresent(RelayConstraint<UserSelectedRelays>.self, forKey: .locations)
+ ?? Self.migrateRelayLocation(decoder: decoder)
+ ?? .only(UserSelectedRelays(locations: [.country("se")]))
}
}
extension RelayConstraints {
- private static func migrateLocations(decoder: Decoder) -> RelayConstraint<RelayLocations>? {
+ private static func migrateRelayLocation(decoder: Decoder) -> RelayConstraint<UserSelectedRelays>? {
let container = try? decoder.container(keyedBy: CodingKeys.self)
guard
- let location = try? container?.decodeIfPresent(RelayConstraint<RelayLocation>.self, forKey: .location)
+ let relay = try? container?.decodeIfPresent(RelayConstraint<RelayLocation>.self, forKey: .location)
else {
return nil
}
- switch location {
+ return switch relay {
case .any:
- return .any
- case let .only(location):
- return .only(RelayLocations(locations: [location]))
+ .any
+ case let .only(relay):
+ .only(UserSelectedRelays(locations: [relay]))
}
}
}
diff --git a/ios/MullvadTypes/RelayLocation.swift b/ios/MullvadTypes/RelayLocation.swift
index d7dbb8d2a8..279f3cb6bc 100644
--- a/ios/MullvadTypes/RelayLocation.swift
+++ b/ios/MullvadTypes/RelayLocation.swift
@@ -107,6 +107,31 @@ public enum RelayLocation: Codable, Hashable, CustomDebugStringConvertible {
}
}
+public struct UserSelectedRelays: Codable, Equatable {
+ public let locations: [RelayLocation]
+ public let customListSelection: CustomListSelection?
+
+ public init(locations: [RelayLocation], customListSelection: CustomListSelection? = nil) {
+ self.locations = locations
+ self.customListSelection = customListSelection
+ }
+}
+
+extension UserSelectedRelays {
+ public struct CustomListSelection: Codable, Equatable {
+ /// The ID of the custom list that the selected relays belong to.
+ public let listId: UUID
+ /// Whether the selected relays are subnodes or the custom list itself.
+ public let isList: Bool
+
+ public init(listId: UUID, isList: Bool) {
+ self.listId = listId
+ self.isList = isList
+ }
+ }
+}
+
+@available(*, deprecated, message: "Use UserSelectedRelays instead.")
public struct RelayLocations: Codable, Equatable {
public let locations: [RelayLocation]
public let customListId: UUID?
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index e11619a445..fe5e97794e 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -578,12 +578,15 @@
7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */; };
7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1412A2E3306000B728D /* CheckboxView.swift */; };
7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */; };
+ 7AB2B6702BA1EB8C00B03E3B /* ListCustomListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */; };
+ 7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */; };
7AB4CCB92B69097E006037F5 /* IPOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */; };
7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */; };
7ABCA5B32A9349F20044A708 /* Routing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; };
7ABCA5B42A9349F20044A708 /* Routing.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 7A88DCCE2A8FABBE00D2FF0E /* Routing.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
7ABCA5B72A9353C60044A708 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CAF9F72983D36800BE19F7 /* Coordinator.swift */; };
7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */; };
+ 7ABFB09E2BA316220074A49E /* RelayConstraintsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABFB09D2BA316220074A49E /* RelayConstraintsTests.swift */; };
7AC8A3AE2ABC6FBB00DC4939 /* SettingsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */; };
7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */; };
7AD0AA1C2AD6A63F00119E10 /* PacketTunnelActorStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AD0AA1B2AD6A63F00119E10 /* PacketTunnelActorStub.swift */; };
@@ -1815,9 +1818,12 @@
7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelCoordinator.swift; sourceTree = "<group>"; };
7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = "<group>"; };
7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = "<group>"; };
+ 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListViewController.swift; sourceTree = "<group>"; };
+ 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListCoordinator.swift; sourceTree = "<group>"; };
7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideTests.swift; sourceTree = "<group>"; };
7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideInteractor.swift; sourceTree = "<group>"; };
7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = "<group>"; };
+ 7ABFB09D2BA316220074A49E /* RelayConstraintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayConstraintsTests.swift; sourceTree = "<group>"; };
7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = "<group>"; };
7AD0AA192AD69B6E00119E10 /* PacketTunnelActorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorProtocol.swift; sourceTree = "<group>"; };
7AD0AA1B2AD6A63F00119E10 /* PacketTunnelActorStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorStub.swift; sourceTree = "<group>"; };
@@ -2923,6 +2929,7 @@
F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */,
A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */,
A9C342C22ACC3EE90045F00E /* RelayCacheTracker+Stubs.swift */,
+ 7ABFB09D2BA316220074A49E /* RelayConstraintsTests.swift */,
584B26F3237434D00073B10E /* RelaySelectorTests.swift */,
A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */,
A9C342C42ACC42130045F00E /* ServerRelaysResponse+Stubs.swift */,
@@ -3485,6 +3492,8 @@
7A6389E62B7E42BE008E77E1 /* CustomListViewController.swift */,
7A6389D32B7E3BD6008E77E1 /* CustomListViewModel.swift */,
7A6389E42B7E4247008E77E1 /* EditCustomListCoordinator.swift */,
+ 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */,
+ 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */,
);
path = CustomLists;
sourceTree = "<group>";
@@ -4821,6 +4830,7 @@
F09D04B72AE941DA003D4F89 /* OutgoingConnectionProxyTests.swift in Sources */,
F09D04B92AE95111003D4F89 /* OutgoingConnectionProxy.swift in Sources */,
7A6000F92B6273A4001CF0D9 /* AccessMethodViewModel.swift in Sources */,
+ 7ABFB09E2BA316220074A49E /* RelayConstraintsTests.swift in Sources */,
F050AE5C2B73797D003F4EDB /* CustomListRepositoryTests.swift in Sources */,
A9A5F9F62ACB05160083449F /* TunnelStatusNotificationProvider.swift in Sources */,
A9A5F9F72ACB05160083449F /* NotificationProviderProtocol.swift in Sources */,
@@ -5071,6 +5081,7 @@
58FF9FE22B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift in Sources */,
F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */,
7A58699B2B482FE200640D27 /* UITableViewCell+Disable.swift in Sources */,
+ 7AB2B6702BA1EB8C00B03E3B /* ListCustomListViewController.swift in Sources */,
7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */,
7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */,
587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */,
@@ -5231,6 +5242,7 @@
583DA21425FA4B5C00318683 /* LocationDataSource.swift in Sources */,
F050AE602B73A41E003F4EDB /* AllLocationDataSource.swift in Sources */,
587EB6742714520600123C75 /* PreferencesDataSourceDelegate.swift in Sources */,
+ 7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */,
582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */,
7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */,
58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */,
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift
index 6692471ba4..69fb742c47 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/AddCustomListCoordinator.swift
@@ -13,7 +13,7 @@ import UIKit
class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
let navigationController: UINavigationController
- let customListInteractor: CustomListInteractorProtocol
+ let interactor: CustomListInteractorProtocol
var presentedViewController: UIViewController {
navigationController
@@ -23,10 +23,10 @@ class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
init(
navigationController: UINavigationController,
- customListInteractor: CustomListInteractorProtocol
+ interactor: CustomListInteractorProtocol
) {
self.navigationController = navigationController
- self.customListInteractor = customListInteractor
+ self.interactor = interactor
}
func start() {
@@ -35,7 +35,7 @@ class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
)
let controller = CustomListViewController(
- interactor: customListInteractor,
+ interactor: interactor,
subject: subject,
alertPresenter: AlertPresenter(context: self)
)
@@ -55,16 +55,23 @@ class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
comment: ""
)
+ controller.navigationItem.leftBarButtonItem = UIBarButtonItem(
+ systemItem: .cancel,
+ primaryAction: UIAction(handler: { _ in
+ self.didFinish?()
+ })
+ )
+
navigationController.pushViewController(controller, animated: false)
}
}
extension AddCustomListCoordinator: CustomListViewControllerDelegate {
- func customListDidSave() {
+ func customListDidSave(_ list: CustomList) {
didFinish?()
}
- func customListDidDelete() {
+ func customListDidDelete(_ list: CustomList) {
// No op.
}
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift
index 5f129c79cf..f81f060ec8 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift
@@ -9,23 +9,23 @@
import MullvadSettings
protocol CustomListInteractorProtocol {
- func createCustomList(viewModel: CustomListViewModel) throws
- func updateCustomList(viewModel: CustomListViewModel)
- func deleteCustomList(id: UUID)
+ func fetchAll() -> [CustomList]
+ func save(viewModel: CustomListViewModel) throws
+ func delete(id: UUID)
}
struct CustomListInteractor: CustomListInteractorProtocol {
let repository: CustomListRepositoryProtocol
- func createCustomList(viewModel: CustomListViewModel) throws {
- try _ = repository.create(viewModel.name, locations: viewModel.locations)
+ func fetchAll() -> [CustomList] {
+ repository.fetchAll()
}
- func updateCustomList(viewModel: CustomListViewModel) {
- repository.update(viewModel.customList)
+ func save(viewModel: CustomListViewModel) throws {
+ try repository.save(list: viewModel.customList)
}
- func deleteCustomList(id: UUID) {
+ func delete(id: UUID) {
repository.delete(id: id)
}
}
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
index b0a5da3ae6..43ad9ed259 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
@@ -11,8 +11,8 @@ import MullvadSettings
import UIKit
protocol CustomListViewControllerDelegate: AnyObject {
- func customListDidSave()
- func customListDidDelete()
+ func customListDidSave(_ list: CustomList)
+ func customListDidDelete(_ list: CustomList)
func showLocations()
}
@@ -91,13 +91,6 @@ class CustomListViewController: UIViewController {
}
private func configureNavigationItem() {
- navigationItem.leftBarButtonItem = UIBarButtonItem(
- systemItem: .cancel,
- primaryAction: UIAction(handler: { _ in
- self.dismiss(animated: true)
- })
- )
-
navigationItem.rightBarButtonItem = saveBarButton
}
@@ -149,8 +142,8 @@ class CustomListViewController: UIViewController {
private func onSave() {
do {
- try interactor.createCustomList(viewModel: subject.value)
- delegate?.customListDidSave()
+ try interactor.save(viewModel: subject.value)
+ delegate?.customListDidSave(subject.value.customList)
} catch {
validationErrors.insert(.name)
dataSourceConfiguration?.set(validationErrors: validationErrors)
@@ -182,9 +175,8 @@ class CustomListViewController: UIViewController {
),
style: .destructive,
handler: {
- self.interactor.deleteCustomList(id: self.subject.value.id)
- self.dismiss(animated: true)
- self.delegate?.customListDidDelete()
+ self.interactor.delete(id: self.subject.value.id)
+ self.delegate?.customListDidDelete(self.subject.value.customList)
}
),
AlertAction(
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
index ad045714de..d8677161bc 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
@@ -12,6 +12,10 @@ import Routing
import UIKit
class EditCustomListCoordinator: Coordinator, Presentable, Presenting {
+ enum FinishAction {
+ case save, delete
+ }
+
let navigationController: UINavigationController
let customListInteractor: CustomListInteractorProtocol
let customList: CustomList
@@ -20,7 +24,7 @@ class EditCustomListCoordinator: Coordinator, Presentable, Presenting {
navigationController
}
- var didFinish: (() -> Void)?
+ var didFinish: ((FinishAction, CustomList) -> Void)?
init(
navigationController: UINavigationController,
@@ -56,17 +60,17 @@ class EditCustomListCoordinator: Coordinator, Presentable, Presenting {
comment: ""
)
- navigationController.pushViewController(controller, animated: false)
+ navigationController.pushViewController(controller, animated: true)
}
}
extension EditCustomListCoordinator: CustomListViewControllerDelegate {
- func customListDidSave() {
- didFinish?()
+ func customListDidSave(_ list: CustomList) {
+ didFinish?(.save, list)
}
- func customListDidDelete() {
- didFinish?()
+ func customListDidDelete(_ list: CustomList) {
+ didFinish?(.delete, list)
}
func showLocations() {
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift
new file mode 100644
index 0000000000..842d9544e6
--- /dev/null
+++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift
@@ -0,0 +1,100 @@
+//
+// ListCustomListCoordinator.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2024-03-06.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import MullvadTypes
+import Routing
+import UIKit
+
+class ListCustomListCoordinator: Coordinator, Presentable, Presenting {
+ let navigationController: UINavigationController
+ let interactor: CustomListInteractorProtocol
+ let tunnelManager: TunnelManager
+ let listViewController: ListCustomListViewController
+
+ var presentedViewController: UIViewController {
+ navigationController
+ }
+
+ var didFinish: (() -> Void)?
+
+ init(
+ navigationController: UINavigationController,
+ interactor: CustomListInteractorProtocol,
+ tunnelManager: TunnelManager
+ ) {
+ self.navigationController = navigationController
+ self.interactor = interactor
+ self.tunnelManager = tunnelManager
+
+ listViewController = ListCustomListViewController(interactor: interactor)
+ }
+
+ func start() {
+ listViewController.didFinish = didFinish
+ listViewController.didSelectItem = {
+ self.edit(list: $0)
+ }
+
+ navigationController.pushViewController(listViewController, animated: false)
+ }
+
+ private func edit(list: CustomList) {
+ // Remove previous edit coordinator to prevent accumulation.
+ childCoordinators.filter { $0 is EditCustomListCoordinator }.forEach { $0.removeFromParent() }
+
+ let coordinator = EditCustomListCoordinator(
+ navigationController: navigationController,
+ customListInteractor: interactor,
+ customList: list
+ )
+
+ coordinator.didFinish = { action, list in
+ self.popToList()
+ coordinator.removeFromParent()
+
+ self.updateRelayConstraints(for: action, in: list)
+ self.listViewController.updateDataSource(reloadExisting: action == .save)
+ }
+
+ coordinator.start()
+ addChild(coordinator)
+ }
+
+ private func updateRelayConstraints(for action: EditCustomListCoordinator.FinishAction, in list: CustomList) {
+ var relayConstraints = tunnelManager.settings.relayConstraints
+
+ guard let customListSelection = relayConstraints.locations.value?.customListSelection,
+ customListSelection.listId == list.id
+ else { return }
+
+ switch action {
+ case .save:
+ if customListSelection.isList {
+ let selectedRelays = UserSelectedRelays(
+ locations: list.locations,
+ customListSelection: UserSelectedRelays.CustomListSelection(listId: list.id, isList: true)
+ )
+ relayConstraints.locations = .only(selectedRelays)
+ }
+ case .delete:
+ relayConstraints.locations = .only(UserSelectedRelays(locations: []))
+ }
+
+ tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) {
+ self.tunnelManager.startTunnel()
+ }
+ }
+
+ private func popToList() {
+ guard let listController = navigationController.viewControllers
+ .first(where: { $0 is ListCustomListViewController }) else { return }
+
+ navigationController.popToViewController(listController, animated: true)
+ }
+}
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift
new file mode 100644
index 0000000000..25a8e374e6
--- /dev/null
+++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift
@@ -0,0 +1,153 @@
+//
+// ListCustomListViewController.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2024-03-06.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import UIKit
+
+private enum SectionIdentifier: Hashable {
+ case `default`
+}
+
+private struct ItemIdentifier: Hashable {
+ var id: UUID
+}
+
+private enum CellReuseIdentifier: String, CaseIterable, CellIdentifierProtocol {
+ case `default`
+
+ var cellClass: AnyClass {
+ switch self {
+ case .default: BasicCell.self
+ }
+ }
+}
+
+class ListCustomListViewController: UIViewController {
+ private typealias DataSource = UITableViewDiffableDataSource<SectionIdentifier, ItemIdentifier>
+
+ private let interactor: CustomListInteractorProtocol
+ private var dataSource: DataSource?
+ private var fetchedItems: [CustomList] = []
+ private var tableView = UITableView(frame: .zero, style: .plain)
+
+ var didSelectItem: ((CustomList) -> Void)?
+ var didFinish: (() -> Void)?
+
+ init(interactor: CustomListInteractorProtocol) {
+ self.interactor = interactor
+ 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
+
+ addSubviews()
+ configureNavigationItem()
+ configureDataSource()
+ configureTableView()
+ }
+
+ func updateDataSource(reloadExisting: Bool, animated: Bool = true) {
+ fetchedItems = interactor.fetchAll()
+
+ var snapshot = NSDiffableDataSourceSnapshot<SectionIdentifier, ItemIdentifier>()
+ snapshot.appendSections([.default])
+
+ let itemIdentifiers = fetchedItems.map { ItemIdentifier(id: $0.id) }
+ snapshot.appendItems(itemIdentifiers, toSection: .default)
+
+ if reloadExisting {
+ for item in fetchedItems {
+ snapshot.reconfigureOrReloadItems([ItemIdentifier(id: item.id)])
+ }
+ }
+
+ dataSource?.apply(snapshot, animatingDifferences: animated)
+ }
+
+ private func addSubviews() {
+ view.addConstrainedSubviews([tableView]) {
+ tableView.pinEdgesToSuperview()
+ }
+ }
+
+ private func configureTableView() {
+ tableView.delegate = self
+ tableView.backgroundColor = .secondaryColor
+ tableView.separatorColor = .secondaryColor
+ tableView.separatorInset = .zero
+ tableView.contentInset.top = 16
+ tableView.rowHeight = UIMetrics.SettingsCell.customListsCellHeight
+
+ tableView.registerReusableViews(from: CellReuseIdentifier.self)
+ }
+
+ private func configureNavigationItem() {
+ navigationItem.title = NSLocalizedString(
+ "LIST_CUSTOM_LIST_NAVIGATION_TITLE",
+ tableName: "CustomList",
+ value: "Edit custom list",
+ comment: ""
+ )
+
+ navigationItem.rightBarButtonItem = UIBarButtonItem(
+ systemItem: .done,
+ primaryAction: UIAction(handler: { [weak self] _ in
+ self?.didFinish?()
+ })
+ )
+ }
+
+ private func configureDataSource() {
+ dataSource = DataSource(
+ tableView: tableView,
+ cellProvider: { [weak self] _, indexPath, itemIdentifier in
+ self?.dequeueCell(at: indexPath, itemIdentifier: itemIdentifier)
+ }
+ )
+
+ updateDataSource(reloadExisting: false, animated: false)
+ }
+
+ private func dequeueCell(
+ at indexPath: IndexPath,
+ itemIdentifier: ItemIdentifier
+ ) -> UITableViewCell {
+ let cell = tableView.dequeueReusableView(withIdentifier: CellReuseIdentifier.default, for: indexPath)
+ let item = fetchedItems[indexPath.row]
+
+ var contentConfiguration = ListCellContentConfiguration()
+ contentConfiguration.text = item.name
+ cell.contentConfiguration = contentConfiguration
+
+ if let cell = cell as? DynamicBackgroundConfiguration {
+ cell.setAutoAdaptingBackgroundConfiguration(.mullvadListPlainCell(), selectionType: .dimmed)
+ }
+
+ if let cell = cell as? CustomCellDisclosureHandling {
+ cell.disclosureType = .chevron
+ }
+
+ return cell
+ }
+}
+
+extension ListCustomListViewController: UITableViewDelegate {
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ tableView.deselectRow(at: indexPath, animated: false)
+
+ let item = fetchedItems[indexPath.row]
+ didSelectItem?(item)
+ }
+}
diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift
index 4d18672f5a..8da5a6dca2 100644
--- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift
@@ -12,7 +12,7 @@ import MullvadTypes
import Routing
import UIKit
-class LocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrackerObserver {
+class LocationCoordinator: Coordinator, Presentable, Presenting {
private let tunnelManager: TunnelManager
private let relayCacheTracker: RelayCacheTracker
private var cachedRelays: CachedRelays?
@@ -24,7 +24,7 @@ class LocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrack
navigationController
}
- var selectLocationViewController: LocationViewController? {
+ var locationViewController: LocationViewController? {
return navigationController.viewControllers.first {
$0 is LocationViewController
} as? LocationViewController
@@ -58,7 +58,6 @@ class LocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrack
locationViewController.delegate = self
locationViewController.didSelectRelays = { [weak self] locations in
-
guard let self else { return }
var relayConstraints = tunnelManager.settings.relayConstraints
@@ -119,7 +118,7 @@ class LocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrack
relayFilterCoordinator.didFinish = { [weak self] coordinator, filter in
if let cachedRelays = self?.cachedRelays, let filter {
- self?.selectLocationViewController?.setCachedRelays(cachedRelays, filter: filter)
+ self?.locationViewController?.setCachedRelays(cachedRelays, filter: filter)
}
coordinator.dismiss(animated: true)
@@ -128,18 +127,112 @@ class LocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrack
return relayFilterCoordinator
}
+ private func showAddCustomList() {
+ let coordinator = AddCustomListCoordinator(
+ navigationController: CustomNavigationController(),
+ interactor: CustomListInteractor(repository: customListRepository)
+ )
+
+ coordinator.didFinish = {
+ coordinator.dismiss(animated: true)
+ self.locationViewController?.refreshCustomLists()
+ }
+
+ coordinator.start()
+ presentChild(coordinator, animated: true)
+ }
+
+ private func showEditCustomLists() {
+ let coordinator = ListCustomListCoordinator(
+ navigationController: CustomNavigationController(),
+ interactor: CustomListInteractor(repository: customListRepository),
+ tunnelManager: tunnelManager
+ )
+
+ coordinator.didFinish = {
+ coordinator.dismiss(animated: true)
+ self.locationViewController?.refreshCustomLists()
+ }
+
+ coordinator.start()
+ presentChild(coordinator, animated: true)
+
+ coordinator.presentedViewController.presentationController?.delegate = self
+ }
+}
+
+// Intercept dismissal (by down swipe) of ListCustomListCoordinator and apply custom actions.
+// See showEditCustomLists() above.
+extension LocationCoordinator: UIAdaptivePresentationControllerDelegate {
+ func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
+ locationViewController?.refreshCustomLists()
+ }
+}
+
+extension LocationCoordinator: RelayCacheTrackerObserver {
func relayCacheTracker(
_ tracker: RelayCacheTracker,
didUpdateCachedRelays cachedRelays: CachedRelays
) {
self.cachedRelays = cachedRelays
- selectLocationViewController?.setCachedRelays(cachedRelays, filter: relayFilter)
+ locationViewController?.setCachedRelays(cachedRelays, filter: relayFilter)
}
}
extension LocationCoordinator: LocationViewControllerDelegate {
func didRequestRouteToCustomLists(_ controller: LocationViewController) {
- // TODO: Show add/Edit bottom sheet.
+ let actionSheet = UIAlertController(
+ title: NSLocalizedString(
+ "CUSTOM_LIST_ACTION_SHEET_TITLE",
+ tableName: "CustomLists",
+ value: "Custom lists",
+ comment: ""
+ ),
+ message: nil,
+ preferredStyle: .actionSheet
+ )
+
+ actionSheet.addAction(UIAlertAction(
+ title: NSLocalizedString(
+ "CUSTOM_LIST_ACTION_SHEET_ADD_LIST_BUTTON",
+ tableName: "CustomLists",
+ value: "Add new list",
+ comment: ""
+ ),
+ style: .default,
+ handler: { _ in
+ self.showAddCustomList()
+ }
+ ))
+
+ actionSheet.addAction(UIAlertAction(
+ title: NSLocalizedString(
+ "CUSTOM_LIST_ACTION_SHEET_EDIT_LISTS_BUTTON",
+ tableName: "CustomLists",
+ value: "Edit lists",
+ comment: ""
+ ),
+ style: .default,
+ handler: { _ in
+ self.showEditCustomLists()
+ }
+ ))
+
+ actionSheet.addAction(UIAlertAction(
+ title: NSLocalizedString(
+ "CUSTOM_LIST_ACTION_SHEET_CANCEL_BUTTON",
+ tableName: "CustomLists",
+ value: "Cancel",
+ comment: ""
+ ),
+ style: .cancel,
+ handler: nil
+ ))
+
+ actionSheet.overrideUserInterfaceStyle = .dark
+ actionSheet.view.tintColor = .white
+
+ presentationContext.present(actionSheet, animated: true)
}
}
diff --git a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
index 93e17e7af9..e7bf690f87 100644
--- a/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
+++ b/ios/MullvadVPN/SimulatorTunnelProvider/SimulatorTunnelProviderHost.swift
@@ -146,8 +146,8 @@ final class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate {
completionHandler?(reply)
}
- case let .cancelURLRequest(id):
- urlRequestProxy.cancelRequest(identifier: id)
+ case let .cancelURLRequest(listId):
+ urlRequestProxy.cancelRequest(identifier: listId)
completionHandler?(nil)
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift
index a41f0eb9e5..7d5639e00d 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift
@@ -50,16 +50,15 @@ class CustomListsDataSource: LocationDataSourceProtocol {
}
}
- func node(by locations: [RelayLocation], for customList: CustomList) -> LocationNode? {
- guard let listNode = nodes.first(where: { $0.name == customList.name })
- else { return nil }
+ func node(by relays: UserSelectedRelays, for customList: CustomList) -> LocationNode? {
+ guard let listNode = nodes.first(where: { $0.name == customList.name }) else { return nil }
- if locations.count > 1 {
+ if relays.customListSelection?.isList == true {
return listNode
} else {
// Each search for descendant nodes needs the parent custom list node code to be
// prefixed in order to get a match. See comment in reload() above.
- return switch locations.first {
+ return switch relays.locations.first {
case let .country(countryCode):
listNode.descendantNodeFor(codes: [listNode.code, countryCode])
case let .city(countryCode, cityCode):
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift b/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift
index 999b4ad110..7123e19a24 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/InMemoryCustomListRepository.swift
@@ -12,10 +12,6 @@ import MullvadSettings
import MullvadTypes
class InMemoryCustomListRepository: CustomListRepositoryProtocol {
- var publisher: AnyPublisher<[CustomList], Never> {
- passthroughSubject.eraseToAnyPublisher()
- }
-
private var customRelayLists: [CustomList] = [
CustomList(
id: UUID(uuidString: "F17948CB-18E2-4F84-82CD-5780F94216DB")!,
@@ -33,11 +29,13 @@ class InMemoryCustomListRepository: CustomListRepositoryProtocol {
),
]
- private let passthroughSubject = PassthroughSubject<[CustomList], Never>()
-
- func update(_ list: CustomList) {
+ func save(list: MullvadSettings.CustomList) throws {
if let index = customRelayLists.firstIndex(where: { $0.id == list.id }) {
customRelayLists[index] = list
+ } else if customRelayLists.contains(where: { $0.name == list.name }) {
+ throw CustomRelayListError.duplicateName
+ } else {
+ customRelayLists.append(list)
}
}
@@ -51,12 +49,6 @@ class InMemoryCustomListRepository: CustomListRepositoryProtocol {
return customRelayLists.first(where: { $0.id == id })
}
- func create(_ name: String, locations: [RelayLocation]) throws -> CustomList {
- let item = CustomList(id: UUID(), name: name, locations: locations)
- customRelayLists.append(item)
- return item
- }
-
func fetchAll() -> [CustomList] {
customRelayLists
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
index 77da5c69b9..4e95c9f592 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
@@ -18,7 +18,7 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
private var dataSources: [LocationDataSourceProtocol] = []
private var selectedItem: LocationCellViewModel?
- var didSelectRelayLocations: ((RelayLocations) -> Void)?
+ var didSelectRelayLocations: ((UserSelectedRelays) -> Void)?
var didTapEditCustomLists: (() -> Void)?
init(
@@ -35,11 +35,8 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
super.init(tableView: tableView) { _, indexPath, itemIdentifier in
let reuseIdentifier = LocationSection.Cell.locationCell.reuseIdentifier
- let cell = tableView.dequeueReusableCell(
- withIdentifier: reuseIdentifier,
- for: indexPath
- // swiftlint:disable:next force_cast
- ) as! LocationCell
+ // swiftlint:disable:next force_cast
+ let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as! LocationCell
cell.configureCell(item: itemIdentifier)
return cell
}
@@ -49,7 +46,7 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
registerClasses()
}
- func setRelays(_ response: REST.ServerRelaysResponse, selectedLocations: RelayLocations?, filter: RelayFilter) {
+ func setRelays(_ response: REST.ServerRelaysResponse, selectedRelays: UserSelectedRelays?, filter: RelayFilter) {
let allLocationsDataSource =
dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource
@@ -63,23 +60,11 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
allLocationsDataSource?.reload(response, relays: relays)
customListsDataSource?.reload(allLocationNodes: allLocationsDataSource?.nodes ?? [])
- if let selectedLocations {
- // Look for a matching custom list node.
- if let customListId = selectedLocations.customListId,
- let customList = customListsDataSource?.customList(by: customListId),
- let selectedNode = customListsDataSource?.node(by: selectedLocations.locations, for: customList) {
- selectedItem = LocationCellViewModel(section: .customLists, node: selectedNode)
- // Look for a matching all locations node.
- } else if let location = selectedLocations.locations.first,
- let selectedNode = allLocationsDataSource?.node(by: location) {
- selectedItem = LocationCellViewModel(section: .allLocations, node: selectedNode)
- }
- }
-
+ mapSelectedItem(from: selectedRelays)
filterRelays(by: currentSearchString)
}
- func filterRelays(by searchString: String) {
+ func filterRelays(by searchString: String, scrollToSelected: Bool = true) {
currentSearchString = searchString
let list = LocationSection.allCases.enumerated().map { index, section in
@@ -92,6 +77,11 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
}
updateDataSnapshot(with: list, reloadExisting: !searchString.isEmpty) {
+ guard scrollToSelected else {
+ self.setSelectedItem(self.selectedItem, animated: false)
+ return
+ }
+
DispatchQueue.main.async {
if searchString.isEmpty {
self.setSelectedItem(self.selectedItem, animated: false, completion: {
@@ -104,6 +94,19 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
}
}
+ func refreshCustomLists(selectedRelays: UserSelectedRelays?) {
+ let allLocationsDataSource =
+ dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource
+
+ let customListsDataSource =
+ dataSources.first(where: { $0 is CustomListsDataSource }) as? CustomListsDataSource
+
+ customListsDataSource?.reload(allLocationNodes: allLocationsDataSource?.nodes ?? [])
+
+ mapSelectedItem(from: selectedRelays)
+ filterRelays(by: currentSearchString, scrollToSelected: false)
+ }
+
private func indexPathForSelectedRelay() -> IndexPath? {
selectedItem.flatMap { indexPath(for: $0) }
}
@@ -119,11 +122,13 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
snapshot.appendSections(sections)
for (index, section) in sections.enumerated() {
- snapshot.appendItems(list[index], toSection: section)
- }
+ let items = list[index]
- if reloadExisting {
- snapshot.reloadSections(sections)
+ snapshot.appendItems(items, toSection: section)
+
+ if reloadExisting {
+ snapshot.reconfigureOrReloadItems(items)
+ }
}
apply(snapshot, animatingDifferences: animated, completion: completion)
@@ -131,18 +136,32 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
private func registerClasses() {
LocationSection.allCases.forEach {
- tableView.register(
- $0.cell.reusableViewClass,
- forCellReuseIdentifier: $0.cell.reuseIdentifier
- )
+ tableView.register($0.cell.reusableViewClass, forCellReuseIdentifier: $0.cell.reuseIdentifier)
}
}
- private func setSelectedItem(
- _ item: LocationCellViewModel?,
- animated: Bool,
- completion: (() -> Void)? = nil
- ) {
+ private func mapSelectedItem(from selectedRelays: UserSelectedRelays?) {
+ let allLocationsDataSource =
+ dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource
+
+ let customListsDataSource =
+ dataSources.first(where: { $0 is CustomListsDataSource }) as? CustomListsDataSource
+
+ if let selectedRelays {
+ // Look for a matching custom list node.
+ if let customListSelection = selectedRelays.customListSelection,
+ let customList = customListsDataSource?.customList(by: customListSelection.listId),
+ let selectedNode = customListsDataSource?.node(by: selectedRelays, for: customList) {
+ selectedItem = LocationCellViewModel(section: .customLists, node: selectedNode)
+ // Look for a matching all locations node.
+ } else if let location = selectedRelays.locations.first,
+ let selectedNode = allLocationsDataSource?.node(by: location) {
+ selectedItem = LocationCellViewModel(section: .allLocations, node: selectedNode)
+ }
+ }
+ }
+
+ private func setSelectedItem(_ item: LocationCellViewModel?, animated: Bool, completion: (() -> Void)? = nil) {
selectedItem = item
guard let selectedItem else { return }
@@ -202,14 +221,12 @@ final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, L
)
)
- let indentationLevel = indentationLevel + 1
-
if childNode.showsChildren {
viewModels.append(
contentsOf: recursivelyCreateCellViewModelTree(
for: childNode,
in: section,
- indentationLevel: indentationLevel
+ indentationLevel: indentationLevel + 1
)
)
}
@@ -262,11 +279,7 @@ extension LocationDataSource: UITableViewDelegate {
itemIdentifier(for: indexPath)?.indentationLevel ?? 0
}
- func tableView(
- _ tableView: UITableView,
- willDisplay cell: UITableViewCell,
- forRowAt indexPath: IndexPath
- ) {
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if let item = itemIdentifier(for: indexPath),
item == selectedItem {
cell.setSelected(true, animated: false)
@@ -278,8 +291,18 @@ extension LocationDataSource: UITableViewDelegate {
guard let item = itemIdentifier(for: indexPath) else { return }
- let topmostNode = item.node.root as? CustomListLocationNode
- let relayLocations = RelayLocations(locations: item.node.locations, customListId: topmostNode?.customList.id)
+ var customListSelection: UserSelectedRelays.CustomListSelection?
+ if let topmostNode = item.node.root as? CustomListLocationNode {
+ customListSelection = UserSelectedRelays.CustomListSelection(
+ listId: topmostNode.customList.id,
+ isList: topmostNode == item.node
+ )
+ }
+
+ let relayLocations = UserSelectedRelays(
+ locations: item.node.locations,
+ customListSelection: customListSelection
+ )
didSelectRelayLocations?(relayLocations)
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
index baa3cce181..2b3a1f8c15 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
@@ -24,7 +24,7 @@ final class LocationViewController: UIViewController {
private var dataSource: LocationDataSource?
private var cachedRelays: CachedRelays?
private var filter = RelayFilter()
- var relayLocations: RelayLocations?
+ var relayLocations: UserSelectedRelays?
weak var delegate: LocationViewControllerDelegate?
var customListRepository: CustomListRepositoryProtocol
@@ -37,7 +37,7 @@ final class LocationViewController: UIViewController {
}
var navigateToFilter: (() -> Void)?
- var didSelectRelays: ((RelayLocations) -> Void)?
+ var didSelectRelays: ((UserSelectedRelays) -> Void)?
var didUpdateFilter: ((RelayFilter) -> Void)?
var didFinish: (() -> Void)?
@@ -114,7 +114,11 @@ final class LocationViewController: UIViewController {
filterView.setFilter(filter)
}
- dataSource?.setRelays(cachedRelays.relays, selectedLocations: relayLocations, filter: filter)
+ dataSource?.setRelays(cachedRelays.relays, selectedRelays: relayLocations, filter: filter)
+ }
+
+ func refreshCustomLists() {
+ dataSource?.refreshCustomLists(selectedRelays: relayLocations)
}
// MARK: - Private
@@ -122,6 +126,7 @@ final class LocationViewController: UIViewController {
private func setUpDataSources() {
let allLocationDataSource = AllLocationDataSource()
let customListsDataSource = CustomListsDataSource(repository: customListRepository)
+
dataSource = LocationDataSource(
tableView: tableView,
allLocations: allLocationDataSource,
@@ -138,7 +143,7 @@ final class LocationViewController: UIViewController {
}
if let cachedRelays {
- dataSource?.setRelays(cachedRelays.relays, selectedLocations: relayLocations, filter: filter)
+ dataSource?.setRelays(cachedRelays.relays, selectedRelays: relayLocations, filter: filter)
}
}
diff --git a/ios/MullvadVPNTests/Location/CustomListRepositoryTests.swift b/ios/MullvadVPNTests/Location/CustomListRepositoryTests.swift
index be7bffc24e..bb54ec2a6e 100644
--- a/ios/MullvadVPNTests/Location/CustomListRepositoryTests.swift
+++ b/ios/MullvadVPNTests/Location/CustomListRepositoryTests.swift
@@ -29,33 +29,47 @@ class CustomListRepositoryTests: XCTestCase {
}
func testFailedAddingDuplicateCustomList() throws {
- let name = "Netflix"
- let item = try XCTUnwrap(repository.create(name, locations: []))
+ let item1 = CustomList(name: "Netflix", locations: [])
+ let item2 = CustomList(name: "Netflix", locations: [])
- XCTAssertThrowsError(try repository.create(item.name, locations: [])) { error in
+ try XCTAssertNoThrow(repository.save(list: item1))
+
+ XCTAssertThrowsError(try repository.save(list: item2)) { error in
XCTAssertEqual(error as? CustomRelayListError, CustomRelayListError.duplicateName)
}
}
func testAddingCustomList() throws {
- let name = "Netflix"
+ let item = CustomList(name: "Netflix", locations: [
+ .country("SE"),
+ .city("SE", "Gothenburg"),
+ ])
+ try repository.save(list: item)
- let item = try XCTUnwrap(repository.create(name, locations: [
+ let storedItem = repository.fetch(by: item.id)
+ XCTAssertEqual(storedItem, item)
+ }
+
+ func testUpdatingCustomList() throws {
+ var item = CustomList(name: "Netflix", locations: [
.country("SE"),
.city("SE", "Gothenburg"),
- ]))
+ ])
+ try repository.save(list: item)
+
+ item.locations.append(.country("FR"))
+ try repository.save(list: item)
let storedItem = repository.fetch(by: item.id)
XCTAssertEqual(storedItem, item)
}
func testDeletingCustomList() throws {
- let name = "Netflix"
-
- let item = try XCTUnwrap(repository.create(name, locations: [
+ let item = CustomList(name: "Netflix", locations: [
.country("SE"),
.city("SE", "Gothenburg"),
- ]))
+ ])
+ try repository.save(list: item)
let storedItem = repository.fetch(by: item.id)
repository.delete(id: try XCTUnwrap(storedItem?.id))
@@ -64,12 +78,12 @@ class CustomListRepositoryTests: XCTestCase {
}
func testFetchingAllCustomList() throws {
- _ = try XCTUnwrap(repository.create("Netflix", locations: [
+ try repository.save(list: CustomList(name: "Netflix", locations: [
.country("FR"),
.city("SE", "Gothenburg"),
]))
- _ = try XCTUnwrap(repository.create("PS5", locations: [
+ try repository.save(list: CustomList(name: "PS5", locations: [
.country("DE"),
.city("SE", "Gothenburg"),
]))
diff --git a/ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift b/ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift
index 0120322702..2b6fc5b5e8 100644
--- a/ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift
+++ b/ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift
@@ -7,6 +7,7 @@
//
@testable import MullvadSettings
+@testable import MullvadTypes
import XCTest
class CustomListsDataSourceTests: XCTestCase {
@@ -50,7 +51,9 @@ class CustomListsDataSourceTests: XCTestCase {
}
func testNodeByLocations() throws {
- let nodeByLocations = dataSource.node(by: [.hostname("es", "mad", "es1-wireguard")], for: customLists.first!)
+ let relays = UserSelectedRelays(locations: [.hostname("es", "mad", "es1-wireguard")], customListSelection: nil)
+
+ let nodeByLocations = dataSource.node(by: relays, for: customLists.first!)
let nodeByCode = dataSource.nodes.first?.descendantNodeFor(codes: ["netflix", "es1-wireguard"])
XCTAssertEqual(nodeByLocations, nodeByCode)
diff --git a/ios/MullvadVPNTests/MigrationManagerTests.swift b/ios/MullvadVPNTests/MigrationManagerTests.swift
index bfe7213628..ace14e8eb0 100644
--- a/ios/MullvadVPNTests/MigrationManagerTests.swift
+++ b/ios/MullvadVPNTests/MigrationManagerTests.swift
@@ -122,7 +122,7 @@ final class MigrationManagerTests: XCTestCase {
func testSuccessfulMigrationFromV2ToLatest() throws {
var settingsV2 = TunnelSettingsV2()
let osakaRelayConstraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.city("jp", "osa")]))
+ locations: .only(UserSelectedRelays(locations: [.city("jp", "osa")]))
)
settingsV2.relayConstraints = osakaRelayConstraints
@@ -136,7 +136,7 @@ final class MigrationManagerTests: XCTestCase {
func testSuccessfulMigrationFromV1ToLatest() throws {
var settingsV1 = TunnelSettingsV1()
let osakaRelayConstraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.city("jp", "osa")]))
+ locations: .only(UserSelectedRelays(locations: [.city("jp", "osa")]))
)
settingsV1.relayConstraints = osakaRelayConstraints
diff --git a/ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift b/ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift
index 06bbd9d5f3..782d8c4d82 100644
--- a/ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift
+++ b/ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift
@@ -13,15 +13,7 @@ import MullvadTypes
struct CustomListsRepositoryStub: CustomListRepositoryProtocol {
let customLists: [CustomList]
- var publisher: AnyPublisher<[CustomList], Never> {
- PassthroughSubject().eraseToAnyPublisher()
- }
-
- init(customLists: [CustomList]) {
- self.customLists = customLists
- }
-
- func update(_ list: CustomList) {}
+ func save(list: CustomList) throws {}
func delete(id: UUID) {}
@@ -29,10 +21,6 @@ struct CustomListsRepositoryStub: CustomListRepositoryProtocol {
nil
}
- func create(_ name: String, locations: [RelayLocation]) throws -> CustomList {
- CustomList(name: "", locations: [])
- }
-
func fetchAll() -> [CustomList] {
customLists
}
diff --git a/ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift b/ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift
index 1a89f822a2..89a234f3ce 100644
--- a/ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift
+++ b/ios/MullvadVPNTests/MullvadSettings/TunnelSettingsUpdateTests.swift
@@ -48,7 +48,7 @@ final class TunnelSettingsUpdateTests: XCTestCase {
// When:
let relayConstraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.country("zz")])),
+ locations: .only(UserSelectedRelays(locations: [.country("zz")])),
port: .only(9999),
filter: .only(.init(ownership: .rented, providers: .only(["foo", "bar"])))
)
diff --git a/ios/MullvadVPNTests/RelayConstraintsTests.swift b/ios/MullvadVPNTests/RelayConstraintsTests.swift
new file mode 100644
index 0000000000..401dc13edd
--- /dev/null
+++ b/ios/MullvadVPNTests/RelayConstraintsTests.swift
@@ -0,0 +1,62 @@
+//
+// RelayConstraintsTests.swift
+// MullvadVPNTests
+//
+// Created by Jon Petersson on 2024-03-14.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+@testable import MullvadTypes
+import XCTest
+
+// There's currently no test for migrating from V2 (RelayConstraint<RelayLocations>) to
+// V3 (RelayConstraint<UserSelectedLocations>) due to the only part being changed was an
+// optional property. Even if the stored version is V2, the decoder still matches the
+// required property of V3 and then disregards the optional, resulting in a successful
+// migration. This doesn't affect any end users since during V2 there was no way to
+// access the affected features from a release build.
+final class RelayConstraintsTests: XCTestCase {
+ func testMigratingConstraintsFromV1ToLatest() throws {
+ let constraintsFromJson = try parseData(from: constraintsV1)
+
+ let constraintsFromInit = RelayConstraints(
+ locations: .only(UserSelectedRelays(locations: [.city("se", "got")])),
+ port: .only(80),
+ filter: .only(RelayFilter(ownership: .rented, providers: .any))
+ )
+
+ XCTAssertEqual(constraintsFromJson, constraintsFromInit)
+ }
+}
+
+extension RelayConstraintsTests {
+ private func parseData(from constraintsString: String) throws -> RelayConstraints {
+ let data = constraintsString.data(using: .utf8)!
+ let decoder = JSONDecoder()
+
+ return try decoder.decode(RelayConstraints.self, from: data)
+ }
+}
+
+extension RelayConstraintsTests {
+ private var constraintsV1: String {
+ return """
+ {
+ "port": {
+ "only": 80
+ },
+ "location": {
+ "only": ["se", "got"]
+ },
+ "filter": {
+ "only": {
+ "providers": "any",
+ "ownership": {
+ "rented": {}
+ }
+ }
+ }
+ }
+ """
+ }
+}
diff --git a/ios/MullvadVPNTests/RelaySelectorTests.swift b/ios/MullvadVPNTests/RelaySelectorTests.swift
index 34e1c2d1d4..68f48c0112 100644
--- a/ios/MullvadVPNTests/RelaySelectorTests.swift
+++ b/ios/MullvadVPNTests/RelaySelectorTests.swift
@@ -19,7 +19,7 @@ class RelaySelectorTests: XCTestCase {
func testCountryConstraint() throws {
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.country("es")]))
+ locations: .only(UserSelectedRelays(locations: [.country("es")]))
)
let result = try RelaySelector.evaluate(
@@ -33,7 +33,7 @@ class RelaySelectorTests: XCTestCase {
func testCityConstraint() throws {
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.city("se", "got")]))
+ locations: .only(UserSelectedRelays(locations: [.city("se", "got")]))
)
let result = try RelaySelector.evaluate(
@@ -47,7 +47,7 @@ class RelaySelectorTests: XCTestCase {
func testHostnameConstraint() throws {
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.hostname("se", "sto", "se6-wireguard")]))
+ locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")]))
)
let result = try RelaySelector.evaluate(
@@ -61,7 +61,7 @@ class RelaySelectorTests: XCTestCase {
func testMultipleLocationsConstraint() throws {
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [
+ locations: .only(UserSelectedRelays(locations: [
.city("se", "got"),
.hostname("se", "sto", "se6-wireguard"),
]))
@@ -100,7 +100,7 @@ class RelaySelectorTests: XCTestCase {
func testSpecificPortConstraint() throws {
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.hostname("se", "sto", "se6-wireguard")])),
+ locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])),
port: .only(1)
)
@@ -115,7 +115,7 @@ class RelaySelectorTests: XCTestCase {
func testRandomPortSelectionWithFailedAttempts() throws {
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.hostname("se", "sto", "se6-wireguard")]))
+ locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")]))
)
let allPorts = portRanges.flatMap { $0 }
@@ -141,7 +141,7 @@ class RelaySelectorTests: XCTestCase {
func testClosestShadowsocksRelay() throws {
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.city("se", "sto")]))
+ locations: .only(UserSelectedRelays(locations: [.city("se", "sto")]))
)
let selectedRelay = RelaySelector.closestShadowsocksRelayConstrained(by: constraints, in: sampleRelays)
@@ -151,7 +151,7 @@ class RelaySelectorTests: XCTestCase {
func testClosestShadowsocksRelayIsRandomWhenNoContraintsAreSatisfied() throws {
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.country("INVALID COUNTRY")]))
+ locations: .only(UserSelectedRelays(locations: [.country("INVALID COUNTRY")]))
)
let selectedRelay = try XCTUnwrap(RelaySelector.closestShadowsocksRelayConstrained(
@@ -166,7 +166,7 @@ class RelaySelectorTests: XCTestCase {
let filter = RelayFilter(ownership: .owned, providers: .any)
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.hostname("se", "sto", "se6-wireguard")])),
+ locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])),
filter: .only(filter)
)
@@ -183,7 +183,7 @@ class RelaySelectorTests: XCTestCase {
let filter = RelayFilter(ownership: .rented, providers: .any)
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.hostname("se", "sto", "se6-wireguard")])),
+ locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])),
filter: .only(filter)
)
@@ -201,7 +201,7 @@ class RelaySelectorTests: XCTestCase {
let filter = RelayFilter(ownership: .any, providers: .only([provider]))
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.hostname("se", "sto", "se6-wireguard")])),
+ locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])),
filter: .only(filter)
)
@@ -219,7 +219,7 @@ class RelaySelectorTests: XCTestCase {
let filter = RelayFilter(ownership: .any, providers: .only([provider]))
let constraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.hostname("se", "sto", "se6-wireguard")])),
+ locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")])),
filter: .only(filter)
)
diff --git a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift
index 94dcbcf500..97cf010b34 100644
--- a/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift
+++ b/ios/PacketTunnelCoreTests/AppMessageHandlerTests.swift
@@ -78,7 +78,7 @@ final class AppMessageHandlerTests: XCTestCase {
let appMessageHandler = createAppMessageHandler(actor: actor)
let relayConstraints = RelayConstraints(
- locations: .only(RelayLocations(locations: [.hostname("se", "sto", "se6-wireguard")]))
+ locations: .only(UserSelectedRelays(locations: [.hostname("se", "sto", "se6-wireguard")]))
)
let selectorResult = try XCTUnwrap(try? RelaySelector.evaluate(
relays: ServerRelaysResponseStubs.sampleRelays,