From 2d8ad5799c23ebdf751a3f4a5bf80b8ab352e937 Mon Sep 17 00:00:00 2001
From: Jon Petersson <jon.petersson@kvadrat.se>
Date: Wed, 6 Mar 2024 09:38:42 +0100
Subject: [PATCH] Create view with custom lists to edit

---
 .../CustomListRepository.swift                |  31 +---
 .../CustomListRepositoryProtocol.swift        |  15 +-
 ios/MullvadTypes/RelayConstraints.swift       |  25 +--
 ios/MullvadTypes/RelayLocation.swift          |  25 +++
 ios/MullvadVPN.xcodeproj/project.pbxproj      |  12 ++
 .../AddCustomListCoordinator.swift            |  19 ++-
 .../CustomLists/CustomListInteractor.swift    |  16 +-
 .../CustomListViewController.swift            |  20 +--
 .../EditCustomListCoordinator.swift           |  16 +-
 .../ListCustomListCoordinator.swift           | 100 ++++++++++++
 .../ListCustomListViewController.swift        | 153 ++++++++++++++++++
 .../Coordinators/LocationCoordinator.swift    | 105 +++++++++++-
 .../SimulatorTunnelProviderHost.swift         |   4 +-
 .../CustomListsDataSource.swift               |   9 +-
 .../InMemoryCustomListRepository.swift        |  18 +--
 .../SelectLocation/LocationDataSource.swift   | 111 ++++++++-----
 .../LocationViewController.swift              |  13 +-
 .../Location/CustomListRepositoryTests.swift  |  38 +++--
 .../Location/CustomListsDataSourceTests.swift |   5 +-
 .../MigrationManagerTests.swift               |   4 +-
 .../Mocks/CustomListsRepositoryStub.swift     |  14 +-
 .../TunnelSettingsUpdateTests.swift           |   2 +-
 .../RelayConstraintsTests.swift               |  62 +++++++
 ios/MullvadVPNTests/RelaySelectorTests.swift  |  24 +--
 .../AppMessageHandlerTests.swift              |   2 +-
 25 files changed, 645 insertions(+), 198 deletions(-)
 create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift
 create mode 100644 ios/MullvadVPN/Coordinators/CustomLists/ListCustomListViewController.swift
 create mode 100644 ios/MullvadVPNTests/RelayConstraintsTests.swift

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<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 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 = "<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 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<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 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<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 baa3cce18109..2b3a1f8c150b 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 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<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 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,