diff --git a/ios/MullvadSettings/CustomListRepository.swift b/ios/MullvadSettings/CustomListRepository.swift index 5b0935faf882..4466b2f9aa7d 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 582111b15d33..99b50443d52a 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 a756008e0bd5..21444a26586f 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 // Added in 2024.1 - public var locations: RelayConstraint + // Changed from RelayLocations to UserSelectedRelays in 2024.3 + public var locations: RelayConstraint public var debugDescription: String { - "RelayConstraints { locations: \(locations), port: \(port) }" + "RelayConstraints { locations: \(locations), port: \(port), filter: \(filter) }" } public init( - locations: RelayConstraint = .only(RelayLocations(locations: [.country("se")])), + locations: RelayConstraint = .only(UserSelectedRelays(locations: [.country("se")])), port: RelayConstraint = .any, filter: RelayConstraint = .any ) { @@ -53,27 +54,27 @@ public struct RelayConstraints: Codable, Equatable, CustomDebugStringConvertible filter = try container.decodeIfPresent(RelayConstraint.self, forKey: .filter) ?? .any // Added in 2024.1 - locations = try container.decodeIfPresent(RelayConstraint.self, forKey: .locations) - ?? Self.migrateLocations(decoder: decoder) - ?? .only(RelayLocations(locations: [.country("se")])) + locations = try container.decodeIfPresent(RelayConstraint.self, forKey: .locations) + ?? Self.migrateRelayLocation(decoder: decoder) + ?? .only(UserSelectedRelays(locations: [.country("se")])) } } extension RelayConstraints { - private static func migrateLocations(decoder: Decoder) -> RelayConstraint? { + private static func migrateRelayLocation(decoder: Decoder) -> RelayConstraint? { let container = try? decoder.container(keyedBy: CodingKeys.self) guard - let location = try? container?.decodeIfPresent(RelayConstraint.self, forKey: .location) + let relay = try? container?.decodeIfPresent(RelayConstraint.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 d7dbb8d2a896..279f3cb6bc8e 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 e11619a44595..fe5e97794ee9 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 = ""; }; 7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = ""; }; 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = ""; }; + 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListViewController.swift; sourceTree = ""; }; + 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListCoordinator.swift; sourceTree = ""; }; 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideTests.swift; sourceTree = ""; }; 7AB4CCBA2B691BBB006037F5 /* IPOverrideInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideInteractor.swift; sourceTree = ""; }; 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = ""; }; + 7ABFB09D2BA316220074A49E /* RelayConstraintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayConstraintsTests.swift; sourceTree = ""; }; 7AC8A3AD2ABC6FBB00DC4939 /* SettingsHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; 7AD0AA192AD69B6E00119E10 /* PacketTunnelActorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorProtocol.swift; sourceTree = ""; }; 7AD0AA1B2AD6A63F00119E10 /* PacketTunnelActorStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorStub.swift; sourceTree = ""; }; @@ -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 = ""; @@ -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 6692471ba443..69fb742c4778 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 5f129c79cfed..f81f060ec84f 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 b0a5da3ae616..43ad9ed259c4 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 ad045714de32..d8677161bc01 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 000000000000..842d9544e6ff --- /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 000000000000..25a8e374e6ff --- /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 + + 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() + 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 4d18672f5ae3..8da5a6dca285 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 93e17e7af998..e7bf690f872d 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 a41f0eb9e56b..7d5639e00d4e 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 999b4ad1108a..7123e19a24d0 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 77da5c69b907..4e95c9f59212 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 Void)? + var didSelectRelayLocations: ((UserSelectedRelays) -> Void)? var didTapEditCustomLists: (() -> Void)? init( @@ -35,11 +35,8 @@ final class LocationDataSource: UITableViewDiffableDataSource IndexPath? { selectedItem.flatMap { indexPath(for: $0) } } @@ -119,11 +122,13 @@ final class LocationDataSource: UITableViewDiffableDataSource 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 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 be7bffc24e4f..bb54ec2a6ed8 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 0120322702e9..2b6fc5b5e829 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 bfe721362845..ace14e8eb0a6 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 06bbd9d5f393..782d8c4d8202 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 1a89f822a2fc..89a234f3ce9a 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 000000000000..401dc13eddb2 --- /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) to +// V3 (RelayConstraint) 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 34e1c2d1d49d..68f48c011289 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 94dcbcf500e0..97cf010b343e 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,