summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@kvadrat.se>2024-02-21 12:39:29 +0100
committerJon Petersson <jon.petersson@kvadrat.se>2024-03-04 16:10:48 +0100
commit410730246cf85a39aa6afe563e01b9aa0f795f05 (patch)
tree390305bc6f565e0c99df0888e37f3f7dd0262897
parent75c053739a33b0314b13951a9dce04cfd1fb52da (diff)
downloadmullvadvpn-410730246cf85a39aa6afe563e01b9aa0f795f05.tar.xz
mullvadvpn-410730246cf85a39aa6afe563e01b9aa0f795f05.zip
Split select location view into two sections
-rw-r--r--ios/.swiftlint.yml1
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj78
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift24
-rw-r--r--ios/MullvadVPN/Coordinators/LocationCoordinator.swift (renamed from ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift)30
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorConfiguration.swift22
-rw-r--r--ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorContentView.swift90
-rw-r--r--ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift16
-rw-r--r--ios/MullvadVPN/UI appearance/UIColor+Palette.swift25
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift2
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift125
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift96
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift)24
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift13
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift10
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift284
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift100
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift152
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift)12
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift)37
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift123
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift18
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsAddDNSEntryCell.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsCell.swift6
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsDNSTextCell.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift2
-rw-r--r--ios/MullvadVPNTests/Location/AllLocationsDataSourceTests.swift66
-rw-r--r--ios/MullvadVPNTests/Location/CustomListRepositoryTests.swift (renamed from ios/MullvadVPNTests/CustomListRepositoryTests.swift)0
-rw-r--r--ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift89
-rw-r--r--ios/MullvadVPNTests/Location/LocationNodeTests.swift107
-rw-r--r--ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift39
31 files changed, 1086 insertions, 511 deletions
diff --git a/ios/.swiftlint.yml b/ios/.swiftlint.yml
index 1d834d4954..496503001d 100644
--- a/ios/.swiftlint.yml
+++ b/ios/.swiftlint.yml
@@ -7,6 +7,7 @@ disabled_rules:
- type_body_length
- opening_brace # Differs from Google swift guidelines enforced by swiftformat
- trailing_comma
+ - switch_case_alignment # Enables expressions such as [return switch location {}]
opt_in_rules:
- empty_count
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index b26f3c3097..6e6b63475b 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -202,8 +202,8 @@
588395602A9DEEA1008B63F6 /* WgAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5883955F2A9DEEA1008B63F6 /* WgAdapter.swift */; };
588527B2276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */; };
588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */; };
- 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* SelectLocationCell.swift */; };
- 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */; };
+ 5888AD83227B11080051EB06 /* LocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* LocationCell.swift */; };
+ 5888AD87227B17950051EB06 /* LocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* LocationViewController.swift */; };
588D7ED62AF3903F005DF40A /* ListAccessMethodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7ED52AF3903F005DF40A /* ListAccessMethodViewController.swift */; };
588D7EDC2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDB2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift */; };
588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDD2AF3A585005DF40A /* ListAccessMethodItem.swift */; };
@@ -528,6 +528,7 @@
7A6389E92B7F8FE2008E77E1 /* CustomListValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389E82B7F8FE2008E77E1 /* CustomListValidationError.swift */; };
7A6389EB2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389EA2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift */; };
7A6389ED2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */; };
+ 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389F72B864CDF008E77E1 /* LocationNode.swift */; };
7A6B4F592AB8412E00123853 /* TunnelMonitorTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */; };
7A6F2FA52AFA3CB2006D0856 /* AccountExpiryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */; };
7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */; };
@@ -549,12 +550,20 @@
7A88DCF42A93471F00D2FF0E /* ApplicationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5893C6FB29C311E9009090D1 /* ApplicationRouter.swift */; };
7A88DCF52A93471F00D2FF0E /* ApplicationRouterTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBCA2A8E45DC00E5CE4C /* ApplicationRouterTypes.swift */; };
7A88DCF62A93471F00D2FF0E /* AppRouteProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBC62A8E457A00E5CE4C /* AppRouteProtocol.swift */; };
+ 7A9BE5A22B8F88C500E2A7D0 /* LocationNodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */; };
+ 7A9BE5A32B8F89B900E2A7D0 /* LocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389F72B864CDF008E77E1 /* LocationNode.swift */; };
+ 7A9BE5A52B90760C00E2A7D0 /* CustomListsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */; };
+ 7A9BE5A62B90762F00E2A7D0 /* CustomListsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */; };
+ 7A9BE5A72B907EEC00E2A7D0 /* AllLocationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */; };
+ 7A9BE5A92B90806800E2A7D0 /* CustomListsRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */; };
+ 7A9BE5AB2B909A1700E2A7D0 /* LocationDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */; };
+ 7A9BE5AD2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5AC2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift */; };
7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA12A96302700DD6A34 /* WelcomeCoordinator.swift */; };
7A9CCCB52A96302800DD6A34 /* AddCreditSucceededCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA32A96302700DD6A34 /* AddCreditSucceededCoordinator.swift */; };
7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */; };
7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */; };
7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */; };
- 7A9CCCB92A96302800DD6A34 /* SelectLocationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */; };
+ 7A9CCCB92A96302800DD6A34 /* LocationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */; };
7A9CCCBA2A96302800DD6A34 /* CreateAccountVoucherCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */; };
7A9CCCBB2A96302800DD6A34 /* InAppPurchaseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */; };
7A9CCCBC2A96302800DD6A34 /* ChangeLogCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */; };
@@ -797,10 +806,8 @@
F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */; };
F04F95A12B21D24400431E08 /* shadowsocks.h in Headers */ = {isa = PBXBuildFile; fileRef = F04F95A02B21D24400431E08 /* shadowsocks.h */; settings = {ATTRIBUTES = (Private, ); }; };
F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04FBE602A8379EE009278D7 /* AppPreferences.swift */; };
- F050AE4C2B70D5A7003F4EDB /* SelectLocationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */; };
F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */; };
- F050AE502B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */; };
- F050AE522B70DFC0003F4EDB /* SelectLocationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */; };
+ F050AE522B70DFC0003F4EDB /* LocationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE512B70DFC0003F4EDB /* LocationSection.swift */; };
F050AE572B7376C6003F4EDB /* CustomListRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */; };
F050AE582B7376C6003F4EDB /* CustomListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE562B7376C6003F4EDB /* CustomListRepository.swift */; };
F050AE5A2B7376F4003F4EDB /* CustomList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE592B7376F4003F4EDB /* CustomList.swift */; };
@@ -1499,8 +1506,8 @@
5883955F2A9DEEA1008B63F6 /* WgAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WgAdapter.swift; sourceTree = "<group>"; };
588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadTunnelConfigurationOperation.swift; sourceTree = "<group>"; };
588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAccountOperation.swift; sourceTree = "<group>"; };
- 5888AD82227B11080051EB06 /* SelectLocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationCell.swift; sourceTree = "<group>"; };
- 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewController.swift; sourceTree = "<group>"; };
+ 5888AD82227B11080051EB06 /* LocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCell.swift; sourceTree = "<group>"; };
+ 5888AD86227B17950051EB06 /* LocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationViewController.swift; sourceTree = "<group>"; };
588D7ED52AF3903F005DF40A /* ListAccessMethodViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodViewController.swift; sourceTree = "<group>"; };
588D7ED72AF3A533005DF40A /* AccessMethodKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodKind.swift; sourceTree = "<group>"; };
588D7EDB2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodInteractorProtocol.swift; sourceTree = "<group>"; };
@@ -1756,6 +1763,7 @@
7A6389E82B7F8FE2008E77E1 /* CustomListValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListValidationError.swift; sourceTree = "<group>"; };
7A6389EA2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFieldValidationErrorContentView.swift; sourceTree = "<group>"; };
7A6389EC2B7FADA1008E77E1 /* SettingsFieldValidationErrorConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFieldValidationErrorConfiguration.swift; sourceTree = "<group>"; };
+ 7A6389F72B864CDF008E77E1 /* LocationNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNode.swift; sourceTree = "<group>"; };
7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = "<group>"; };
7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryTests.swift; sourceTree = "<group>"; };
7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiry.swift; sourceTree = "<group>"; };
@@ -1773,13 +1781,17 @@
7A88DCD02A8FABBE00D2FF0E /* Routing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Routing.h; sourceTree = "<group>"; };
7A88DCD72A8FABBE00D2FF0E /* RoutingTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RoutingTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
7A88DCDE2A8FABBF00D2FF0E /* RoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingTests.swift; sourceTree = "<group>"; };
+ 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNodeTests.swift; sourceTree = "<group>"; };
+ 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsDataSourceTests.swift; sourceTree = "<group>"; };
+ 7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsRepositoryStub.swift; sourceTree = "<group>"; };
+ 7A9BE5AC2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationsDataSourceTests.swift; sourceTree = "<group>"; };
7A9CCCA12A96302700DD6A34 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TermsOfServiceCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA32A96302700DD6A34 /* AddCreditSucceededCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCreditSucceededCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RevokedCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedCoordinator.swift; sourceTree = "<group>"; };
- 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectLocationCoordinator.swift; sourceTree = "<group>"; };
+ 7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateAccountVoucherCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppPurchaseCoordinator.swift; sourceTree = "<group>"; };
7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangeLogCoordinator.swift; sourceTree = "<group>"; };
@@ -1926,10 +1938,8 @@
F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = "<group>"; };
F04F95A02B21D24400431E08 /* shadowsocks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = shadowsocks.h; sourceTree = "<group>"; };
F04FBE602A8379EE009278D7 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = "<group>"; };
- F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationNode.swift; sourceTree = "<group>"; };
F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCellViewModel.swift; sourceTree = "<group>"; };
- F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationNodeProtocol.swift; sourceTree = "<group>"; };
- F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationSection.swift; sourceTree = "<group>"; };
+ F050AE512B70DFC0003F4EDB /* LocationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSection.swift; sourceTree = "<group>"; };
F050AE552B7376C5003F4EDB /* CustomListRepositoryProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepositoryProtocol.swift; sourceTree = "<group>"; };
F050AE562B7376C6003F4EDB /* CustomListRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomListRepository.swift; sourceTree = "<group>"; };
F050AE592B7376F4003F4EDB /* CustomList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomList.swift; sourceTree = "<group>"; };
@@ -2192,6 +2202,7 @@
44DD7D252B6D18E90005F67F /* Mocks */ = {
isa = PBXGroup;
children = (
+ 7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */,
44DD7D282B7113CA0005F67F /* MockTunnel.swift */,
44DD7D262B6D18FB0005F67F /* MockTunnelInteractor.swift */,
);
@@ -2363,15 +2374,14 @@
children = (
F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */,
F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */,
+ 5888AD82227B11080051EB06 /* LocationCell.swift */,
58435AC129CB2A350099C71B /* LocationCellFactory.swift */,
F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */,
583DA21325FA4B5C00318683 /* LocationDataSource.swift */,
F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */,
- 5888AD82227B11080051EB06 /* SelectLocationCell.swift */,
- F050AE512B70DFC0003F4EDB /* SelectLocationSection.swift */,
- F050AE4B2B70D5A7003F4EDB /* SelectLocationNode.swift */,
- F050AE4F2B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift */,
- 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */,
+ 7A6389F72B864CDF008E77E1 /* LocationNode.swift */,
+ F050AE512B70DFC0003F4EDB /* LocationSection.swift */,
+ 5888AD86227B17950051EB06 /* LocationViewController.swift */,
);
path = SelectLocation;
sourceTree = "<group>";
@@ -2851,6 +2861,7 @@
58B0A2A1238EE67E00BC001D /* MullvadVPNTests */ = {
isa = PBXGroup;
children = (
+ 7A9BE5A02B8F881B00E2A7D0 /* Location */,
44DD7D252B6D18E90005F67F /* Mocks */,
449872E22B7CB91B00094DDC /* MullvadSettings */,
A900E9BF2ACC661900C95F67 /* AccessTokenManager+Stubs.swift */,
@@ -2861,7 +2872,6 @@
A900E9BD2ACC654100C95F67 /* APIProxy+Stubs.swift */,
A9EC20E72A5D3A8C0040D56E /* CoordinatesTests.swift */,
5896AE85246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift */,
- F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */,
58915D622A25F8400066445B /* DeviceCheckOperationTests.swift */,
A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */,
58FBFBF0291630700020E046 /* DurationTests.swift */,
@@ -3057,13 +3067,13 @@
7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */,
7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */,
7A9CCCA92A96302700DD6A34 /* InAppPurchaseCoordinator.swift */,
+ 7A9CCCA72A96302700DD6A34 /* LocationCoordinator.swift */,
7A9CCCAB2A96302800DD6A34 /* LoginCoordinator.swift */,
7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */,
7A9CCCAE2A96302800DD6A34 /* ProfileVoucherCoordinator.swift */,
7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */,
7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */,
7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */,
- 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */,
7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */,
7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */,
7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */,
@@ -3478,6 +3488,17 @@
path = RoutingTests;
sourceTree = "<group>";
};
+ 7A9BE5A02B8F881B00E2A7D0 /* Location */ = {
+ isa = PBXGroup;
+ children = (
+ 7A9BE5AC2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift */,
+ 7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */,
+ F050AE5B2B73797D003F4EDB /* CustomListRepositoryTests.swift */,
+ 7A9BE5A12B8F88C500E2A7D0 /* LocationNodeTests.swift */,
+ );
+ path = Location;
+ sourceTree = "<group>";
+ };
7AF9BE912A39F47D00DBFEDB /* RelayFilter */ = {
isa = PBXGroup;
children = (
@@ -4740,6 +4761,7 @@
A9A5F9E72ACB05160083449F /* FirstTimeLaunch.swift in Sources */,
A9A5F9E92ACB05160083449F /* ObserverList.swift in Sources */,
A9B6AC1B2ADEA3AD00F7802A /* MemoryCache.swift in Sources */,
+ 7A9BE5A32B8F89B900E2A7D0 /* LocationNode.swift in Sources */,
A9A5F9EA2ACB05160083449F /* Bundle+ProductVersion.swift in Sources */,
A9A5F9EB2ACB05160083449F /* CharacterSet+IPAddress.swift in Sources */,
F0D8825C2B04F70E00D3EF9A /* OutgoingConnectionData.swift in Sources */,
@@ -4747,6 +4769,7 @@
A9A5F9ED2ACB05160083449F /* NSRegularExpression+IPAddress.swift in Sources */,
A9A5F9EE2ACB05160083449F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */,
7A12D0772B062D6500E9602D /* URLSessionProtocol.swift in Sources */,
+ 7A9BE5A72B907EEC00E2A7D0 /* AllLocationDataSource.swift in Sources */,
A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */,
A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */,
F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */,
@@ -4768,6 +4791,7 @@
A9A5F9FD2ACB05160083449F /* NotificationResponse.swift in Sources */,
A9A5F9FE2ACB05160083449F /* NotificationManager.swift in Sources */,
A9A5F9FF2ACB05160083449F /* NotificationManagerDelegate.swift in Sources */,
+ 7A9BE5AD2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift in Sources */,
A900E9BE2ACC654100C95F67 /* APIProxy+Stubs.swift in Sources */,
A900E9BA2ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift in Sources */,
A9A5FA002ACB05160083449F /* ProductsRequestOperation.swift in Sources */,
@@ -4806,8 +4830,10 @@
A9A5FA182ACB05160083449F /* SetAccountOperation.swift in Sources */,
A9A5FA192ACB05160083449F /* StartTunnelOperation.swift in Sources */,
A9A5FA1A2ACB05160083449F /* StopTunnelOperation.swift in Sources */,
+ 7A9BE5A52B90760C00E2A7D0 /* CustomListsDataSourceTests.swift in Sources */,
A9A5FA1B2ACB05160083449F /* Tunnel.swift in Sources */,
A9A5FA1C2ACB05160083449F /* Tunnel+Messaging.swift in Sources */,
+ 7A9BE5A92B90806800E2A7D0 /* CustomListsRepositoryStub.swift in Sources */,
F09D04BB2AE95396003D4F89 /* URLSessionStub.swift in Sources */,
A9A5FA1D2ACB05160083449F /* TunnelBlockObserver.swift in Sources */,
A9A5FA1E2ACB05160083449F /* TunnelConfiguration.swift in Sources */,
@@ -4818,7 +4844,9 @@
A9A5FA222ACB05160083449F /* TunnelObserver.swift in Sources */,
A988A3E22AFE54AC0008D2C7 /* AccountExpiry.swift in Sources */,
A9E0317F2ACC331C0095D843 /* TunnelStatusBlockObserver.swift in Sources */,
+ 7A9BE5A62B90762F00E2A7D0 /* CustomListsDataSource.swift in Sources */,
F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */,
+ 7A9BE5A22B8F88C500E2A7D0 /* LocationNodeTests.swift in Sources */,
A9A5FA232ACB05160083449F /* TunnelState.swift in Sources */,
A9A5FA242ACB05160083449F /* TunnelStore.swift in Sources */,
A9A5FA252ACB05160083449F /* UpdateAccountDataOperation.swift in Sources */,
@@ -4828,6 +4856,7 @@
449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */,
A9A5FA292ACB05160083449F /* AddressCacheTests.swift in Sources */,
A9B6AC182ADE8F4300F7802A /* MigrationManagerTests.swift in Sources */,
+ 7A9BE5AB2B909A1700E2A7D0 /* LocationDataSourceProtocol.swift in Sources */,
A9A5FA2A2ACB05160083449F /* CoordinatesTests.swift in Sources */,
44DD7D242B6CFFD70005F67F /* StartTunnelOperationTests.swift in Sources */,
A9A5FA2B2ACB05160083449F /* CustomDateComponentsFormattingTests.swift in Sources */,
@@ -5000,6 +5029,7 @@
F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */,
7A58699B2B482FE200640D27 /* UITableViewCell+Disable.swift in Sources */,
7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */,
+ 7A6389F82B864CDF008E77E1 /* LocationNode.swift in Sources */,
587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */,
7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */,
5827B0902B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift in Sources */,
@@ -5068,7 +5098,7 @@
58CEB30C2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift in Sources */,
587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */,
5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */,
- 7A9CCCB92A96302800DD6A34 /* SelectLocationCoordinator.swift in Sources */,
+ 7A9CCCB92A96302800DD6A34 /* LocationCoordinator.swift in Sources */,
58FB865A26EA214400F188BC /* RelayCacheTrackerObserver.swift in Sources */,
581DFAEC2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift in Sources */,
58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */,
@@ -5078,7 +5108,6 @@
5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */,
F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */,
7A9CCCC12A96302800DD6A34 /* AccountCoordinator.swift in Sources */,
- F050AE502B70DC4F003F4EDB /* SelectLocationNodeProtocol.swift in Sources */,
58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */,
5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */,
F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */,
@@ -5104,7 +5133,7 @@
58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */,
58EE2E3B272FF814003BFF93 /* SettingsDataSourceDelegate.swift in Sources */,
5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */,
- 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */,
+ 5888AD87227B17950051EB06 /* LocationViewController.swift in Sources */,
58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */,
586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */,
7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */,
@@ -5174,7 +5203,7 @@
7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */,
5862805422428EF100F5A6E1 /* TranslucentButtonBlurView.swift in Sources */,
587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */,
- 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */,
+ 5888AD83227B11080051EB06 /* LocationCell.swift in Sources */,
5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */,
5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */,
7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */,
@@ -5267,9 +5296,8 @@
584592612639B4A200EF967F /* TermsOfServiceContentView.swift in Sources */,
5875960A26F371FC00BF6711 /* Tunnel+Messaging.swift in Sources */,
586C0D912B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift in Sources */,
- F050AE4C2B70D5A7003F4EDB /* SelectLocationNode.swift in Sources */,
7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */,
- F050AE522B70DFC0003F4EDB /* SelectLocationSection.swift in Sources */,
+ F050AE522B70DFC0003F4EDB /* LocationSection.swift in Sources */,
063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */,
A9C342C12ACC37E30045F00E /* TunnelStatusBlockObserver.swift in Sources */,
587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */,
diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
index 5db7d9799e..19db4c7297 100644
--- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
@@ -65,7 +65,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
}()
private var splitTunnelCoordinator: TunnelCoordinator?
- private var splitLocationCoordinator: SelectLocationCoordinator?
+ private var splitLocationCoordinator: LocationCoordinator?
private let tunnelManager: TunnelManager
private let storePaymentManager: StorePaymentManager
@@ -488,17 +488,17 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
private func setupSplitView() {
let tunnelCoordinator = makeTunnelCoordinator()
- let selectLocationCoordinator = makeSelectLocationCoordinator(forModalPresentation: false)
+ let locationCoordinator = makeLocationCoordinator(forModalPresentation: false)
addChild(tunnelCoordinator)
- addChild(selectLocationCoordinator)
+ addChild(locationCoordinator)
splitTunnelCoordinator = tunnelCoordinator
- splitLocationCoordinator = selectLocationCoordinator
+ splitLocationCoordinator = locationCoordinator
splitViewController.delegate = self
splitViewController.viewControllers = [
- selectLocationCoordinator.navigationController,
+ locationCoordinator.navigationController,
tunnelCoordinator.rootViewController,
]
@@ -508,7 +508,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
.safeAreaLayoutGuide
tunnelCoordinator.start()
- selectLocationCoordinator.start()
+ locationCoordinator.start()
}
private func presentTOS(animated: Bool, completion: @escaping (Coordinator) -> Void) {
@@ -634,7 +634,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
}
private func presentSelectLocation(animated: Bool, completion: @escaping (Coordinator) -> Void) {
- let coordinator = makeSelectLocationCoordinator(forModalPresentation: true)
+ let coordinator = makeLocationCoordinator(forModalPresentation: true)
coordinator.start()
presentChild(coordinator, animated: animated) {
@@ -702,24 +702,24 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
return tunnelCoordinator
}
- private func makeSelectLocationCoordinator(forModalPresentation isModalPresentation: Bool)
- -> SelectLocationCoordinator {
+ private func makeLocationCoordinator(forModalPresentation isModalPresentation: Bool)
+ -> LocationCoordinator {
let navigationController = CustomNavigationController()
navigationController.isNavigationBarHidden = !isModalPresentation
- let selectLocationCoordinator = SelectLocationCoordinator(
+ let locationCoordinator = LocationCoordinator(
navigationController: navigationController,
tunnelManager: tunnelManager,
relayCacheTracker: relayCacheTracker
)
- selectLocationCoordinator.didFinish = { [weak self] _, _ in
+ locationCoordinator.didFinish = { [weak self] _ in
if isModalPresentation {
self?.router.dismiss(.selectLocation, animated: true)
}
}
- return selectLocationCoordinator
+ return locationCoordinator
}
private func presentAccount(animated: Bool, completion: @escaping (Coordinator) -> Void) {
diff --git a/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift
index dcaf47347d..39bffdaab9 100644
--- a/ios/MullvadVPN/Coordinators/SelectLocationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift
@@ -1,5 +1,5 @@
//
-// SelectLocationCoordinator.swift
+// LocationCoordinator.swift
// MullvadVPN
//
// Created by pronebird on 29/01/2023.
@@ -11,9 +11,7 @@ import MullvadTypes
import Routing
import UIKit
-import MullvadSettings
-
-class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrackerObserver {
+class LocationCoordinator: Coordinator, Presentable, Presenting, RelayCacheTrackerObserver {
private let tunnelManager: TunnelManager
private let relayCacheTracker: RelayCacheTracker
private var cachedRelays: CachedRelays?
@@ -24,10 +22,10 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach
navigationController
}
- var selectLocationViewController: SelectLocationViewController? {
+ var selectLocationViewController: LocationViewController? {
return navigationController.viewControllers.first {
- $0 is SelectLocationViewController
- } as? SelectLocationViewController
+ $0 is LocationViewController
+ } as? LocationViewController
}
var relayFilter: RelayFilter {
@@ -39,7 +37,7 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach
}
}
- var didFinish: ((SelectLocationCoordinator, RelayLocation?) -> Void)?
+ var didFinish: ((LocationCoordinator) -> Void)?
init(
navigationController: UINavigationController,
@@ -52,22 +50,19 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach
}
func start() {
- let selectLocationViewController = SelectLocationViewController()
+ let selectLocationViewController = LocationViewController()
- selectLocationViewController.didSelectRelay = { [weak self] relay in
+ selectLocationViewController.didSelectRelays = { [weak self] locations in
guard let self else { return }
var relayConstraints = tunnelManager.settings.relayConstraints
- relayConstraints.locations = .only(RelayLocations(
- locations: [relay],
- customListId: nil
- ))
+ relayConstraints.locations = .only(locations)
tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) {
self.tunnelManager.startTunnel()
}
- didFinish?(self, relay)
+ didFinish?(self)
}
selectLocationViewController.navigateToFilter = { [weak self] in
@@ -91,7 +86,7 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach
selectLocationViewController.didFinish = { [weak self] in
guard let self else { return }
- didFinish?(self, nil)
+ didFinish?(self)
}
relayCacheTracker.addObserver(self)
@@ -101,8 +96,7 @@ class SelectLocationCoordinator: Coordinator, Presentable, Presenting, RelayCach
selectLocationViewController.setCachedRelays(cachedRelays, filter: relayFilter)
}
- selectLocationViewController.relayLocation =
- tunnelManager.settings.relayConstraints.locations.value?.locations.first
+ selectLocationViewController.relayLocations = tunnelManager.settings.relayConstraints.locations.value
navigationController.pushViewController(selectLocationViewController, animated: false)
}
diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorConfiguration.swift
new file mode 100644
index 0000000000..64b7061108
--- /dev/null
+++ b/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorConfiguration.swift
@@ -0,0 +1,22 @@
+//
+// SettingsValidationErrorConfiguration.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2024-02-16.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+struct SettingsValidationErrorConfiguration: UIContentConfiguration, Equatable {
+ var errors: [CustomListFieldValidationError] = []
+ var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.settingsValidationErrorLayoutMargins
+
+ func makeContentView() -> UIView & UIContentView {
+ return SettingsValidationErrorContentView(configuration: self)
+ }
+
+ func updated(for state: UIConfigurationState) -> Self {
+ return self
+ }
+}
diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorContentView.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorContentView.swift
new file mode 100644
index 0000000000..b3708bb764
--- /dev/null
+++ b/ios/MullvadVPN/Coordinators/Settings/SettingsValidationErrorContentView.swift
@@ -0,0 +1,90 @@
+//
+// SettingsValidationErrorContentView.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2024-02-16.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+class SettingsValidationErrorContentView: UIView, UIContentView {
+ let contentView = UIStackView()
+
+ var icon: UIImageView {
+ let view = UIImageView(image: UIImage(resource: .iconAlert).withTintColor(.dangerColor))
+ view.heightAnchor.constraint(equalToConstant: 14).isActive = true
+ view.widthAnchor.constraint(equalTo: view.heightAnchor, multiplier: 1).isActive = true
+ return view
+ }
+
+ var configuration: UIContentConfiguration {
+ get {
+ actualConfiguration
+ }
+ set {
+ guard let newConfiguration = newValue as? SettingsValidationErrorConfiguration else { return }
+
+ let previousConfiguration = actualConfiguration
+ actualConfiguration = newConfiguration
+
+ configureSubviews(previousConfiguration: previousConfiguration)
+ }
+ }
+
+ private var actualConfiguration: SettingsValidationErrorConfiguration
+
+ func supports(_ configuration: UIContentConfiguration) -> Bool {
+ configuration is SettingsValidationErrorConfiguration
+ }
+
+ init(configuration: SettingsValidationErrorConfiguration) {
+ actualConfiguration = configuration
+
+ super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44))
+
+ addSubviews()
+ configureSubviews()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func addSubviews() {
+ contentView.axis = .vertical
+ contentView.spacing = 6
+
+ addConstrainedSubviews([contentView]) {
+ contentView.pinEdgesToSuperviewMargins()
+ }
+ }
+
+ private func configureSubviews(previousConfiguration: SettingsValidationErrorConfiguration? = nil) {
+ guard actualConfiguration != previousConfiguration else { return }
+
+ configureLayoutMargins()
+
+ contentView.arrangedSubviews.forEach { view in
+ view.removeFromSuperview()
+ }
+
+ actualConfiguration.errors.forEach { error in
+ let label = UILabel()
+ label.text = error.errorDescription
+ label.numberOfLines = 0
+ label.font = .systemFont(ofSize: 13)
+ label.textColor = .white.withAlphaComponent(0.6)
+
+ let stackView = UIStackView(arrangedSubviews: [icon, label])
+ stackView.alignment = .top
+ stackView.spacing = 6
+
+ contentView.addArrangedSubview(stackView)
+ }
+ }
+
+ private func configureLayoutMargins() {
+ directionalLayoutMargins = actualConfiguration.directionalLayoutMargins
+ }
+}
diff --git a/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift b/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift
index 69f51d5452..e6babbbf4b 100644
--- a/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift
+++ b/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift
@@ -21,7 +21,7 @@ extension UIBackgroundConfiguration {
/// - Returns: a background configuration
static func mullvadListPlainCell() -> UIBackgroundConfiguration {
var config = listPlainCell()
- config.backgroundColor = UIColor.Cell.backgroundColor
+ config.backgroundColor = UIColor.Cell.Background.normal
return config
}
@@ -29,7 +29,7 @@ extension UIBackgroundConfiguration {
/// - Returns: a background configuration
static func mullvadListGroupedCell() -> UIBackgroundConfiguration {
var config = listGroupedCell()
- config.backgroundColor = UIColor.Cell.backgroundColor
+ config.backgroundColor = UIColor.Cell.Background.normal
return config
}
@@ -58,20 +58,20 @@ extension UICellConfigurationState {
switch selectionType {
case .dimmed:
if isSelected || isHighlighted {
- UIColor.Cell.selectedAltBackgroundColor
+ UIColor.Cell.Background.selectedAlt
} else if isDisabled {
- UIColor.Cell.disabledBackgroundColor
+ UIColor.Cell.Background.disabled
} else {
- UIColor.Cell.backgroundColor
+ UIColor.Cell.Background.normal
}
case .green:
if isSelected || isHighlighted {
- UIColor.Cell.selectedBackgroundColor
+ UIColor.Cell.Background.selected
} else if isDisabled {
- UIColor.Cell.disabledBackgroundColor
+ UIColor.Cell.Background.disabled
} else {
- UIColor.Cell.backgroundColor
+ UIColor.Cell.Background.normal
}
}
}
diff --git a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift
index 0f111024b3..63495b44a8 100644
--- a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift
+++ b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift
@@ -86,13 +86,18 @@ extension UIColor {
// Cells
enum Cell {
- static let backgroundColor = primaryColor
- static let disabledBackgroundColor = backgroundColor.darkened(by: 0.3)!
+ enum Background {
+ static let indentationLevelZero = primaryColor
+ static let indentationLevelOne = UIColor(red: 0.15, green: 0.23, blue: 0.33, alpha: 1.0)
+ static let indentationLevelTwo = UIColor(red: 0.13, green: 0.20, blue: 0.30, alpha: 1.0)
+ static let indentationLevelThree = UIColor(red: 0.11, green: 0.17, blue: 0.27, alpha: 1.0)
- static let selectedBackgroundColor = successColor
- static let disabledSelectedBackgroundColor = selectedBackgroundColor.darkened(by: 0.3)!
-
- static let selectedAltBackgroundColor = backgroundColor.darkened(by: 0.2)!
+ static let normal = indentationLevelZero
+ static let disabled = normal.darkened(by: 0.3)!
+ static let selected = successColor
+ static let disabledSelected = selected.darkened(by: 0.3)!
+ static let selectedAlt = normal.darkened(by: 0.2)!
+ }
static let titleTextColor = UIColor.white
static let detailTextColor = UIColor(white: 1.0, alpha: 0.8)
@@ -109,13 +114,7 @@ extension UIColor {
static let footerTextColor = UIColor(white: 1.0, alpha: 0.6)
}
- enum SubCell {
- static let backgroundColor = UIColor(red: 0.15, green: 0.23, blue: 0.33, alpha: 1.0)
- }
-
- enum SubSubCell {
- static let backgroundColor = UIColor(red: 0.13, green: 0.20, blue: 0.30, alpha: 1.0)
- }
+ enum SettingsCellBackground {}
enum HeaderBar {
static let defaultBackgroundColor = primaryColor
diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift
index 3459c5a787..f4d306590c 100644
--- a/ios/MullvadVPN/UI appearance/UIMetrics.swift
+++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift
@@ -173,7 +173,7 @@ extension UIMetrics {
static let contentInsets = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
/// Common layout margins for location cell presentation
- static let selectLocationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 12)
+ static let locationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 12)
/// Layout margins used by content heading displayed below the large navigation title.
static let contentHeadingLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: 24, bottom: 24, trailing: 24)
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift
index 33bfe57593..2cde9b5b69 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift
@@ -2,7 +2,7 @@
// AllLocationDataSource.swift
// MullvadVPN
//
-// Created by Mojgan on 2024-02-07.
+// Created by Jon Petersson on 2024-02-22.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//
@@ -11,83 +11,86 @@ import MullvadREST
import MullvadTypes
class AllLocationDataSource: LocationDataSourceProtocol {
- var nodeByLocation = [RelayLocation: SelectLocationNode]()
- private var locationList = [RelayLocation]()
+ private(set) var nodes = [LocationNode]()
- func search(by text: String) -> [RelayLocation] {
- guard !text.isEmpty else {
- return locationList
- }
+ var searchableNodes: [LocationNode] {
+ nodes
+ }
- var filteredLocations: [RelayLocation] = []
- locationList.forEach { location in
- guard let countryNode = nodeByLocation[location] else { return }
- countryNode.showsChildren = false
+ /// Constructs a collection of node trees from relays fetched from the API.
+ /// ``RelayLocation.city`` is of special import since we use it to get country
+ /// and city names.
+ func reload(_ response: REST.ServerRelaysResponse, relays: [REST.ServerRelay]) {
+ let rootNode = RootLocationNode()
- if countryNode.displayName.fuzzyMatch(text) {
- filteredLocations.append(countryNode.location)
- }
+ for relay in relays {
+ guard case
+ let .city(countryCode, cityCode) = RelayLocation(dashSeparatedString: relay.location),
+ let serverLocation = response.locations[relay.location]
+ else { continue }
- countryNode.children.forEach { cityNode in
- cityNode.showsChildren = false
+ let relayLocation = RelayLocation.hostname(countryCode, cityCode, relay.hostname)
- let relaysContainSearchString = cityNode.children
- .contains(where: { $0.displayName.fuzzyMatch(text) })
+ for ancestorOrSelf in relayLocation.ancestors + [relayLocation] {
+ addLocation(ancestorOrSelf, rootNode: rootNode, serverLocation: serverLocation, relay: relay)
+ }
+ }
- if cityNode.displayName.fuzzyMatch(text) || relaysContainSearchString {
- if !filteredLocations.contains(countryNode.location) {
- filteredLocations.append(countryNode.location)
- }
+ nodes = rootNode.children
+ }
- filteredLocations.append(cityNode.location)
- countryNode.showsChildren = true
+ func node(by location: RelayLocation) -> LocationNode? {
+ let rootNode = RootLocationNode(children: nodes)
- if relaysContainSearchString {
- filteredLocations.append(contentsOf: cityNode.children.map { $0.location })
- cityNode.showsChildren = true
- }
- }
- }
+ return switch location {
+ case let .country(countryCode):
+ rootNode.descendantNodeFor(code: countryCode)
+ case let .city(_, cityCode):
+ rootNode.descendantNodeFor(code: cityCode)
+ case let .hostname(_, _, hostCode):
+ rootNode.descendantNodeFor(code: hostCode)
}
-
- return filteredLocations
}
- func reload(
- _ response: REST.ServerRelaysResponse,
- relays: [REST.ServerRelay]
- ) -> [RelayLocation] {
- nodeByLocation.removeAll()
- let rootNode = self.makeRootNode(name: SelectLocationSection.allLocations.description)
+ private func addLocation(
+ _ location: RelayLocation,
+ rootNode: LocationNode,
+ serverLocation: REST.ServerLocation,
+ relay: REST.ServerRelay
+ ) {
+ switch location {
+ case let .country(countryCode):
+ let countryNode = CountryLocationNode(
+ name: serverLocation.country,
+ code: countryCode,
+ locations: [location]
+ )
- for relay in relays {
- guard case let .city(countryCode, cityCode) = RelayLocation(dashSeparatedString: relay.location),
- let serverLocation = response.locations[relay.location] else { continue }
+ if !rootNode.children.contains(countryNode) {
+ rootNode.children.append(countryNode)
+ rootNode.children.sort()
+ }
- let relayLocation = RelayLocation.hostname(countryCode, cityCode, relay.hostname)
+ case let .city(countryCode, cityCode):
+ let cityNode = CityLocationNode(name: serverLocation.city, code: cityCode, locations: [location])
- for ancestorOrSelf in relayLocation.ancestors + [relayLocation] {
- guard !nodeByLocation.keys.contains(ancestorOrSelf) else {
- continue
- }
+ if let countryNode = rootNode.countryFor(code: countryCode),
+ !countryNode.children.contains(cityNode) {
+ cityNode.parent = countryNode
+ countryNode.children.append(cityNode)
+ countryNode.children.sort()
+ }
- // Maintain the `showsChildren` state when transitioning between relay lists
- let wasShowingChildren = nodeByLocation[ancestorOrSelf]?.showsChildren ?? false
+ case let .hostname(countryCode, cityCode, hostCode):
+ let hostNode = HostLocationNode(name: relay.hostname, code: hostCode, locations: [location])
- let node = createNode(
- root: rootNode,
- ancestorOrSelf: ancestorOrSelf,
- serverLocation: serverLocation,
- relay: relay,
- wasShowingChildren: wasShowingChildren
- )
- nodeByLocation[ancestorOrSelf] = node
+ if let countryNode = rootNode.countryFor(code: countryCode),
+ let cityNode = countryNode.cityFor(code: cityCode),
+ !cityNode.children.contains(hostNode) {
+ hostNode.parent = cityNode
+ cityNode.children.append(hostNode)
+ cityNode.children.sort()
}
}
-
- rootNode.sortChildrenRecursive()
- rootNode.computeActiveChildrenRecursive()
- locationList = rootNode.flatRelayLocationList()
- return locationList
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift
index 897e68b9c3..51a26401ea 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift
@@ -2,26 +2,102 @@
// CustomListsDataSource.swift
// MullvadVPN
//
-// Created by Mojgan on 2024-02-08.
+// Created by Jon Petersson on 2024-02-22.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//
import Foundation
import MullvadREST
+import MullvadSettings
import MullvadTypes
class CustomListsDataSource: LocationDataSourceProtocol {
- var nodeByLocation = [RelayLocation: SelectLocationNode]()
- private var locationList = [RelayLocation]()
+ private(set) var nodes = [LocationNode]()
+ private(set) var repository: CustomListRepositoryProtocol
- func search(by text: String) -> [RelayLocation] {
- []
+ init(repository: CustomListRepositoryProtocol) {
+ self.repository = repository
}
- func reload(
- _ response: REST.ServerRelaysResponse,
- relays: [REST.ServerRelay]
- ) -> [RelayLocation] {
- locationList
+ var searchableNodes: [LocationNode] {
+ nodes.flatMap { $0.children }
+ }
+
+ /// Constructs a collection of node trees by copying each matching counterpart
+ /// from the complete list of nodes created in ``AllLocationDataSource``.
+ func reload(allLocationNodes: [LocationNode]) {
+ nodes = repository.fetchAll().map { list in
+ let listNode = CustomListLocationNode(
+ name: list.name,
+ code: list.name.lowercased(),
+ locations: list.locations,
+ customList: list
+ )
+
+ listNode.children = list.locations.compactMap { location in
+ copy(location, from: allLocationNodes, withParent: listNode)
+ }
+
+ listNode.forEachDescendant { node in
+ // Each item in a section in a diffable data source needs to be unique.
+ // Since LocationCellViewModel partly depends on LocationNode.code for
+ // equality, each node code needs to be prefixed with the code of its
+ // parent custom list to uphold this.
+ node.code = "\(listNode.code)-\(node.code)"
+ }
+
+ return listNode
+ }
+ }
+
+ func node(by locations: [RelayLocation], for customList: CustomList) -> LocationNode? {
+ guard let customListNode = nodes.first(where: { $0.name == customList.name })
+ else { return nil }
+
+ if locations.count > 1 {
+ return customListNode
+ } 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 {
+ case let .country(countryCode):
+ customListNode.descendantNodeFor(code: "\(customListNode.code)-\(countryCode)")
+ case let .city(_, cityCode):
+ customListNode.descendantNodeFor(code: "\(customListNode.code)-\(cityCode)")
+ case let .hostname(_, _, hostCode):
+ customListNode.descendantNodeFor(code: "\(customListNode.code)-\(hostCode)")
+ case .none:
+ nil
+ }
+ }
+ }
+
+ func customList(by id: UUID) -> CustomList? {
+ repository.fetch(by: id)
+ }
+
+ private func copy(
+ _ location: RelayLocation,
+ from allLocationNodes: [LocationNode],
+ withParent parentNode: LocationNode
+ ) -> LocationNode? {
+ let rootNode = RootLocationNode(children: allLocationNodes)
+
+ return switch location {
+ case let .country(countryCode):
+ rootNode
+ .countryFor(code: countryCode)?.copy(withParent: parentNode)
+
+ case let .city(countryCode, cityCode):
+ rootNode
+ .countryFor(code: countryCode)?.copy(withParent: parentNode)
+ .cityFor(code: cityCode)
+
+ case let .hostname(countryCode, cityCode, hostCode):
+ rootNode
+ .countryFor(code: countryCode)?.copy(withParent: parentNode)
+ .cityFor(code: cityCode)?
+ .hostFor(code: hostCode)
+ }
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift
index 05a9c46664..8c1ed9334b 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationCell.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCell.swift
@@ -1,5 +1,5 @@
//
-// SelectLocationCell.swift
+// LocationCell.swift
// MullvadVPN
//
// Created by pronebird on 02/05/2019.
@@ -11,8 +11,8 @@ import UIKit
private let kCollapseButtonWidth: CGFloat = 24
private let kRelayIndicatorSize: CGFloat = 16
-class SelectLocationCell: UITableViewCell {
- typealias CollapseHandler = (SelectLocationCell) -> Void
+class LocationCell: UITableViewCell {
+ typealias CollapseHandler = (LocationCell) -> Void
let locationLabel = UILabel()
let statusIndicator: UIView = {
@@ -72,7 +72,7 @@ class SelectLocationCell: UITableViewCell {
private func setLayoutMargins() {
let indentation = CGFloat(indentationLevel) * indentationWidth
- var contentMargins = UIMetrics.selectLocationCellLayoutMargins
+ var contentMargins = UIMetrics.locationCellLayoutMargins
contentMargins.leading += indentation
contentView.directionalLayoutMargins = contentMargins
@@ -98,10 +98,10 @@ class SelectLocationCell: UITableViewCell {
contentView.backgroundColor = .clear
backgroundView = UIView()
- backgroundView?.backgroundColor = UIColor.Cell.backgroundColor
+ backgroundView?.backgroundColor = UIColor.Cell.Background.normal
selectedBackgroundView = UIView()
- selectedBackgroundView?.backgroundColor = UIColor.Cell.selectedBackgroundColor
+ selectedBackgroundView?.backgroundColor = UIColor.Cell.Background.selected
locationLabel.font = UIFont.systemFont(ofSize: 17)
locationLabel.textColor = .white
@@ -184,19 +184,21 @@ class SelectLocationCell: UITableViewCell {
private func backgroundColorForIdentationLevel() -> UIColor {
switch indentationLevel {
case 1:
- return UIColor.SubCell.backgroundColor
+ return UIColor.Cell.Background.indentationLevelOne
case 2:
- return UIColor.SubSubCell.backgroundColor
+ return UIColor.Cell.Background.indentationLevelTwo
+ case 3:
+ return UIColor.Cell.Background.indentationLevelThree
default:
- return UIColor.Cell.backgroundColor
+ return UIColor.Cell.Background.normal
}
}
private func selectedBackgroundColorForIndentationLevel() -> UIColor {
if isDisabled {
- return UIColor.Cell.disabledSelectedBackgroundColor
+ return UIColor.Cell.Background.disabledSelected
} else {
- return UIColor.Cell.selectedBackgroundColor
+ return UIColor.Cell.Background.selected
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift
index 5151752d09..81bd14b052 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellFactory.swift
@@ -11,7 +11,6 @@ import UIKit
protocol LocationCellEventHandler {
func toggleCell(for item: LocationCellViewModel)
- func node(for item: LocationCellViewModel) -> SelectLocationNode?
}
final class LocationCellFactory: CellFactoryProtocol {
@@ -39,14 +38,12 @@ final class LocationCellFactory: CellFactoryProtocol {
}
func configureCell(_ cell: UITableViewCell, item: LocationCellViewModel, indexPath: IndexPath) {
- guard let cell = cell as? SelectLocationCell,
- let node = delegate?.node(for: item) else { return }
+ guard let cell = cell as? LocationCell else { return }
- cell.accessibilityIdentifier = node.location.stringRepresentation
- cell.isDisabled = !node.isActive
- cell.locationLabel.text = node.displayName
- cell.showsCollapseControl = node.isCollapsible
- cell.isExpanded = node.showsChildren
+ cell.accessibilityIdentifier = item.node.name
+ cell.locationLabel.text = item.node.name
+ cell.showsCollapseControl = !item.node.children.isEmpty
+ cell.isExpanded = item.node.showsChildren
cell.didCollapseHandler = { [weak self] _ in
self?.delegate?.toggleCell(for: item)
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift
index 2b27f8e0ed..711f6a8a12 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationCellViewModel.swift
@@ -9,6 +9,12 @@
import MullvadTypes
struct LocationCellViewModel: Hashable {
- let group: SelectLocationSection
- let location: RelayLocation
+ let section: LocationSection
+ let node: LocationNode
+ var indentationLevel = 0
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.node == rhs.node &&
+ lhs.section == rhs.section
+ }
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
index a8bd237226..449c8bd61e 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
@@ -8,17 +8,18 @@
import Combine
import MullvadREST
+import MullvadSettings
import MullvadTypes
import UIKit
-final class LocationDataSource: UITableViewDiffableDataSource<SelectLocationSection, LocationCellViewModel> {
+final class LocationDataSource: UITableViewDiffableDataSource<LocationSection, LocationCellViewModel> {
private var currentSearchString = ""
private let tableView: UITableView
private let locationCellFactory: LocationCellFactory
private var dataSources: [LocationDataSourceProtocol] = []
+ private var selectedItem: LocationCellViewModel?
- var selectedRelayLocation: LocationCellViewModel?
- var didSelectRelayLocation: ((RelayLocation) -> Void)?
+ var didSelectRelayLocations: ((RelayLocations) -> Void)?
init(
tableView: UITableView,
@@ -34,7 +35,7 @@ final class LocationDataSource: UITableViewDiffableDataSource<SelectLocationSect
let locationCellFactory = LocationCellFactory(
tableView: tableView,
- reuseIdentifier: SelectLocationSection.Cell.locationCell.reuseIdentifier
+ reuseIdentifier: LocationSection.Cell.locationCell.reuseIdentifier
)
self.locationCellFactory = locationCellFactory
@@ -49,40 +50,52 @@ final class LocationDataSource: UITableViewDiffableDataSource<SelectLocationSect
registerClasses()
}
- func setRelays(_ response: REST.ServerRelaysResponse, filter: RelayFilter) {
+ func setRelays(_ response: REST.ServerRelaysResponse, selectedLocations: RelayLocations?, filter: RelayFilter) {
+ guard let customListsDataSource =
+ dataSources.first(where: { $0 is CustomListsDataSource }) as? CustomListsDataSource,
+ let allLocationsDataSource =
+ dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource
+ else { return }
+
let relays = response.wireguard.relays.filter { relay in
- return RelaySelector.relayMatchesFilter(relay, filter: filter)
- }
- var list: [[LocationCellViewModel]] = []
- for section in 0 ..< dataSources.count {
- list.append(
- dataSources[section]
- .reload(response, relays: relays)
- .map { LocationCellViewModel(group: SelectLocationSection.allCases[section], location: $0) }
- )
+ RelaySelector.relayMatchesFilter(relay, filter: filter)
}
- filterRelays(by: currentSearchString)
- }
- func indexPathForSelectedRelay() -> IndexPath? {
- selectedRelayLocation.flatMap {
- indexPath(for: $0)
+ 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)
+ }
}
+
+ filterRelays(by: currentSearchString)
}
func filterRelays(by searchString: String) {
currentSearchString = searchString
- let list = SelectLocationSection.allCases.enumerated().map { section, group in
- dataSources[section]
+ let list = LocationSection.allCases.enumerated().map { index, section in
+ dataSources[index]
.search(by: searchString)
- .map { LocationCellViewModel(group: group, location: $0) }
+ .flatMap { node in
+ let rootNode = RootLocationNode(children: [node])
+ return recursivelyCreateCellViewModelTree(for: rootNode, in: section, indentationLevel: 0)
+ }
}
updateDataSnapshot(with: list, reloadExisting: !searchString.isEmpty) {
DispatchQueue.main.async {
if searchString.isEmpty {
- self.setSelectedRelayLocation(self.selectedRelayLocation, animated: false, completion: {
+ self.setSelectedItem(self.selectedItem, animated: false, completion: {
self.scrollToSelectedRelay()
})
} else {
@@ -92,29 +105,33 @@ final class LocationDataSource: UITableViewDiffableDataSource<SelectLocationSect
}
}
+ private func indexPathForSelectedRelay() -> IndexPath? {
+ selectedItem.flatMap { indexPath(for: $0) }
+ }
+
private func updateDataSnapshot(
with list: [[LocationCellViewModel]],
reloadExisting: Bool = false,
animated: Bool = false,
completion: (() -> Void)? = nil
) {
- var snapshot = NSDiffableDataSourceSnapshot<SelectLocationSection, LocationCellViewModel>()
+ var snapshot = NSDiffableDataSourceSnapshot<LocationSection, LocationCellViewModel>()
+ let sections = LocationSection.allCases
- let sections = SelectLocationSection.allCases
snapshot.appendSections(sections)
for (index, section) in sections.enumerated() {
snapshot.appendItems(list[index], toSection: section)
}
if reloadExisting {
- snapshot.reloadSections(SelectLocationSection.allCases)
+ snapshot.reloadSections(sections)
}
apply(snapshot, animatingDifferences: animated, completion: completion)
}
private func registerClasses() {
- SelectLocationSection.allCases.forEach {
+ LocationSection.allCases.forEach {
tableView.register(
$0.cell.reusableViewClass,
forCellReuseIdentifier: $0.cell.reuseIdentifier
@@ -122,44 +139,44 @@ final class LocationDataSource: UITableViewDiffableDataSource<SelectLocationSect
}
}
- private func setSelectedRelayLocation(
- _ relayLocation: LocationCellViewModel?,
+ private func setSelectedItem(
+ _ item: LocationCellViewModel?,
animated: Bool,
completion: (() -> Void)? = nil
) {
- selectedRelayLocation = relayLocation
- guard let selectedRelayLocation else { return }
+ selectedItem = item
+ guard let selectedItem else { return }
+
+ let rootNode = selectedItem.node.root
- let group = selectedRelayLocation.group
- var locationList = snapshot().itemIdentifiers(inSection: group)
- guard !locationList.contains(selectedRelayLocation) else {
+ guard selectedItem.node != rootNode else {
completion?()
return
}
- let selectedLocationTree = selectedRelayLocation.location.ancestors + [selectedRelayLocation.location]
- guard let first = selectedLocationTree.first else { return }
- let topLocation = LocationCellViewModel(group: group, location: first)
+ guard let indexPath = indexPath(for: LocationCellViewModel(
+ section: selectedItem.section,
+ node: rootNode
+ )) else { return }
- guard let indexPath = indexPath(for: topLocation),
- let topNode = node(for: topLocation) else {
- return
- }
-
- selectedLocationTree.forEach { location in
- node(for: LocationCellViewModel(group: group, location: location))?.showsChildren = true
+ // Walk tree backwards to determine which nodes should be expanded.
+ selectedItem.node.forEachAncestor { node in
+ node.showsChildren = true
}
- locationList.addLocations(
- topNode.flatRelayLocationList().map { LocationCellViewModel(group: group, location: $0) },
- at: indexPath.row + 1
+ let nodesToAdd = recursivelyCreateCellViewModelTree(
+ for: rootNode,
+ in: selectedItem.section,
+ indentationLevel: 1
)
- var list: [[LocationCellViewModel]] = Array(repeating: [], count: dataSources.count)
- for index in 0 ..< list.count {
- list[index] = (index == indexPath.section)
- ? locationList
- : snapshot().itemIdentifiers(inSection: SelectLocationSection.allCases[index])
+ var snapshotItems = snapshot().itemIdentifiers(inSection: selectedItem.section)
+ snapshotItems.insert(contentsOf: nodesToAdd, at: indexPath.row + 1)
+
+ let list = LocationSection.allCases.enumerated().map { index, section in
+ index == indexPath.section
+ ? snapshotItems
+ : snapshot().itemIdentifiers(inSection: section)
}
updateDataSnapshot(
@@ -169,17 +186,58 @@ final class LocationDataSource: UITableViewDiffableDataSource<SelectLocationSect
completion: completion
)
}
+
+ private func recursivelyCreateCellViewModelTree(
+ for node: LocationNode,
+ in section: LocationSection,
+ indentationLevel: Int
+ ) -> [LocationCellViewModel] {
+ var viewModels = [LocationCellViewModel]()
+
+ for childNode in node.children where !childNode.isHiddenFromSearch {
+ viewModels.append(
+ LocationCellViewModel(
+ section: section,
+ node: childNode,
+ indentationLevel: indentationLevel
+ )
+ )
+
+ let indentationLevel = indentationLevel + 1
+
+ if childNode.showsChildren {
+ viewModels.append(
+ contentsOf: recursivelyCreateCellViewModelTree(
+ for: childNode,
+ in: section,
+ indentationLevel: indentationLevel
+ )
+ )
+ }
+ }
+
+ return viewModels
+ }
}
extension LocationDataSource: UITableViewDelegate {
- func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
- guard let item = itemIdentifier(for: indexPath) else { return false }
- return node(for: item)?.isActive ?? false
+ func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
+ nil
+ }
+
+ func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
+ let section = snapshot().sectionIdentifiers[section]
+
+ switch section {
+ case .customLists:
+ return 24
+ case .allLocations:
+ return 0
+ }
}
func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int {
- guard let item = itemIdentifier(for: indexPath) else { return 0 }
- return node(for: item)?.indentationLevel ?? 0
+ itemIdentifier(for: indexPath)?.indentationLevel ?? 0
}
func tableView(
@@ -188,86 +246,66 @@ extension LocationDataSource: UITableViewDelegate {
forRowAt indexPath: IndexPath
) {
if let item = itemIdentifier(for: indexPath),
- item == selectedRelayLocation {
+ item == selectedItem {
cell.setSelected(true, animated: false)
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- itemIdentifier(for: indexPath)
- .flatMap { item in
- guard item.location != selectedRelayLocation?.location else { return }
- didSelectRelayLocation?(item.location)
+ tableView.deselectRow(at: indexPath, animated: false)
- setSelectedRelayLocation(item, animated: false)
+ guard let item = itemIdentifier(for: indexPath) else { return }
- indexPathForSelectedRelay().flatMap {
- let cell = tableView.cellForRow(at: $0)
- cell?.setSelected(false, animated: false)
- }
- }
+ let topmostNode = item.node.root as? CustomListLocationNode
+ let relayLocations = RelayLocations(locations: item.node.locations, customListId: topmostNode?.customList.id)
+
+ didSelectRelayLocations?(relayLocations)
}
}
extension LocationDataSource: LocationCellEventHandler {
func toggleCell(for item: LocationCellViewModel) {
- indexPath(for: item).flatMap { indexPath in
- guard let node = node(for: item), let cell = tableView.cellForRow(at: indexPath) else { return }
-
- let isExpanded = node.showsChildren
- let group = SelectLocationSection.allCases[indexPath.section]
-
- node.showsChildren = !isExpanded
- locationCellFactory.configureCell(
- cell,
- item: LocationCellViewModel(group: group, location: node.location),
- indexPath: indexPath
- )
+ guard let indexPath = indexPath(for: item) else { return }
- var locationList = snapshot().itemIdentifiers(inSection: group)
- let locationsToEdit = node.flatRelayLocationList().map { LocationCellViewModel(group: group, location: $0) }
+ let sections = LocationSection.allCases
+ let section = sections[indexPath.section]
+ let isExpanded = item.node.showsChildren
+ var locationList = snapshot().itemIdentifiers(inSection: section)
- if !isExpanded {
- locationList.addLocations(locationsToEdit, at: indexPath.row + 1)
- } else {
- locationsToEdit.forEach { self.node(for: $0)?.showsChildren = false }
- locationList.removeLocations(locationsToEdit)
- }
+ item.node.showsChildren = !isExpanded
- var list: [[LocationCellViewModel]] = Array(repeating: [], count: dataSources.count)
- for index in 0 ..< list.count {
- list[index] = (index == indexPath.section)
- ? locationList
- : snapshot().itemIdentifiers(inSection: SelectLocationSection.allCases[index])
- }
-
- updateDataSnapshot(with: list, completion: {
- self.scroll(to: item, animated: true)
- })
+ if !isExpanded {
+ locationList.addSubNodes(from: item, at: indexPath)
+ } else {
+ locationList.recursivelyRemoveSubNodes(from: item.node)
}
- }
- func node(for item: LocationCellViewModel) -> SelectLocationNode? {
- guard let sectionIndex = SelectLocationSection.allCases.firstIndex(of: item.group) else {
- return nil
+ let list = sections.enumerated().map { index, section in
+ index == indexPath.section
+ ? locationList
+ : snapshot().itemIdentifiers(inSection: section)
}
- return dataSources[sectionIndex].nodeByLocation[item.location]
+
+ updateDataSnapshot(with: list, reloadExisting: true, completion: {
+ self.scroll(to: item, animated: true)
+ })
}
}
extension LocationDataSource {
- private func scroll(to location: LocationCellViewModel, animated: Bool) {
- guard let visibleIndexPaths = tableView.indexPathsForVisibleRows,
- let indexPath = indexPath(for: location),
- let node = node(for: location) else { return }
+ private func scroll(to item: LocationCellViewModel, animated: Bool) {
+ guard
+ let visibleIndexPaths = tableView.indexPathsForVisibleRows,
+ let indexPath = indexPath(for: item)
+ else { return }
- if node.children.count > visibleIndexPaths.count {
+ if item.node.children.count > visibleIndexPaths.count {
tableView.scrollToRow(at: indexPath, at: .top, animated: animated)
} else {
- node.children.last.flatMap { last in
+ if let last = item.node.children.last {
if let lastInsertedIndexPath = self.indexPath(for: LocationCellViewModel(
- group: SelectLocationSection.allCases[indexPath.section],
- location: last.location
+ section: LocationSection.allCases[indexPath.section],
+ node: last
)),
let lastVisibleIndexPath = visibleIndexPaths.last,
lastInsertedIndexPath >= lastVisibleIndexPath {
@@ -289,17 +327,27 @@ extension LocationDataSource {
}
private extension [LocationCellViewModel] {
- mutating func addLocations(_ locations: [LocationCellViewModel], at index: Int) {
- if index < count {
- insert(contentsOf: locations, at: index)
+ mutating func addSubNodes(from item: LocationCellViewModel, at indexPath: IndexPath) {
+ let section = LocationSection.allCases[indexPath.section]
+ let row = indexPath.row + 1
+
+ let locations = item.node.children.map {
+ LocationCellViewModel(section: section, node: $0, indentationLevel: item.indentationLevel + 1)
+ }
+
+ if row < count {
+ insert(contentsOf: locations, at: row)
} else {
append(contentsOf: locations)
}
}
- mutating func removeLocations(_ locations: [LocationCellViewModel]) {
- removeAll(where: { location in
- locations.contains(location)
- })
+ mutating func recursivelyRemoveSubNodes(from node: LocationNode) {
+ for node in node.children {
+ node.showsChildren = false
+ removeAll(where: { node == $0.node })
+
+ recursivelyRemoveSubNodes(from: node)
+ }
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift
index 6511f4bd44..9fff54fff1 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift
@@ -12,57 +12,67 @@ import MullvadTypes
import UIKit
protocol LocationDataSourceProtocol {
- var nodeByLocation: [RelayLocation: SelectLocationNode] { get }
-
- func search(by text: String) -> [RelayLocation]
-
- func reload(
- _ response: REST.ServerRelaysResponse,
- relays: [REST.ServerRelay]
- ) -> [RelayLocation]
+ var nodes: [LocationNode] { get }
+ var searchableNodes: [LocationNode] { get }
}
extension LocationDataSourceProtocol {
- func makeRootNode(name: String) -> SelectLocationNode {
- SelectLocationNode(nodeType: .root, location: .country("#root"), displayName: name)
- }
+ func search(by text: String) -> [LocationNode] {
+ guard !text.isEmpty else {
+ resetNodes()
+ return nodes
+ }
+
+ var filteredNodes: [LocationNode] = []
+
+ searchableNodes.forEach { countryNode in
+ countryNode.showsChildren = false
+
+ if countryNode.name.fuzzyMatch(text) {
+ filteredNodes.append(countryNode)
+ }
+
+ countryNode.children.forEach { cityNode in
+ cityNode.showsChildren = false
+ cityNode.isHiddenFromSearch = true
- func createNode(
- root: SelectLocationNode,
- ancestorOrSelf: RelayLocation,
- serverLocation: REST.ServerLocation,
- relay: REST.ServerRelay,
- wasShowingChildren: Bool
- ) -> SelectLocationNode {
- let node: SelectLocationNode
+ var relaysContainSearchString = false
+ cityNode.children.forEach { hostNode in
+ hostNode.isHiddenFromSearch = true
+
+ if hostNode.name.fuzzyMatch(text) {
+ relaysContainSearchString = true
+ hostNode.isHiddenFromSearch = false
+ }
+ }
+
+ if cityNode.name.fuzzyMatch(text) || relaysContainSearchString {
+ if !filteredNodes.contains(countryNode) {
+ filteredNodes.append(countryNode)
+ }
+
+ countryNode.showsChildren = true
+ cityNode.isHiddenFromSearch = false
+
+ if relaysContainSearchString {
+ cityNode.showsChildren = true
+ }
+ }
+ }
+ }
+
+ return filteredNodes
+ }
- switch ancestorOrSelf {
- case .country:
- node = SelectLocationNode(
- nodeType: .country,
- location: ancestorOrSelf,
- displayName: serverLocation.country,
- showsChildren: wasShowingChildren
- )
- root.addChild(node)
- case let .city(countryCode, _):
- node = SelectLocationNode(
- nodeType: .city,
- location: ancestorOrSelf,
- displayName: serverLocation.city,
- showsChildren: wasShowingChildren
- )
- nodeByLocation[.country(countryCode)]!.addChild(node)
+ private func resetNodes() {
+ nodes.forEach { node in
+ node.showsChildren = false
+ node.isHiddenFromSearch = false
- case let .hostname(countryCode, cityCode, _):
- node = SelectLocationNode(
- nodeType: .relay,
- location: ancestorOrSelf,
- displayName: relay.hostname,
- isActive: relay.active
- )
- nodeByLocation[.city(countryCode, cityCode)]!.addChild(node)
+ node.forEachDescendant { descendantNode in
+ descendantNode.showsChildren = false
+ descendantNode.isHiddenFromSearch = false
+ }
}
- return node
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift
new file mode 100644
index 0000000000..8b123c55ee
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift
@@ -0,0 +1,152 @@
+//
+// LocationNode.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2024-02-21.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import MullvadTypes
+
+class LocationNode {
+ let name: String
+ var code: String
+ var locations: [RelayLocation]
+ var parent: LocationNode?
+ var children: [LocationNode]
+ var showsChildren: Bool
+ var isHiddenFromSearch: Bool
+
+ init(
+ name: String,
+ code: String,
+ locations: [RelayLocation] = [],
+ parent: LocationNode? = nil,
+ children: [LocationNode] = [],
+ showsChildren: Bool = false,
+ isHiddenFromSearch: Bool = false
+ ) {
+ self.name = name
+ self.code = code
+ self.locations = locations
+ self.parent = parent
+ self.children = children
+ self.showsChildren = showsChildren
+ self.isHiddenFromSearch = isHiddenFromSearch
+ }
+}
+
+extension LocationNode {
+ var root: LocationNode {
+ parent?.root ?? self
+ }
+
+ func countryFor(code: String) -> LocationNode? {
+ self.code == code ? self : children.first(where: { $0.code == code })
+ }
+
+ func cityFor(code: String) -> LocationNode? {
+ self.code == code ? self : children.first(where: { $0.code == code })
+ }
+
+ func hostFor(code: String) -> LocationNode? {
+ self.code == code ? self : children.first(where: { $0.code == code })
+ }
+
+ func descendantNodeFor(code: String) -> LocationNode? {
+ self.code == code ? self : children.compactMap { $0.descendantNodeFor(code: code) }.first
+ }
+
+ func forEachDescendant(do callback: (LocationNode) -> Void) {
+ children.forEach { child in
+ callback(child)
+ child.forEachDescendant(do: callback)
+ }
+ }
+
+ func forEachAncestor(do callback: (LocationNode) -> Void) {
+ if let parent = parent {
+ callback(parent)
+ parent.forEachAncestor(do: callback)
+ }
+ }
+}
+
+extension LocationNode {
+ func copy(withParent parent: LocationNode? = nil) -> LocationNode {
+ let node = LocationNode(
+ name: name,
+ code: code,
+ locations: locations,
+ parent: parent,
+ children: [],
+ showsChildren: showsChildren,
+ isHiddenFromSearch: isHiddenFromSearch
+ )
+
+ node.children = recursivelyCopyChildren(withParent: node)
+
+ return node
+ }
+
+ private func recursivelyCopyChildren(withParent parent: LocationNode) -> [LocationNode] {
+ children.map { $0.copy(withParent: parent) }
+ }
+}
+
+extension LocationNode: Hashable {
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(code)
+ }
+
+ static func == (lhs: LocationNode, rhs: LocationNode) -> Bool {
+ lhs.code == rhs.code
+ }
+}
+
+extension LocationNode: Comparable {
+ static func < (lhs: LocationNode, rhs: LocationNode) -> Bool {
+ lhs.name < rhs.name
+ }
+}
+
+/// Proxy class for building and/or searching node trees.
+class RootLocationNode: LocationNode {
+ init(name: String = "", code: String = "", children: [LocationNode] = []) {
+ super.init(name: name, code: code, children: children)
+ }
+}
+
+class CustomListLocationNode: LocationNode {
+ let customList: CustomList
+
+ init(
+ name: String,
+ code: String,
+ locations: [RelayLocation] = [],
+ parent: LocationNode? = nil,
+ children: [LocationNode] = [],
+ showsChildren: Bool = false,
+ isHiddenFromSearch: Bool = false,
+ customList: CustomList
+ ) {
+ self.customList = customList
+
+ super.init(
+ name: name,
+ code: code,
+ locations: locations,
+ parent: parent,
+ children: children,
+ showsChildren: showsChildren,
+ isHiddenFromSearch: isHiddenFromSearch
+ )
+ }
+}
+
+class CountryLocationNode: LocationNode {}
+
+class CityLocationNode: LocationNode {}
+
+class HostLocationNode: LocationNode {}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift
index 2e0984c809..6ebf676adb 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationSection.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationSection.swift
@@ -1,5 +1,5 @@
//
-// SelectLocationSectionGroup.swift
+// LocationSection.swift
// MullvadVPN
//
// Created by Mojgan on 2024-02-05.
@@ -7,7 +7,7 @@
//
import Foundation
-enum SelectLocationSection: Hashable, CustomStringConvertible, CaseIterable {
+enum LocationSection: Int, Hashable, CustomStringConvertible, CaseIterable {
case customLists
case allLocations
@@ -29,10 +29,10 @@ enum SelectLocationSection: Hashable, CustomStringConvertible, CaseIterable {
}
var cell: Cell {
- Cell.locationCell
+ .locationCell
}
- static var allCases: [SelectLocationSection] {
+ static var allCases: [LocationSection] {
#if DEBUG
return [.customLists, .allLocations]
#else
@@ -41,14 +41,14 @@ enum SelectLocationSection: Hashable, CustomStringConvertible, CaseIterable {
}
}
-extension SelectLocationSection {
+extension LocationSection {
enum Cell: String, CaseIterable {
case locationCell
var reusableViewClass: AnyClass {
switch self {
case .locationCell:
- return SelectLocationCell.self
+ return LocationCell.self
}
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
index 5f8f5d2145..16f4797d80 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewController.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
@@ -1,5 +1,5 @@
//
-// SelectLocationViewController.swift
+// LocationViewController.swift
// MullvadVPN
//
// Created by pronebird on 02/05/2019.
@@ -8,10 +8,11 @@
import MullvadLogging
import MullvadREST
+import MullvadSettings
import MullvadTypes
import UIKit
-final class SelectLocationViewController: UIViewController {
+final class LocationViewController: UIViewController {
private let searchBar = UISearchBar()
private let tableView = UITableView()
private let topContentView = UIStackView()
@@ -19,7 +20,7 @@ final class SelectLocationViewController: UIViewController {
private var dataSource: LocationDataSource?
private var cachedRelays: CachedRelays?
private var filter = RelayFilter()
- var relayLocation: RelayLocation?
+ var relayLocations: RelayLocations?
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
@@ -30,7 +31,7 @@ final class SelectLocationViewController: UIViewController {
}
var navigateToFilter: (() -> Void)?
- var didSelectRelay: ((RelayLocation) -> Void)?
+ var didSelectRelays: ((RelayLocations) -> Void)?
var didUpdateFilter: ((RelayFilter) -> Void)?
var didFinish: (() -> Void)?
@@ -85,16 +86,6 @@ final class SelectLocationViewController: UIViewController {
tableView.flashScrollIndicators()
}
- override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
- super.viewWillTransition(to: size, with: coordinator)
-
- coordinator.animate(alongsideTransition: nil) { _ in
- guard let indexPath = self.dataSource?.indexPathForSelectedRelay() else { return }
-
- self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
- }
- }
-
// MARK: - Public
func setCachedRelays(_ cachedRelays: CachedRelays, filter: RelayFilter) {
@@ -108,7 +99,7 @@ final class SelectLocationViewController: UIViewController {
filterView.setFilter(filter)
}
- dataSource?.setRelays(cachedRelays.relays, filter: filter)
+ dataSource?.setRelays(cachedRelays.relays, selectedLocations: relayLocations, filter: filter)
}
// MARK: - Private
@@ -117,19 +108,15 @@ final class SelectLocationViewController: UIViewController {
dataSource = LocationDataSource(
tableView: tableView,
allLocations: AllLocationDataSource(),
- customLists: CustomListsDataSource()
+ customLists: CustomListsDataSource(repository: CustomListRepository())
)
- dataSource?.didSelectRelayLocation = { [weak self] location in
- self?.didSelectRelay?(location)
- }
- dataSource?.selectedRelayLocation = relayLocation.flatMap { LocationCellViewModel(
- group: .allLocations,
- location: $0
- ) }
+ dataSource?.didSelectRelayLocations = { [weak self] locations in
+ self?.didSelectRelays?(locations)
+ }
if let cachedRelays {
- dataSource?.setRelays(cachedRelays.relays, filter: filter)
+ dataSource?.setRelays(cachedRelays.relays, selectedLocations: relayLocations, filter: filter)
}
}
@@ -181,7 +168,7 @@ final class SelectLocationViewController: UIViewController {
}
}
-extension SelectLocationViewController: UISearchBarDelegate {
+extension LocationViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
dataSource?.filterRelays(by: searchText)
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift
deleted file mode 100644
index 789075d15f..0000000000
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNode.swift
+++ /dev/null
@@ -1,123 +0,0 @@
-//
-// SelectLocationNode.swift
-// MullvadVPN
-//
-// Created by Mojgan on 2024-02-05.
-// Copyright © 2024 Mullvad VPN AB. All rights reserved.
-//
-
-import MullvadTypes
-
-enum LocationNodeType {
- case root
- case country
- case city
- case relay
-}
-
-class SelectLocationNode: SelectLocationNodeProtocol {
- var children: [SelectLocationNode]
- var showsChildren: Bool
- var nodeType: LocationNodeType
- var location: RelayLocation
- var displayName: String
- var isActive: Bool
-
- init(
- nodeType: LocationNodeType,
- location: RelayLocation,
- displayName: String = "",
- isActive: Bool = true,
- showsChildren: Bool = false,
- children: [SelectLocationNode] = []
- ) {
- self.showsChildren = showsChildren
- self.nodeType = nodeType
- self.location = location
- self.displayName = displayName
- self.isActive = isActive
- self.children = children
- }
-
- var isCollapsible: Bool {
- switch nodeType {
- case .country, .city:
- return true
- case .root, .relay:
- return false
- }
- }
-
- var indentationLevel: Int {
- switch nodeType {
- case .root, .country:
- return 0
- case .city:
- return 1
- case .relay:
- return 2
- }
- }
-
- func addChild(_ child: SelectLocationNode) {
- children.append(child)
- }
-
- func sortChildrenRecursive() {
- sortChildren()
- children.forEach { node in
- node.sortChildrenRecursive()
- }
- }
-
- func computeActiveChildrenRecursive() {
- switch nodeType {
- case .root, .country:
- for node in children {
- node.computeActiveChildrenRecursive()
- }
- fallthrough
- case .city:
- isActive = children.contains(where: { node -> Bool in
- node.isActive
- })
- case .relay:
- break
- }
- }
-
- func flatRelayLocationList(includeHiddenChildren: Bool = false) -> [RelayLocation] {
- children.reduce(into: []) { array, node in
- Self.flatten(node: node, into: &array, includeHiddenChildren: includeHiddenChildren)
- }
- }
-
- private func sortChildren() {
- switch nodeType {
- case .root, .country:
- children.sort { a, b -> Bool in
- a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending
- }
- case .city:
- children.sort { a, b -> Bool in
- a.location.stringRepresentation
- .localizedStandardCompare(b.location.stringRepresentation) == .orderedAscending
- }
- case .relay:
- break
- }
- }
-
- private class func flatten(
- node: SelectLocationNode,
- into array: inout [RelayLocation],
- includeHiddenChildren: Bool
- ) {
- array.append(node.location)
- if includeHiddenChildren || node.showsChildren {
- for child in node.children {
- Self.flatten(node: child, into: &array, includeHiddenChildren: includeHiddenChildren)
- }
- }
- }
-}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift
deleted file mode 100644
index 2d45f3f2d8..0000000000
--- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationNodeProtocol.swift
+++ /dev/null
@@ -1,18 +0,0 @@
-//
-// SelectLocationNodeProtocol.swift
-// MullvadVPN
-//
-// Created by Mojgan on 2024-02-05.
-// Copyright © 2024 Mullvad VPN AB. All rights reserved.
-//
-
-import MullvadTypes
-
-protocol SelectLocationNodeProtocol {
- var location: RelayLocation { get }
- var displayName: String { get }
- var showsChildren: Bool { get }
- var isActive: Bool { get }
- var isCollapsible: Bool { get }
- var indentationLevel: Int { get }
-}
diff --git a/ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift
index 1afa622d00..3cc5bb1e76 100644
--- a/ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SelectableSettingsCell.swift
@@ -20,7 +20,7 @@ class SelectableSettingsCell: SettingsCell {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setLeftView(tickImageView, spacing: UIMetrics.SettingsCell.selectableSettingsCellLeftViewSpacing)
- selectedBackgroundView?.backgroundColor = UIColor.Cell.selectedBackgroundColor
+ selectedBackgroundView?.backgroundColor = UIColor.Cell.Background.selected
}
required init?(coder: NSCoder) {
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsAddDNSEntryCell.swift b/ios/MullvadVPN/View controllers/Settings/SettingsAddDNSEntryCell.swift
index 01872824f5..11f2c416bc 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsAddDNSEntryCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsAddDNSEntryCell.swift
@@ -14,7 +14,7 @@ class SettingsAddDNSEntryCell: SettingsCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
- backgroundView?.backgroundColor = UIColor.SubSubCell.backgroundColor
+ backgroundView?.backgroundColor = UIColor.Cell.Background.indentationLevelTwo
let gestureRecognizer = UITapGestureRecognizer(
target: self,
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift
index e29c5f7593..8b4db565d0 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift
@@ -73,10 +73,10 @@ class SettingsCell: UITableViewCell, CustomCellDisclosureHandling {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundView = UIView()
- backgroundView?.backgroundColor = UIColor.Cell.backgroundColor
+ backgroundView?.backgroundColor = UIColor.Cell.Background.normal
selectedBackgroundView = UIView()
- selectedBackgroundView?.backgroundColor = UIColor.Cell.selectedAltBackgroundColor
+ selectedBackgroundView?.backgroundColor = UIColor.Cell.Background.selectedAlt
separatorInset = .zero
backgroundColor = .clear
@@ -150,7 +150,7 @@ class SettingsCell: UITableViewCell, CustomCellDisclosureHandling {
func applySubCellStyling() {
contentView.layoutMargins.left += UIMetrics.TableView.cellIndentationWidth
- backgroundView?.backgroundColor = UIColor.SubCell.backgroundColor
+ backgroundView?.backgroundColor = UIColor.Cell.Background.indentationLevelOne
}
func setLeftView(_ view: UIView, spacing: CGFloat) {
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDNSTextCell.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDNSTextCell.swift
index cbb3c552bc..039d3b34ec 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsDNSTextCell.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsDNSTextCell.swift
@@ -115,7 +115,7 @@ class SettingsDNSTextCell: SettingsCell, UITextFieldDelegate {
textField.textMargins.left = UIMetrics.SettingsCell.textFieldNonEditingContentInsetLeft
textField.textColor = .white
- backgroundView?.backgroundColor = UIColor.SubCell.backgroundColor
+ backgroundView?.backgroundColor = UIColor.Cell.Background.indentationLevelOne
}
}
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift b/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift
index 956367e5e0..e6735ea202 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsHeaderView.swift
@@ -75,7 +75,7 @@ class SettingsHeaderView: UITableViewHeaderFooterView {
)
contentView.directionalLayoutMargins = UIMetrics.SettingsCell.layoutMargins
- contentView.backgroundColor = UIColor.Cell.backgroundColor
+ contentView.backgroundColor = UIColor.Cell.Background.normal
let buttonAreaWidth = UIMetrics.contentLayoutMargins.leading + UIMetrics
.contentLayoutMargins.trailing + buttonWidth
diff --git a/ios/MullvadVPNTests/Location/AllLocationsDataSourceTests.swift b/ios/MullvadVPNTests/Location/AllLocationsDataSourceTests.swift
new file mode 100644
index 0000000000..bc343a3db7
--- /dev/null
+++ b/ios/MullvadVPNTests/Location/AllLocationsDataSourceTests.swift
@@ -0,0 +1,66 @@
+//
+// AllLocationsDataSourceTests.swift
+// MullvadVPNTests
+//
+// Created by Jon Petersson on 2024-02-29.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+@testable import MullvadSettings
+import XCTest
+
+class AllLocationsDataSourceTests: XCTestCase {
+ var allLocationNodes = [LocationNode]()
+ var dataSource: AllLocationDataSource!
+
+ override func setUp() async throws {
+ setUpDataSource()
+ }
+
+ func testNodeTree() throws {
+ let rootNode = RootLocationNode(children: dataSource.nodes)
+
+ // Testing a selection.
+ XCTAssertNotNil(rootNode.descendantNodeFor(code: "se"))
+ XCTAssertNotNil(rootNode.descendantNodeFor(code: "dal"))
+ XCTAssertNotNil(rootNode.descendantNodeFor(code: "es1-wireguard"))
+ XCTAssertNotNil(rootNode.descendantNodeFor(code: "se2-wireguard"))
+ }
+
+ func testSearch() throws {
+ let nodes = dataSource.search(by: "got")
+ let rootNode = RootLocationNode(children: nodes)
+
+ XCTAssertTrue(rootNode.descendantNodeFor(code: "got")?.isHiddenFromSearch == false)
+ XCTAssertTrue(rootNode.descendantNodeFor(code: "sto")?.isHiddenFromSearch == true)
+ }
+
+ func testSearchWithEmptyText() throws {
+ let nodes = dataSource.search(by: "")
+ XCTAssertEqual(nodes, dataSource.nodes)
+ }
+
+ func testNodeByLocation() throws {
+ var nodeByLocation = dataSource.node(by: .country("es"))
+ var nodeByCode = dataSource.nodes.first?.descendantNodeFor(code: "es")
+ XCTAssertEqual(nodeByLocation, nodeByCode)
+
+ nodeByLocation = dataSource.node(by: .city("es", "mad"))
+ nodeByCode = dataSource.nodes.first?.descendantNodeFor(code: "mad")
+ XCTAssertEqual(nodeByLocation, nodeByCode)
+
+ nodeByLocation = dataSource.node(by: .hostname("es", "mad", "es1-wireguard"))
+ nodeByCode = dataSource.nodes.first?.descendantNodeFor(code: "es1-wireguard")
+ XCTAssertEqual(nodeByLocation, nodeByCode)
+ }
+}
+
+extension AllLocationsDataSourceTests {
+ private func setUpDataSource() {
+ let response = ServerRelaysResponseStubs.sampleRelays
+ let relays = response.wireguard.relays
+
+ dataSource = AllLocationDataSource()
+ dataSource.reload(response, relays: relays)
+ }
+}
diff --git a/ios/MullvadVPNTests/CustomListRepositoryTests.swift b/ios/MullvadVPNTests/Location/CustomListRepositoryTests.swift
index be7bffc24e..be7bffc24e 100644
--- a/ios/MullvadVPNTests/CustomListRepositoryTests.swift
+++ b/ios/MullvadVPNTests/Location/CustomListRepositoryTests.swift
diff --git a/ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift b/ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift
new file mode 100644
index 0000000000..9085ec65d4
--- /dev/null
+++ b/ios/MullvadVPNTests/Location/CustomListsDataSourceTests.swift
@@ -0,0 +1,89 @@
+//
+// CustomListsDataSourceTests.swift
+// MullvadVPNTests
+//
+// Created by Jon Petersson on 2024-02-29.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+@testable import MullvadSettings
+import XCTest
+
+class CustomListsDataSourceTests: XCTestCase {
+ var allLocationNodes = [LocationNode]()
+ var dataSource: CustomListsDataSource!
+
+ override func setUp() async throws {
+ createAllLocationNodes()
+ setUpDataSource()
+ }
+
+ func testNodeTree() throws {
+ let nodes = dataSource.nodes
+
+ let netflixNode = try XCTUnwrap(nodes.first(where: { $0.name == "Netflix" }))
+ XCTAssertNotNil(netflixNode.descendantNodeFor(code: "netflix-es1-wireguard"))
+ XCTAssertNotNil(netflixNode.descendantNodeFor(code: "netflix-se"))
+ XCTAssertNotNil(netflixNode.descendantNodeFor(code: "netflix-dal"))
+
+ let youtubeNode = try XCTUnwrap(nodes.first(where: { $0.name == "Youtube" }))
+ XCTAssertNotNil(youtubeNode.descendantNodeFor(code: "youtube-se2-wireguard"))
+ XCTAssertNotNil(youtubeNode.descendantNodeFor(code: "youtube-dal"))
+ }
+
+ func testSearch() throws {
+ let nodes = dataSource.search(by: "got")
+ let rootNode = RootLocationNode(children: nodes)
+
+ XCTAssertTrue(rootNode.descendantNodeFor(code: "netflix-got")?.isHiddenFromSearch == false)
+ XCTAssertTrue(rootNode.descendantNodeFor(code: "netflix-sto")?.isHiddenFromSearch == true)
+ }
+
+ func testSearchWithEmptyText() throws {
+ let nodes = dataSource.search(by: "")
+ XCTAssertEqual(nodes, dataSource.nodes)
+ }
+
+ func testSearchYieldsNoListNodes() throws {
+ let nodes = dataSource.search(by: "net")
+ XCTAssertFalse(nodes.contains(where: { $0.name == "Netflix" }))
+ }
+
+ func testNodeByLocations() throws {
+ let nodeByLocations = dataSource.node(by: [.hostname("es", "mad", "es1-wireguard")], for: customLists.first!)
+ let nodeByCode = dataSource.nodes.first?.descendantNodeFor(code: "netflix-es1-wireguard")
+
+ XCTAssertEqual(nodeByLocations, nodeByCode)
+ }
+}
+
+extension CustomListsDataSourceTests {
+ private func setUpDataSource() {
+ dataSource = CustomListsDataSource(repository: CustomListsRepositoryStub(customLists: customLists))
+ dataSource.reload(allLocationNodes: allLocationNodes)
+ }
+
+ private func createAllLocationNodes() {
+ let response = ServerRelaysResponseStubs.sampleRelays
+ let relays = response.wireguard.relays
+
+ let dataSource = AllLocationDataSource()
+ dataSource.reload(response, relays: relays)
+
+ allLocationNodes = dataSource.nodes
+ }
+
+ var customLists: [CustomList] {
+ [
+ CustomList(name: "Netflix", locations: [
+ .hostname("es", "mad", "es1-wireguard"),
+ .country("se"),
+ .city("us", "dal"),
+ ]),
+ CustomList(name: "Youtube", locations: [
+ .hostname("se", "sto", "se2-wireguard"),
+ .city("us", "dal"),
+ ]),
+ ]
+ }
+}
diff --git a/ios/MullvadVPNTests/Location/LocationNodeTests.swift b/ios/MullvadVPNTests/Location/LocationNodeTests.swift
new file mode 100644
index 0000000000..b2775a7fb2
--- /dev/null
+++ b/ios/MullvadVPNTests/Location/LocationNodeTests.swift
@@ -0,0 +1,107 @@
+//
+// LocationNodeTests.swift
+// MullvadVPNTests
+//
+// Created by Jon Petersson on 2024-02-28.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import XCTest
+
+class LocationNodeTests: XCTestCase {
+ let listNode = CustomListLocationNode(
+ name: "List",
+ code: "list",
+ showsChildren: false,
+ customList: CustomList(name: "List", locations: [])
+ )
+ let countryNode = CountryLocationNode(name: "Country", code: "country", showsChildren: false)
+ let cityNode = CityLocationNode(name: "City", code: "city", showsChildren: false)
+ let hostNode = HostLocationNode(name: "Host", code: "host", showsChildren: false)
+
+ override func setUp() async throws {
+ createNodeTree()
+ }
+
+ func testNodeTree() throws {
+ XCTAssertEqual(listNode.children.first, countryNode)
+ XCTAssertEqual(countryNode.children.first, cityNode)
+ XCTAssertEqual(cityNode.children.first, hostNode)
+ XCTAssertNil(hostNode.children.first)
+ }
+
+ func testTopmostAncestor() throws {
+ XCTAssertEqual(hostNode.root, listNode)
+ }
+
+ func testAnscestors() throws {
+ hostNode.forEachAncestor { node in
+ node.showsChildren = true
+ }
+
+ XCTAssertTrue(listNode.showsChildren)
+ XCTAssertTrue(countryNode.showsChildren)
+ XCTAssertTrue(cityNode.showsChildren)
+ XCTAssertFalse(hostNode.showsChildren)
+ }
+
+ func testDescendants() throws {
+ listNode.forEachDescendant { node in
+ node.showsChildren = true
+ }
+
+ XCTAssertFalse(listNode.showsChildren)
+ XCTAssertTrue(countryNode.showsChildren)
+ XCTAssertTrue(cityNode.showsChildren)
+ XCTAssertTrue(hostNode.showsChildren)
+ }
+
+ func testCopyNode() throws {
+ let hostNodeCopy = hostNode.copy()
+
+ XCTAssertTrue(hostNode == hostNodeCopy)
+ XCTAssertFalse(hostNode === hostNodeCopy)
+
+ var numberOfDescendants = 0
+ hostNode.forEachDescendant { _ in
+ numberOfDescendants += 1
+ }
+
+ var numberOfCopyDescendants = 0
+ hostNodeCopy.forEachDescendant { _ in
+ numberOfCopyDescendants += 1
+ }
+
+ XCTAssertEqual(numberOfDescendants, numberOfCopyDescendants)
+ }
+
+ func testFindByCountryCode() {
+ XCTAssertTrue(listNode.countryFor(code: countryNode.code) == countryNode)
+ }
+
+ func testFindByCityCode() {
+ XCTAssertTrue(countryNode.cityFor(code: cityNode.code) == cityNode)
+ }
+
+ func testFindByHostCode() {
+ XCTAssertTrue(cityNode.hostFor(code: hostNode.code) == hostNode)
+ }
+
+ func testFindDescendantByNodeCode() {
+ XCTAssertTrue(listNode.descendantNodeFor(code: hostNode.code) == hostNode)
+ }
+}
+
+extension LocationNodeTests {
+ private func createNodeTree() {
+ hostNode.parent = cityNode
+ cityNode.children.append(hostNode)
+
+ cityNode.parent = countryNode
+ countryNode.children.append(cityNode)
+
+ countryNode.parent = listNode
+ listNode.children.append(countryNode)
+ }
+}
diff --git a/ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift b/ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift
new file mode 100644
index 0000000000..06bbd9d5f3
--- /dev/null
+++ b/ios/MullvadVPNTests/Mocks/CustomListsRepositoryStub.swift
@@ -0,0 +1,39 @@
+//
+// MockCustomListsRepository.swift
+// MullvadVPNTests
+//
+// Created by Jon Petersson on 2024-02-29.
+// Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import Combine
+import MullvadSettings
+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 delete(id: UUID) {}
+
+ func fetch(by id: UUID) -> CustomList? {
+ nil
+ }
+
+ func create(_ name: String, locations: [RelayLocation]) throws -> CustomList {
+ CustomList(name: "", locations: [])
+ }
+
+ func fetchAll() -> [CustomList] {
+ customLists
+ }
+}