summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2025-11-21 14:48:04 +0100
committerBug Magnet <marco.nikic@mullvad.net>2025-11-21 14:48:04 +0100
commit22672c58096399fbe2cb4ebf0bcd2c9c761b0932 (patch)
tree5e1210318bc367b4e6abb47b8395e0138a98a9ff
parent94c8baa3f92a1fabe19b307a6f8e18a78ced67a4 (diff)
parent7921176436500797c2c149619f8e6f63ce0fb212 (diff)
downloadmullvadvpn-22672c58096399fbe2cb4ebf0bcd2c9c761b0932.tar.xz
mullvadvpn-22672c58096399fbe2cb4ebf0bcd2c9c761b0932.zip
Merge branch 'rewrite-location-list-item-in-swiftui-ios-1285'
-rw-r--r--ios/Assets/Localizable.xcstrings50
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj160
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift3
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift184
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift5
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift10
-rw-r--r--ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift53
-rw-r--r--ios/MullvadVPN/Coordinators/LocationCoordinator.swift129
-rw-r--r--ios/MullvadVPN/Extensions/Image+Assets.swift5
-rw-r--r--ios/MullvadVPN/Supporting Files/Assets.xcassets/IconAdd.imageset/Add icon.svg8
-rw-r--r--ios/MullvadVPN/Supporting Files/Assets.xcassets/IconAdd.imageset/Contents.json12
-rw-r--r--ios/MullvadVPN/Supporting Files/Assets.xcassets/IconEdit.imageset/Contents.json12
-rw-r--r--ios/MullvadVPN/Supporting Files/Assets.xcassets/IconEdit.imageset/EditIcon.svg3
-rw-r--r--ios/MullvadVPN/Supporting Files/Assets.xcassets/RedDot.imageset/Contents.json12
-rw-r--r--ios/MullvadVPN/Supporting Files/Assets.xcassets/RedDot.imageset/RedDot.pngbin0 -> 372 bytes
-rw-r--r--ios/MullvadVPN/UI appearance/Color+Mullvad.swift10
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift7
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/ChipCollectionView.swift61
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/ChipFlowLayout.swift68
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/ChipViewCell.swift133
-rw-r--r--ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift187
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DAITAInfoView.swift82
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/AllLocationDataSource.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift)17
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/CustomListLocationNodeBuilder.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/CustomListLocationNodeBuilder.swift)0
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/CustomListsDataSource.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift)29
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift136
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDiffableDataSourceProtocol.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/LocationDiffableDataSourceProtocol.swift)0
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift)59
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationRelays.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/LocationRelays.swift)0
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/DataSource/RelaySelection.swift (renamed from ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift)0
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift20
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift398
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift68
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift95
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift228
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift304
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/MockSelectLocationViewModel.swift197
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/MultihopContext.swift14
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift99
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift369
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/ActiveFilterView.swift70
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/DaitaWarningView.swift29
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/EntryLocationView.swift15
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift160
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/LocationContextMenu.swift109
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/LocationDisclosureGroup.swift110
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift116
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift50
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/RelayItemView.swift106
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift94
-rw-r--r--ios/MullvadVPN/Views/MullvadAlert.swift104
-rw-r--r--ios/MullvadVPN/Views/MullvadListSectionHeader.swift22
-rw-r--r--ios/MullvadVPN/Views/MullvadPrimaryTextField.swift48
-rw-r--r--ios/MullvadVPN/Views/SegmentedControl.swift68
-rw-r--r--ios/MullvadVPNTests/MullvadSettings/CustomListsRepositoryStub.swift19
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/Interactors/CustomListInteractorTests.swift295
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift141
-rw-r--r--ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift69
-rw-r--r--ios/MullvadVPNUITests/Base/BaseUITestCase.swift4
-rw-r--r--ios/MullvadVPNUITests/ConnectivityTests.swift12
-rw-r--r--ios/MullvadVPNUITests/CustomListsTests.swift16
-rw-r--r--ios/MullvadVPNUITests/Pages/SelectLocationPage.swift86
-rw-r--r--ios/MullvadVPNUITests/Screenshots/ScreenshotTests.swift2
-rw-r--r--ios/MullvadVPNUITests/SelectLocationTests.swift4
64 files changed, 2962 insertions, 2014 deletions
diff --git a/ios/Assets/Localizable.xcstrings b/ios/Assets/Localizable.xcstrings
index 00bb9d6322..7a2717ebee 100644
--- a/ios/Assets/Localizable.xcstrings
+++ b/ios/Assets/Localizable.xcstrings
@@ -1560,6 +1560,9 @@
}
}
},
+ "Add %@ to list" : {
+
+ },
"Add 30 days time (%@)" : {
"localizations" : {
"da" : {
@@ -8435,6 +8438,9 @@
"Client is not allowed to issue the request." : {
},
+ "Collapse" : {
+
+ },
"Collapse %@" : {
},
@@ -8913,6 +8919,9 @@
}
}
},
+ "Connected server" : {
+
+ },
"Connected to %@" : {
},
@@ -13173,6 +13182,9 @@
}
}
},
+ "Edit" : {
+
+ },
"Edit custom list" : {
},
@@ -14245,6 +14257,7 @@
}
},
"Entry" : {
+ "extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -14481,6 +14494,7 @@
}
},
"Exit" : {
+ "extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -14598,6 +14612,9 @@
}
}
},
+ "Expand" : {
+
+ },
"Expand %@" : {
},
@@ -15097,6 +15114,9 @@
}
}
},
+ "Filters" : {
+
+ },
"Finland" : {
"localizations" : {
"da" : {
@@ -21475,6 +21495,9 @@
}
}
},
+ "List name" : {
+
+ },
"Ljubljana" : {
"localizations" : {
"da" : {
@@ -24550,6 +24573,7 @@
}
},
"multihop" : {
+ "extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -25496,6 +25520,9 @@
}
}
},
+ "New list" : {
+
+ },
"NEW VERSION INSTALLED" : {
"localizations" : {
"da" : {
@@ -26449,6 +26476,9 @@
}
}
},
+ "No result for \"%@\", please try a different search term." : {
+
+ },
"No servers match your location filter. Try changing filter settings." : {
},
@@ -28358,6 +28388,9 @@
}
}
},
+ "Owned" : {
+
+ },
"Ownership" : {
"localizations" : {
"da" : {
@@ -30485,6 +30518,9 @@
"Providers: %d" : {
},
+ "Providers: %lld" : {
+
+ },
"Quantum resistance" : {
"localizations" : {
"da" : {
@@ -31565,6 +31601,9 @@
"Removing the saved account number from this device cannot be undone.\nDo you want to remove the saved account number?" : {
},
+ "Rented" : {
+
+ },
"Rented only" : {
"localizations" : {
"da" : {
@@ -32634,6 +32673,7 @@
},
"Search for..." : {
+ "extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -37504,12 +37544,12 @@
"The entry and exit servers cannot be the same. Try changing one to a new server or location." : {
},
- "The entry server for %@ is currently overridden by %@. To select an entry server, please first enable “%@” or disable “%@“ in the settings." : {
+ "The entry server for %@ is currently overridden by %@. To select an entry server, please first enable “%@” or disable “%@” in the settings." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "The entry server for %1$@ is currently overridden by %2$@. To select an entry server, please first enable “%3$@” or disable “%4$@“ in the settings."
+ "value" : "The entry server for %1$@ is currently overridden by %2$@. To select an entry server, please first enable “%3$@” or disable “%4$@” in the settings."
}
}
}
@@ -38482,6 +38522,9 @@
}
}
},
+ "To add locations to a list, press the pen or long press on a country, city, or server." : {
+
+ },
"To add more, you will need to disconnect and access the Internet with an unsecure connection." : {
"localizations" : {
"da" : {
@@ -38954,6 +38997,9 @@
}
}
},
+ "To create a custom list press the “+” or long press on a country, city, or server." : {
+
+ },
"To create a custom list, tap on \"...\" " : {
},
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 23bd133ecc..e85a15fe93 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -141,7 +141,6 @@
583832292AC3DF1300EA2071 /* PacketTunnelActorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583832282AC3DF1300EA2071 /* PacketTunnelActorCommand.swift */; };
5838322B2AC3EF9600EA2071 /* EventChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5838322A2AC3EF9600EA2071 /* EventChannel.swift */; };
583D86482A2678DC0060D63B /* DeviceStateAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583D86472A2678DC0060D63B /* DeviceStateAccessor.swift */; };
- 583DA21425FA4B5C00318683 /* LocationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583DA21325FA4B5C00318683 /* LocationDataSource.swift */; };
58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5842102F282D8A3C00F24E46 /* UpdateAccountDataOperation.swift */; };
58421032282E42B000F24E46 /* UpdateDeviceDataOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58421031282E42B000F24E46 /* UpdateDeviceDataOperation.swift */; };
5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846227026E229F20035F7C2 /* StoreSubscription.swift */; };
@@ -219,7 +218,6 @@
588527B2276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */; };
588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */; };
5888AD83227B11080051EB06 /* LocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* LocationCell.swift */; };
- 5888AD87227B17950051EB06 /* LocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* LocationViewController.swift */; };
588D7ED62AF3903F005DF40A /* ListAccessMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7ED52AF3903F005DF40A /* ListAccessMethodView.swift */; };
588D7EDC2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDB2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift */; };
588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDD2AF3A585005DF40A /* ListAccessMethodItem.swift */; };
@@ -465,7 +463,6 @@
7A1A26492A29D48A00B978AA /* RelayFilterCellFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */; };
7A21DACF2A30AA3700A787A9 /* UITextField+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */; };
7A27E3CB2CAE861D0088BCFF /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27E3CA2CAE86170088BCFF /* SettingsViewModel.swift */; };
- 7A27E3CD2CB814EF0088BCFF /* DAITAInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27E3CC2CB814EA0088BCFF /* DAITAInfoView.swift */; };
7A27E3CF2CBD4A8C0088BCFF /* SelectableSettingsDetailsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27E3CE2CBD4A830088BCFF /* SelectableSettingsDetailsCell.swift */; };
7A27E3D12CC299F90088BCFF /* VPNSettingsDetailsButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27E3D02CC299E60088BCFF /* VPNSettingsDetailsButtonItem.swift */; };
7A28826A2BA8336600FD9F20 /* VPNSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2882692BA8336600FD9F20 /* VPNSettingsCoordinator.swift */; };
@@ -618,7 +615,6 @@
7AA7046A2C8EFE2B0045699D /* StoredRelays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA704682C8EFE050045699D /* StoredRelays.swift */; };
7AB2B6702BA1EB8C00B03E3B /* ListCustomListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */; };
7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */; };
- 7AB3BEB52BD7A6CB00E34384 /* LocationViewControllerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB3BEB42BD7A6CB00E34384 /* LocationViewControllerWrapper.swift */; };
7AB401852DA53D5300522E17 /* NewAccountData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB401842DA53D4E00522E17 /* NewAccountData.swift */; };
7AB401872DA53DA300522E17 /* NewAccountDataMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB401862DA53D9B00522E17 /* NewAccountDataMock.swift */; };
7AB4018D2DA790DA00522E17 /* SelectLocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4018C2DA790CE00522E17 /* SelectLocationTests.swift */; };
@@ -668,8 +664,6 @@
7AF9BE8C2A321D1F00DBFEDB /* RelayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */; };
7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */; };
7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; };
- 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */; };
- 7AF9BE972A41C71F00DBFEDB /* ChipViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */; };
7AFBE3892D089163002335FC /* TunnelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE3882D08915D002335FC /* TunnelViewController.swift */; };
7AFBE38B2D09AAFF002335FC /* SinglehopPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE38A2D09AAFF002335FC /* SinglehopPicker.swift */; };
7AFBE38D2D09AB2E002335FC /* MultihopPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFBE38C2D09AB2E002335FC /* MultihopPicker.swift */; };
@@ -922,7 +916,6 @@
F04AF92D2C466013004A8314 /* EphemeralPeerNegotiationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04AF92C2C466013004A8314 /* EphemeralPeerNegotiationState.swift */; };
F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F04FBE602A8379EE009278D7 /* AppPreferences.swift */; };
F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.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 */; };
@@ -999,8 +992,6 @@
F0ACE32D2BE4E784006D5333 /* AccountMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449EB9FE2B95FF2500DFA4EB /* AccountMock.swift */; };
F0ACE32F2BE4EA8B006D5333 /* MockProxyFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */; };
F0ACE3332BE516F1006D5333 /* RESTRequestExecutor+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */; };
- F0ADC3722CD3AD1600A1AD97 /* ChipCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */; };
- F0ADC3742CD3C47400A1AD97 /* ChipFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */; };
F0ADF1CD2CFDFF3100299F09 /* StringConversionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */; };
F0ADF1D12D01B55C00299F09 /* ChipModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1D02D01B55C00299F09 /* ChipModel.swift */; };
F0ADF1D32D01B6B400299F09 /* FeatureIndicatorsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */; };
@@ -1017,7 +1008,6 @@
F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F02BF751E300817A42 /* RelayWithDistance.swift */; };
F0B894F32BF7526700817A42 /* RelaySelector+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */; };
F0B894F52BF7528700817A42 /* RelaySelector+Shadowsocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */; };
- F0BE65372B9F136A005CC385 /* LocationSectionHeaderFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0BE65362B9F136A005CC385 /* LocationSectionHeaderFooterView.swift */; };
F0C13FE42C64F7CB00BD087D /* DAITASettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C13FE32C64F7CB00BD087D /* DAITASettings.swift */; };
F0C13FE62C64FB3400BD087D /* TunnelSettingsV6.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C13FE52C64FB3400BD087D /* TunnelSettingsV6.swift */; };
F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */; };
@@ -1062,6 +1052,8 @@
F0FA16152D7F3E16007E2546 /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; };
F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
F0FADDEC2BE90AB0000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
+ F90052522E6B06AD0085C80E /* SelectLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90052512E6B06AA0085C80E /* SelectLocationView.swift */; };
+ F90052562E6EEB290085C80E /* SelectLocationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90052552E6EEB290085C80E /* SelectLocationViewModel.swift */; };
F90A988A2E042D040020F64F /* ClearBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90A98892E042D040020F64F /* ClearBackgroundView.swift */; };
F90A988C2E1268570020F64F /* MullvadPrimaryTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90A988B2E1268510020F64F /* MullvadPrimaryTextField.swift */; };
F90A988E2E13C5490020F64F /* MullvadSecondaryTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F90A988D2E13C5490020F64F /* MullvadSecondaryTextField.swift */; };
@@ -1077,9 +1069,18 @@
F924C5A42DA65F28001F4660 /* Storekit2.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C5A32DA65F28001F4660 /* Storekit2.swift */; };
F924C65F2DAE4554001F4660 /* ServerRelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */; };
F9276C622DBA2103006FE43D /* Font+Mullvad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9276C612DBA20FC006FE43D /* Font+Mullvad.swift */; };
+ F92C658A2E7A922F00B8E107 /* ActiveFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92C65892E7A922F00B8E107 /* ActiveFilterView.swift */; };
+ F92C658C2E7A924E00B8E107 /* MockSelectLocationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92C658B2E7A924E00B8E107 /* MockSelectLocationViewModel.swift */; };
F9394EEC2DBF56B6009595EA /* Color+Mullvad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */; };
F9394EF02DC0B58D009595EA /* MullvadListNavigationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEF2DC0B58D009595EA /* MullvadListNavigationItemView.swift */; };
F9394EF32DC21D8C009595EA /* MullvadList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EF22DC21D8C009595EA /* MullvadList.swift */; };
+ F95A28312E8A8FC300C3F75D /* LocationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F95A28302E8A8FC300C3F75D /* LocationContext.swift */; };
+ F95A28332E8BBB7400C3F75D /* SelectLocationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F95A28322E8BBB7400C3F75D /* SelectLocationFilter.swift */; };
+ F95AC9EA2E5DFB0600A55B52 /* LocationsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F95AC9E92E5DFAFF00A55B52 /* LocationsListView.swift */; };
+ F96D04E72EC3174D004A4D48 /* LocationContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96D04E62EC31743004A4D48 /* LocationContextMenu.swift */; };
+ F96D04E92EC317B9004A4D48 /* ExitLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96D04E82EC317B9004A4D48 /* ExitLocationView.swift */; };
+ F96D04EB2EC317EC004A4D48 /* MullvadListSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96D04EA2EC317EC004A4D48 /* MullvadListSectionHeader.swift */; };
+ F96D04ED2EC318D8004A4D48 /* EntryLocationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96D04EC2EC318D8004A4D48 /* EntryLocationView.swift */; };
F97C38CA2DE49869006DCB08 /* MultihopSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38C92DE49869006DCB08 /* MultihopSettingsCoordinator.swift */; };
F97C38D92DE5930F006DCB08 /* CustomDNSCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38D82DE59307006DCB08 /* CustomDNSCoordinator.swift */; };
F97C38DF2DEEDB0F006DCB08 /* Color+Mullvad.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */; };
@@ -1088,7 +1089,15 @@
F97C38E82DF025D9006DCB08 /* MullvadAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97C38E72DF025D9006DCB08 /* MullvadAlert.swift */; };
F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; };
F998EFFA2D3656BA00D88D01 /* SKProduct+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */; };
+ F99E32432E7AA202004A7EFE /* MultihopContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F99E32412E7AA109004A7EFE /* MultihopContext.swift */; };
+ F9C579BD2E8E9AEE00C90C50 /* DaitaWarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C579BC2E8E9ADE00C90C50 /* DaitaWarningView.swift */; };
+ F9C579C22E8FB55600C90C50 /* LocationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C579C12E8FB55600C90C50 /* LocationSection.swift */; };
+ F9C579C42E8FE08600C90C50 /* LocationListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C579C32E8FE08600C90C50 /* LocationListItem.swift */; };
+ F9C579C62E8FE0D000C90C50 /* LocationDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C579C52E8FE0D000C90C50 /* LocationDisclosureGroup.swift */; };
+ F9C579C82E8FE10400C90C50 /* RelayItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C579C72E8FE10400C90C50 /* RelayItemView.swift */; };
F9E3BCF72DD35B78009986C3 /* ListAccessViewModelBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E3BCF62DD35B78009986C3 /* ListAccessViewModelBridge.swift */; };
+ F9EDB26C2EC4C0480015DE36 /* CustomListInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9EDB26B2EC4C0420015DE36 /* CustomListInteractorTests.swift */; };
+ F9EDB26D2EC4C0CD0015DE36 /* CustomListInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6389DA2B7E3BD6008E77E1 /* CustomListInteractor.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -1705,7 +1714,6 @@
583832282AC3DF1300EA2071 /* PacketTunnelActorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelActorCommand.swift; sourceTree = "<group>"; };
5838322A2AC3EF9600EA2071 /* EventChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventChannel.swift; sourceTree = "<group>"; };
583D86472A2678DC0060D63B /* DeviceStateAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStateAccessor.swift; sourceTree = "<group>"; };
- 583DA21325FA4B5C00318683 /* LocationDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDataSource.swift; sourceTree = "<group>"; };
583E1E292848DF67004838B3 /* OperationObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationObserverTests.swift; sourceTree = "<group>"; };
583E60952A9F6D0800DC61EF /* ConfigurationBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationBuilder.swift; sourceTree = "<group>"; };
583FE00B29C0C7FD006E85F9 /* ModalPresentationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalPresentationConfiguration.swift; sourceTree = "<group>"; };
@@ -1806,7 +1814,6 @@
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 /* 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 /* ListAccessMethodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodView.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>"; };
@@ -2000,7 +2007,6 @@
7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = "<group>"; };
7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITextField+Appearance.swift"; sourceTree = "<group>"; };
7A27E3CA2CAE86170088BCFF /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
- 7A27E3CC2CB814EA0088BCFF /* DAITAInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITAInfoView.swift; sourceTree = "<group>"; };
7A27E3CE2CBD4A830088BCFF /* SelectableSettingsDetailsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsDetailsCell.swift; sourceTree = "<group>"; };
7A27E3D02CC299E60088BCFF /* VPNSettingsDetailsButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsDetailsButtonItem.swift; sourceTree = "<group>"; };
7A2882692BA8336600FD9F20 /* VPNSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsCoordinator.swift; sourceTree = "<group>"; };
@@ -2138,7 +2144,6 @@
7AA704682C8EFE050045699D /* StoredRelays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredRelays.swift; sourceTree = "<group>"; };
7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListViewController.swift; sourceTree = "<group>"; };
7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListCoordinator.swift; sourceTree = "<group>"; };
- 7AB3BEB42BD7A6CB00E34384 /* LocationViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationViewControllerWrapper.swift; sourceTree = "<group>"; };
7AB401842DA53D4E00522E17 /* NewAccountData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAccountData.swift; sourceTree = "<group>"; };
7AB401862DA53D9B00522E17 /* NewAccountDataMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAccountDataMock.swift; sourceTree = "<group>"; };
7AB4018C2DA790CE00522E17 /* SelectLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationTests.swift; sourceTree = "<group>"; };
@@ -2180,8 +2185,6 @@
7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilter.swift; sourceTree = "<group>"; };
7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = "<group>"; };
7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = "<group>"; };
- 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = "<group>"; };
- 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewCell.swift; sourceTree = "<group>"; };
7AFBE3882D08915D002335FC /* TunnelViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelViewController.swift; sourceTree = "<group>"; };
7AFBE38A2D09AAFF002335FC /* SinglehopPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SinglehopPicker.swift; sourceTree = "<group>"; };
7AFBE38C2D09AB2E002335FC /* MultihopPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopPicker.swift; sourceTree = "<group>"; };
@@ -2351,7 +2354,6 @@
F04DD3D72C130DF600E03E28 /* TunnelSettingsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelSettingsManager.swift; sourceTree = "<group>"; };
F04FBE602A8379EE009278D7 /* AppPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = "<group>"; };
F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCellViewModel.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>"; };
@@ -2409,8 +2411,6 @@
F0ACE3082BE4E478006D5333 /* MullvadMockData.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MullvadMockData.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F0ACE30A2BE4E478006D5333 /* MullvadMockData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MullvadMockData.h; sourceTree = "<group>"; };
F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProxyFactory.swift; sourceTree = "<group>"; };
- F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipCollectionView.swift; sourceTree = "<group>"; };
- F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFlowLayout.swift; sourceTree = "<group>"; };
F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringConversionError.swift; sourceTree = "<group>"; };
F0ADF1D02D01B55C00299F09 /* ChipModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipModel.swift; sourceTree = "<group>"; };
F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIndicatorsViewModel.swift; sourceTree = "<group>"; };
@@ -2426,7 +2426,6 @@
F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = "<group>"; };
F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Wireguard.swift"; sourceTree = "<group>"; };
F0B894F42BF7528700817A42 /* RelaySelector+Shadowsocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Shadowsocks.swift"; sourceTree = "<group>"; };
- F0BE65362B9F136A005CC385 /* LocationSectionHeaderFooterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationSectionHeaderFooterView.swift; sourceTree = "<group>"; };
F0BEFD1F2E4A327A00C19030 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
F0C13FE32C64F7CB00BD087D /* DAITASettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAITASettings.swift; sourceTree = "<group>"; };
F0C13FE52C64FB3400BD087D /* TunnelSettingsV6.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV6.swift; sourceTree = "<group>"; };
@@ -2471,6 +2470,8 @@
F0FA160D2D7F2C3D007E2546 /* MockRelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRelayCache.swift; sourceTree = "<group>"; };
F0FA160F2D7F2FC0007E2546 /* RelayFilterViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModelTests.swift; sourceTree = "<group>"; };
F0FBD98E2C4A60CC00EE5323 /* KeyExchangingResultStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyExchangingResultStub.swift; sourceTree = "<group>"; };
+ F90052512E6B06AA0085C80E /* SelectLocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationView.swift; sourceTree = "<group>"; };
+ F90052552E6EEB290085C80E /* SelectLocationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewModel.swift; sourceTree = "<group>"; };
F90A98892E042D040020F64F /* ClearBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearBackgroundView.swift; sourceTree = "<group>"; };
F90A988B2E1268510020F64F /* MullvadPrimaryTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadPrimaryTextField.swift; sourceTree = "<group>"; };
F90A988D2E13C5490020F64F /* MullvadSecondaryTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadSecondaryTextField.swift; sourceTree = "<group>"; };
@@ -2486,16 +2487,32 @@
F924C5A32DA65F28001F4660 /* Storekit2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storekit2.swift; sourceTree = "<group>"; };
F924C65E2DAE4554001F4660 /* ServerRelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRelayTests.swift; sourceTree = "<group>"; };
F9276C612DBA20FC006FE43D /* Font+Mullvad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Mullvad.swift"; sourceTree = "<group>"; };
+ F92C65892E7A922F00B8E107 /* ActiveFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveFilterView.swift; sourceTree = "<group>"; };
+ F92C658B2E7A924E00B8E107 /* MockSelectLocationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSelectLocationViewModel.swift; sourceTree = "<group>"; };
F9394EEB2DBF56AA009595EA /* Color+Mullvad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Mullvad.swift"; sourceTree = "<group>"; };
F9394EEF2DC0B58D009595EA /* MullvadListNavigationItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadListNavigationItemView.swift; sourceTree = "<group>"; };
F9394EF22DC21D8C009595EA /* MullvadList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadList.swift; sourceTree = "<group>"; };
+ F95A28302E8A8FC300C3F75D /* LocationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationContext.swift; sourceTree = "<group>"; };
+ F95A28322E8BBB7400C3F75D /* SelectLocationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationFilter.swift; sourceTree = "<group>"; };
+ F95AC9E92E5DFAFF00A55B52 /* LocationsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListView.swift; sourceTree = "<group>"; };
+ F96D04E62EC31743004A4D48 /* LocationContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationContextMenu.swift; sourceTree = "<group>"; };
+ F96D04E82EC317B9004A4D48 /* ExitLocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExitLocationView.swift; sourceTree = "<group>"; };
+ F96D04EA2EC317EC004A4D48 /* MullvadListSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadListSectionHeader.swift; sourceTree = "<group>"; };
+ F96D04EC2EC318D8004A4D48 /* EntryLocationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryLocationView.swift; sourceTree = "<group>"; };
F97C38C92DE49869006DCB08 /* MultihopSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopSettingsCoordinator.swift; sourceTree = "<group>"; };
F97C38D82DE59307006DCB08 /* CustomDNSCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSCoordinator.swift; sourceTree = "<group>"; };
F97C38E22DEEDC28006DCB08 /* MullvadListActionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadListActionItemView.swift; sourceTree = "<group>"; };
F97C38E42DEEDFD2006DCB08 /* Image+Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Assets.swift"; sourceTree = "<group>"; };
F97C38E72DF025D9006DCB08 /* MullvadAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadAlert.swift; sourceTree = "<group>"; };
F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Sorting.swift"; sourceTree = "<group>"; };
+ F99E32412E7AA109004A7EFE /* MultihopContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultihopContext.swift; sourceTree = "<group>"; };
+ F9C579BC2E8E9ADE00C90C50 /* DaitaWarningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaitaWarningView.swift; sourceTree = "<group>"; };
+ F9C579C12E8FB55600C90C50 /* LocationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSection.swift; sourceTree = "<group>"; };
+ F9C579C32E8FE08600C90C50 /* LocationListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationListItem.swift; sourceTree = "<group>"; };
+ F9C579C52E8FE0D000C90C50 /* LocationDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDisclosureGroup.swift; sourceTree = "<group>"; };
+ F9C579C72E8FE10400C90C50 /* RelayItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayItemView.swift; sourceTree = "<group>"; };
F9E3BCF62DD35B78009986C3 /* ListAccessViewModelBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessViewModelBridge.swift; sourceTree = "<group>"; };
+ F9EDB26B2EC4C0420015DE36 /* CustomListInteractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListInteractorTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -2711,6 +2728,7 @@
440E9EF02BDA93CB00B1FD11 /* MullvadVPN */ = {
isa = PBXGroup;
children = (
+ F9EDB26A2EC4C03A0015DE36 /* Interactors */,
F0A7EBB02CEF6C5F005BB671 /* Log */,
440E9EF62BDA957300B1FD11 /* Classes */,
440E9F002BDA997C00B1FD11 /* Extensions */,
@@ -3137,22 +3155,16 @@
583FE01729C196F3006E85F9 /* SelectLocation */ = {
isa = PBXGroup;
children = (
- F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */,
- F04413602BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift */,
- F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */,
- 7A27E3CC2CB814EA0088BCFF /* DAITAInfoView.swift */,
5888AD82227B11080051EB06 /* LocationCell.swift */,
F050AE4D2B70D7F8003F4EDB /* LocationCellViewModel.swift */,
- 583DA21325FA4B5C00318683 /* LocationDataSource.swift */,
- F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */,
- 7A6652B62BB44B120042D848 /* LocationDiffableDataSourceProtocol.swift */,
- 7A6389F72B864CDF008E77E1 /* LocationNode.swift */,
- 7A5468AB2C6A55B100590086 /* LocationRelays.swift */,
- F050AE512B70DFC0003F4EDB /* LocationSection.swift */,
- F0BE65362B9F136A005CC385 /* LocationSectionHeaderFooterView.swift */,
- 5888AD86227B17950051EB06 /* LocationViewController.swift */,
- 7AB3BEB42BD7A6CB00E34384 /* LocationViewControllerWrapper.swift */,
- F01DAE322C2B032A00521E46 /* RelaySelection.swift */,
+ F9C579C12E8FB55600C90C50 /* LocationSection.swift */,
+ F92C658B2E7A924E00B8E107 /* MockSelectLocationViewModel.swift */,
+ F99E32412E7AA109004A7EFE /* MultihopContext.swift */,
+ F90052552E6EEB290085C80E /* SelectLocationViewModel.swift */,
+ F95A28322E8BBB7400C3F75D /* SelectLocationFilter.swift */,
+ F95A28302E8A8FC300C3F75D /* LocationContext.swift */,
+ F9C579C02E8FB4A800C90C50 /* DataSource */,
+ F900524C2E6B04470085C80E /* Views */,
);
path = SelectLocation;
sourceTree = "<group>";
@@ -3262,8 +3274,6 @@
583FE01F29C197ED006E85F9 /* Views */ = {
isa = PBXGroup;
children = (
- F90A988B2E1268510020F64F /* MullvadPrimaryTextField.swift */,
- F90A988D2E13C5490020F64F /* MullvadSecondaryTextField.swift */,
7A5869962B32EA4500640D27 /* AppButton.swift */,
7A0EAEA12D033D5A00D3EB8B /* BlurView.swift */,
7A9FA1412A2E3306000B728D /* CheckboxView.swift */,
@@ -3284,14 +3294,17 @@
7A0EAE992D01B41500D3EB8B /* MainButtonStyle.swift */,
F97C38E72DF025D9006DCB08 /* MullvadAlert.swift */,
F91B94A62DC9EB5E00132C28 /* MullvadInfoHeaderView.swift */,
+ F96D04EA2EC317EC004A4D48 /* MullvadListSectionHeader.swift */,
+ F90A988B2E1268510020F64F /* MullvadPrimaryTextField.swift */,
F91CCBFB2DFAF5E1007F1925 /* MullvadProgressViewStyle.swift */,
+ F90A988D2E13C5490020F64F /* MullvadSecondaryTextField.swift */,
7A8A190F2CEE3918000BCB5B /* RowSeparator.swift */,
+ A9019ED62E6878CD0002ACA9 /* SegmentedControl.swift */,
58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */,
7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */,
E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */,
58EF581025D69DB400AEBA94 /* StatusImageView.swift */,
7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */,
- A9019ED62E6878CD0002ACA9 /* SegmentedControl.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -4372,14 +4385,10 @@
7AF9BE912A39F47D00DBFEDB /* RelayFilter */ = {
isa = PBXGroup;
children = (
- F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */,
- F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */,
- 7AF9BE962A41C71F00DBFEDB /* ChipViewCell.swift */,
F0B583D32D6DCE0D007F5AE4 /* FilterDescriptor.swift */,
7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */,
7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */,
F017F8DF2D78ABE90076EC01 /* RelayFilterDataSourceItem.swift */,
- 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */,
7A1A26442A29CEF700B978AA /* RelayFilterViewController.swift */,
7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */,
);
@@ -4784,6 +4793,23 @@
path = Filter;
sourceTree = "<group>";
};
+ F900524C2E6B04470085C80E /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ F92C65892E7A922F00B8E107 /* ActiveFilterView.swift */,
+ F9C579BC2E8E9ADE00C90C50 /* DaitaWarningView.swift */,
+ F96D04EC2EC318D8004A4D48 /* EntryLocationView.swift */,
+ F96D04E82EC317B9004A4D48 /* ExitLocationView.swift */,
+ F96D04E62EC31743004A4D48 /* LocationContextMenu.swift */,
+ F9C579C52E8FE0D000C90C50 /* LocationDisclosureGroup.swift */,
+ F9C579C32E8FE08600C90C50 /* LocationListItem.swift */,
+ F95AC9E92E5DFAFF00A55B52 /* LocationsListView.swift */,
+ F9C579C72E8FE10400C90C50 /* RelayItemView.swift */,
+ F90052512E6B06AA0085C80E /* SelectLocationView.swift */,
+ );
+ path = Views;
+ sourceTree = "<group>";
+ };
F910A4322D4A1BA1002FF3BB /* InAppPurchase */ = {
isa = PBXGroup;
children = (
@@ -4802,6 +4828,29 @@
path = List;
sourceTree = "<group>";
};
+ F9C579C02E8FB4A800C90C50 /* DataSource */ = {
+ isa = PBXGroup;
+ children = (
+ F050AE5F2B73A41E003F4EDB /* AllLocationDataSource.swift */,
+ F04413602BA45CD70018A6EE /* CustomListLocationNodeBuilder.swift */,
+ F050AE612B74DBAC003F4EDB /* CustomListsDataSource.swift */,
+ F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */,
+ 7A6652B62BB44B120042D848 /* LocationDiffableDataSourceProtocol.swift */,
+ 7A6389F72B864CDF008E77E1 /* LocationNode.swift */,
+ 7A5468AB2C6A55B100590086 /* LocationRelays.swift */,
+ F01DAE322C2B032A00521E46 /* RelaySelection.swift */,
+ );
+ path = DataSource;
+ sourceTree = "<group>";
+ };
+ F9EDB26A2EC4C03A0015DE36 /* Interactors */ = {
+ isa = PBXGroup;
+ children = (
+ F9EDB26B2EC4C0420015DE36 /* CustomListInteractorTests.swift */,
+ );
+ path = Interactors;
+ sourceTree = "<group>";
+ };
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -5875,6 +5924,7 @@
A9A5F9EE2ACB05160083449F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */,
7A9BE5A72B907EEC00E2A7D0 /* AllLocationDataSource.swift in Sources */,
A9A5F9EF2ACB05160083449F /* String+AccountFormatting.swift in Sources */,
+ F9EDB26C2EC4C0480015DE36 /* CustomListInteractorTests.swift in Sources */,
A9A5F9F02ACB05160083449F /* String+FuzzyMatch.swift in Sources */,
F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */,
F0FA16152D7F3E16007E2546 /* Collection+Sorting.swift in Sources */,
@@ -5964,6 +6014,7 @@
F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */,
7A9BE5A22B8F88C500E2A7D0 /* LocationNodeTests.swift in Sources */,
F0FA160A2D7F0E8B007E2546 /* FilterDescriptor.swift in Sources */,
+ F9EDB26D2EC4C0CD0015DE36 /* CustomListInteractor.swift in Sources */,
A9A5FA232ACB05160083449F /* TunnelState.swift in Sources */,
A9A5FA242ACB05160083449F /* TunnelStore.swift in Sources */,
A9A5FA252ACB05160083449F /* UpdateAccountDataOperation.swift in Sources */,
@@ -6163,7 +6214,6 @@
58C76A0B2A338E4300100D75 /* BackgroundTask.swift in Sources */,
7A9CCCC32A96302800DD6A34 /* ApplicationCoordinator.swift in Sources */,
5864AF0729C78843005B0CD9 /* SettingsCellFactory.swift in Sources */,
- F0ADC3742CD3C47400A1AD97 /* ChipFlowLayout.swift in Sources */,
587B75412668FD7800DEF7E9 /* AccountExpirySystemNotificationProvider.swift in Sources */,
587988C728A2A01F00E3DF54 /* AccountDataThrottling.swift in Sources */,
F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */,
@@ -6175,7 +6225,6 @@
7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */,
7A8A191A2CEF41AF000BCB5B /* GroupedRowView.swift in Sources */,
58FF9FE22B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift in Sources */,
- F0ADC3722CD3AD1600A1AD97 /* ChipCollectionView.swift in Sources */,
F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */,
F041BE4F2C983C2B0083EC28 /* DAITASettingsPromptItem.swift in Sources */,
7A58699B2B482FE200640D27 /* UITableViewCell+Disable.swift in Sources */,
@@ -6211,8 +6260,10 @@
58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */,
7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */,
587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */,
+ F95AC9EA2E5DFB0600A55B52 /* LocationsListView.swift in Sources */,
5827B0922B0CAB2800CCBBA1 /* MethodSettingsViewController.swift in Sources */,
F01DAE332C2B032A00521E46 /* RelaySelection.swift in Sources */,
+ F96D04E72EC3174D004A4D48 /* LocationContextMenu.swift in Sources */,
58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */,
F91CCBFC2DFAF5E6007F1925 /* MullvadProgressViewStyle.swift in Sources */,
7A516C2E2B6D357500BBD33D /* URL+Scoping.swift in Sources */,
@@ -6246,8 +6297,10 @@
58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */,
58EFC7752AFB4CEF00E9F4CB /* AboutViewController.swift in Sources */,
5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */,
+ F9C579C42E8FE08600C90C50 /* LocationListItem.swift in Sources */,
7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */,
F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */,
+ F92C658C2E7A924E00B8E107 /* MockSelectLocationViewModel.swift in Sources */,
58CEB30A2AFD584700E6E088 /* CustomCellDisclosureHandling.swift in Sources */,
58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */,
7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */,
@@ -6256,6 +6309,7 @@
58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */,
7A0B311E2B303A0D004B12E0 /* AccessbilityIdentifier.swift in Sources */,
E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */,
+ F96D04ED2EC318D8004A4D48 /* EntryLocationView.swift in Sources */,
7AC8A3AE2ABC6FBB00DC4939 /* SettingsHeaderView.swift in Sources */,
588D7EDC2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift in Sources */,
588D7ED62AF3903F005DF40A /* ListAccessMethodView.swift in Sources */,
@@ -6267,7 +6321,6 @@
7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */,
58C774BE29A7A249003A1A56 /* CustomNavigationController.swift in Sources */,
E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */,
- F0BE65372B9F136A005CC385 /* LocationSectionHeaderFooterView.swift in Sources */,
7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */,
0697D6E728F01513007A9E99 /* APITransportMonitor.swift in Sources */,
58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */,
@@ -6276,6 +6329,7 @@
F998EFFA2D3656BA00D88D01 /* SKProduct+Sorting.swift in Sources */,
F050AE4E2B70D7F8003F4EDB /* LocationCellViewModel.swift in Sources */,
58CEB30C2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift in Sources */,
+ F9C579C22E8FB55600C90C50 /* LocationSection.swift in Sources */,
587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */,
5820EDA9288FE064006BF4E4 /* DeviceManaging.swift in Sources */,
F03A69F92C2AD414000E2E7E /* FormsheetPresentationController.swift in Sources */,
@@ -6295,6 +6349,7 @@
5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */,
F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */,
58F2E146276A2C9900A79513 /* StopTunnelOperation.swift in Sources */,
+ F9C579C62E8FE0D000C90C50 /* LocationDisclosureGroup.swift in Sources */,
E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */,
586C0D872B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift in Sources */,
7A9CCCBD2A96302800DD6A34 /* LoginCoordinator.swift in Sources */,
@@ -6319,7 +6374,6 @@
58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */,
58EE2E3B272FF814003BFF93 /* SettingsDataSourceDelegate.swift in Sources */,
5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */,
- 5888AD87227B17950051EB06 /* LocationViewController.swift in Sources */,
F006CCFC2B99CC8400C6C2AC /* EditLocationsCoordinator.swift in Sources */,
44EE8E0A2E58A8D50025196E /* AttributedString+Helpers.swift in Sources */,
58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */,
@@ -6371,11 +6425,10 @@
585E820327F3285E00939F0E /* SendStoreReceiptOperation.swift in Sources */,
5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */,
585B4B8726D9098900555C4C /* TunnelStatusNotificationProvider.swift in Sources */,
- 7AF9BE972A41C71F00DBFEDB /* ChipViewCell.swift in Sources */,
F9276C622DBA2103006FE43D /* Font+Mullvad.swift in Sources */,
063F026628FFE11C001FA09F /* RESTCreateApplePaymentResponse+Localization.swift in Sources */,
58DF28A52417CB4B00E836B0 /* StorePaymentManager.swift in Sources */,
- 583DA21425FA4B5C00318683 /* LocationDataSource.swift in Sources */,
+ F9C579C82E8FE10400C90C50 /* RelayItemView.swift in Sources */,
F050AE602B73A41E003F4EDB /* AllLocationDataSource.swift in Sources */,
587EB6742714520600123C75 /* VPNSettingsDataSourceDelegate.swift in Sources */,
7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */,
@@ -6404,7 +6457,7 @@
44EE8E062E4CB8B80025196E /* AccountDeletionView.swift in Sources */,
7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */,
F02F41A22B9723AF00625A4F /* AddLocationsCoordinator.swift in Sources */,
- 7A27E3CD2CB814EF0088BCFF /* DAITAInfoView.swift in Sources */,
+ F90052562E6EEB290085C80E /* SelectLocationViewModel.swift in Sources */,
F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */,
447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */,
7A5869C52B5A899C00640D27 /* MethodSettingsCellConfiguration.swift in Sources */,
@@ -6419,11 +6472,14 @@
58EF875D2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift in Sources */,
58CE5E66224146200008646E /* LoginViewController.swift in Sources */,
A9019ED72E6878CD0002ACA9 /* SegmentedControl.swift in Sources */,
+ F96D04EB2EC317EC004A4D48 /* MullvadListSectionHeader.swift in Sources */,
F048BFA22D31843000251CB9 /* ChangeLogModel.swift in Sources */,
F0C6FA852A6A733700F521F0 /* InAppPurchaseInteractor.swift in Sources */,
58CEB2F92AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift in Sources */,
5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */,
A98502032B627B120061901E /* LocalNetworkProbe.swift in Sources */,
+ F95A28312E8A8FC300C3F75D /* LocationContext.swift in Sources */,
+ F9C579BD2E8E9AEE00C90C50 /* DaitaWarningView.swift in Sources */,
7A6F2FA92AFD0842006D0856 /* CustomDNSDataSource.swift in Sources */,
58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */,
5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */,
@@ -6437,6 +6493,7 @@
440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */,
588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */,
5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */,
+ F90052522E6B06AD0085C80E /* SelectLocationView.swift in Sources */,
588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */,
F0B4957A2D02F49200CFEC2A /* ChipFeature.swift in Sources */,
F910A43A2D4A283D002FF3BB /* InAppPurchaseViewController.swift in Sources */,
@@ -6508,15 +6565,14 @@
58F2E144276A13F300A79513 /* StartTunnelOperation.swift in Sources */,
58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */,
586E54FB27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift in Sources */,
+ F92C658A2E7A922F00B8E107 /* ActiveFilterView.swift in Sources */,
5875960A26F371FC00BF6711 /* Tunnel+Messaging.swift in Sources */,
586C0D912B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift in Sources */,
F0B583D42D6DCE12007F5AE4 /* FilterDescriptor.swift in Sources */,
7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */,
- F050AE522B70DFC0003F4EDB /* LocationSection.swift in Sources */,
063687BA28EB234F00BE7161 /* PacketTunnelAPITransport.swift in Sources */,
A9C342C12ACC37E30045F00E /* TunnelStatusBlockObserver.swift in Sources */,
587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */,
- 7AB3BEB52BD7A6CB00E34384 /* LocationViewControllerWrapper.swift in Sources */,
F09D04BD2AEBB7C5003D4F89 /* OutgoingConnectionService.swift in Sources */,
F97C38D92DE5930F006DCB08 /* CustomDNSCoordinator.swift in Sources */,
58FF9FF42B07C61B00E4C97D /* AccessMethodValidationError.swift in Sources */,
@@ -6547,6 +6603,7 @@
58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */,
F0ADF1D12D01B55C00299F09 /* ChipModel.swift in Sources */,
F09A297B2A9F8A9B00EA3B6F /* LogoutDialogueView.swift in Sources */,
+ F99E32432E7AA202004A7EFE /* MultihopContext.swift in Sources */,
5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */,
7A21DACF2A30AA3700A787A9 /* UITextField+Appearance.swift in Sources */,
585B1FF02AB09F97008AD470 /* VPNConnectionProtocol.swift in Sources */,
@@ -6556,7 +6613,7 @@
F09A297C2A9F8A9B00EA3B6F /* VoucherTextField.swift in Sources */,
7A5869B72B56B41500640D27 /* IPOverrideTextViewController.swift in Sources */,
58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */,
- 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */,
+ F96D04E92EC317B9004A4D48 /* ExitLocationView.swift in Sources */,
7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */,
58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */,
58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */,
@@ -6564,6 +6621,7 @@
58EFC76E2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift in Sources */,
5827B0B92B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift in Sources */,
7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */,
+ F95A28332E8BBB7400C3F75D /* SelectLocationFilter.swift in Sources */,
5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */,
586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */,
7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */,
diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
index f849db464b..7217faddc3 100644
--- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
+++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
@@ -57,7 +57,6 @@ public enum AccessibilityIdentifier: Equatable {
case problemReportSendButton
case relayStatusCollapseButton
case settingsDoneButton
- case openCustomListsMenuButton
case addNewCustomListButton
case editCustomListButton
case saveCreateCustomListButton
@@ -70,6 +69,8 @@ public enum AccessibilityIdentifier: Equatable {
case openPortSelectorMenuButton
case cancelPurchaseListButton
case acceptLocalNetworkSharingButton
+ case selectLocationToolbarMenu
+ case locationListItem(String)
// Cells
case deviceCell
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift
index 3fafa1d8c6..75e79917db 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift
@@ -7,25 +7,195 @@
//
import MullvadSettings
+import MullvadTypes
protocol CustomListInteractorProtocol {
+ func fetch(by id: UUID) -> CustomList?
func fetchAll() -> [CustomList]
- func save(viewModel: CustomListViewModel) throws
- func delete(id: UUID)
+ func save(list: CustomList) throws
+ func delete(customList: CustomList)
+ func addLocationToCustomList(relayLocations: [RelayLocation], customListName: String) throws
+ func removeLocationFromCustomList(relayLocations: [RelayLocation], customListName: String) throws
}
struct CustomListInteractor: CustomListInteractorProtocol {
- let repository: CustomListRepositoryProtocol
+
+ private enum CustomListAction {
+ case save, delete
+ }
+
+ private let tunnelManager: SettingsUpdating
+ private let repository: CustomListRepositoryProtocol
+
+ init(
+ tunnelManager: SettingsUpdating,
+ repository: CustomListRepositoryProtocol
+ ) {
+ self.tunnelManager = tunnelManager
+ self.repository = repository
+ }
+
+ func fetch(by id: UUID) -> CustomList? {
+ repository.fetch(by: id)
+ }
func fetchAll() -> [CustomList] {
repository.fetchAll()
}
- func save(viewModel: CustomListViewModel) throws {
- try repository.save(list: viewModel.customList)
+ func save(list: CustomList) throws {
+ try repository.save(list: list)
+ updateCustomListRelayConstraints(list: list, action: .save)
}
- func delete(id: UUID) {
- repository.delete(id: id)
+ func delete(customList: CustomList) {
+ repository.delete(id: customList.id)
+ updateCustomListRelayConstraints(list: customList, action: .delete)
+ }
+
+ func addLocationToCustomList(relayLocations: [RelayLocation], customListName: String) throws {
+ let customList =
+ fetchAll().first { $0.name == customListName }
+ ?? CustomList(
+ name: customListName,
+ locations: []
+ )
+
+ let allLocations = (customList.locations + relayLocations)
+ let locations: [RelayLocation] =
+ allLocations
+ .filter { $0.ancestors.allSatisfy { !allLocations.contains($0) } }
+ .reduce(
+ [],
+ { partialResult, location in
+ if !partialResult.contains(location) {
+ return partialResult + [location]
+ } else {
+ return partialResult
+ }
+ })
+ let newCustomList = CustomList(
+ id: customList.id,
+ name: customList.name,
+ locations: locations
+ )
+ try save(list: newCustomList)
+ }
+
+ func removeLocationFromCustomList(
+ relayLocations: [RelayLocation],
+ customListName: String
+ ) throws {
+ let customList = fetchAll().first { $0.name == customListName }
+ guard let customList else {
+ return
+ }
+ let allLocations = customList.locations.filter {
+ !relayLocations.contains($0)
+ }
+ let newCustomList = CustomList(
+ id: customList.id,
+ name: customList.name,
+ locations: allLocations
+ )
+ try save(list: newCustomList)
+ }
+
+ private func updateCustomListRelayConstraints(list: CustomList, action: CustomListAction) {
+ var relayConstraints = tunnelManager.settings.relayConstraints
+
+ // only update relay constraints if custom list is currently selected
+ var isSelectionAffected = false
+ if let customListExitSelection = relayConstraints.exitLocations.value?.customListSelection {
+ if customListExitSelection.listId == list.id {
+ isSelectionAffected = true
+ }
+ }
+ if let customListEntrySelection = relayConstraints.entryLocations.value?.customListSelection {
+ if customListEntrySelection.listId == list.id {
+ isSelectionAffected = true
+ }
+ }
+ guard isSelectionAffected else {
+ return
+ }
+
+ let newEntryLocations = self.updateRelayConstraint(
+ relayConstraints.entryLocations,
+ for: action,
+ in: list
+ )
+
+ let newExitLocations = self.updateRelayConstraint(
+ relayConstraints.exitLocations,
+ for: action,
+ in: list
+ )
+
+ if newExitLocations.value != relayConstraints.exitLocations.value
+ || newEntryLocations.value != relayConstraints.entryLocations.value
+ {
+ relayConstraints.exitLocations = newExitLocations
+ relayConstraints.entryLocations = newEntryLocations
+ tunnelManager
+ .updateSettings(
+ [.relayConstraints(relayConstraints)],
+ completionHandler: nil
+ )
+ }
+ }
+
+ private func updateRelayConstraint(
+ _ relayConstraint: RelayConstraint<UserSelectedRelays>,
+ for action: CustomListAction,
+ in list: CustomList
+ ) -> RelayConstraint<UserSelectedRelays> {
+ var relayConstraint = relayConstraint
+
+ guard let customListSelection = relayConstraint.value?.customListSelection,
+ customListSelection.listId == list.id
+ else {
+ // leave constraint untouched if custom list is not selected
+ return relayConstraint
+ }
+
+ switch action {
+ case .save:
+ // update constraint to custom list
+ if customListSelection.isList {
+ // the selection is the list itself. In that case, update the constraint
+ let selectedRelays = UserSelectedRelays(
+ locations: list.locations,
+ customListSelection: UserSelectedRelays.CustomListSelection(listId: list.id, isList: true)
+ )
+ relayConstraint = .only(selectedRelays)
+ } else {
+ // the selection is a location inside a custom list
+ let selectedConstraintIsRemovedFromList = list.locations.allSatisfy { listLocation in
+ !(relayConstraint.value?.locations
+ .flatMap { [$0] + $0.ancestors }
+ .contains(listLocation) ?? false)
+ }
+
+ if selectedConstraintIsRemovedFromList {
+ // remove location from constraint if it is removed from the custom list
+ // this will lead to the blocked state
+ relayConstraint = .only(UserSelectedRelays(locations: []))
+ }
+ }
+ case .delete:
+ // remove list from constraint
+ // this will lead to the blocked state
+ relayConstraint = .only(UserSelectedRelays(locations: []))
+ }
+
+ return relayConstraint
}
}
+
+protocol SettingsUpdating {
+ func updateSettings(_ updates: [TunnelSettingsUpdate], completionHandler: (@Sendable () -> Void)?)
+ var settings: LatestTunnelSettings { get }
+}
+
+extension TunnelManager: SettingsUpdating {}
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
index 4ad6713ef1..8897966037 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
@@ -146,7 +146,7 @@ class CustomListViewController: UIViewController {
private func onSave() {
do {
- try interactor.save(viewModel: subject.value)
+ try interactor.save(list: subject.value.customList)
delegate?.customListDidSave(subject.value.customList)
} catch {
if let error = error as? CustomRelayListError {
@@ -175,7 +175,8 @@ class CustomListViewController: UIViewController {
style: .destructive,
accessibilityId: .confirmDeleteCustomListButton,
handler: {
- self.interactor.delete(id: self.subject.value.id)
+ self.interactor
+ .delete(customList: self.subject.value.customList)
self.delegate?.customListDidDelete(self.subject.value.customList)
}
),
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
index 40e02330e9..5fd1d41967 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/EditCustomListCoordinator.swift
@@ -12,10 +12,6 @@ import Routing
import UIKit
class EditCustomListCoordinator: Coordinator, Presentable, Presenting {
- enum FinishAction {
- case save, delete
- }
-
let navigationController: UINavigationController
let customListInteractor: CustomListInteractorProtocol
let customList: CustomList
@@ -29,7 +25,7 @@ class EditCustomListCoordinator: Coordinator, Presentable, Presenting {
navigationController
}
- var didFinish: ((EditCustomListCoordinator, FinishAction, CustomList) -> Void)?
+ var didFinish: ((EditCustomListCoordinator, CustomList) -> Void)?
var didCancel: ((EditCustomListCoordinator) -> Void)?
init(
@@ -122,11 +118,11 @@ class EditCustomListCoordinator: Coordinator, Presentable, Presenting {
extension EditCustomListCoordinator: @preconcurrency CustomListViewControllerDelegate {
func customListDidSave(_ list: CustomList) {
- didFinish?(self, .save, list)
+ didFinish?(self, list)
}
func customListDidDelete(_ list: CustomList) {
- didFinish?(self, .delete, list)
+ didFinish?(self, list)
}
func showLocations(_ list: CustomList) {
diff --git a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift
index 5943bb43c2..669c74e5e8 100644
--- a/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/CustomLists/ListCustomListCoordinator.swift
@@ -58,27 +58,11 @@ class ListCustomListCoordinator: Coordinator, Presentable, Presenting {
nodes: nodes
)
- coordinator.didFinish = { [weak self] editCustomListCoordinator, action, list in
+ coordinator.didFinish = { [weak self] editCustomListCoordinator, list in
guard let self else { return }
popToList()
editCustomListCoordinator.removeFromParent()
-
- var relayConstraints = tunnelManager.settings.relayConstraints
- relayConstraints.entryLocations = self.updateRelayConstraint(
- relayConstraints.entryLocations,
- for: action,
- in: list
- )
- relayConstraints.exitLocations = self.updateRelayConstraint(
- relayConstraints.exitLocations,
- for: action,
- in: list
- )
-
- tunnelManager.updateSettings([.relayConstraints(relayConstraints)]) { [weak self] in
- self?.tunnelManager.reconnectTunnel(selectNewRelay: true)
- }
}
coordinator.didCancel = { [weak self] editCustomListCoordinator in
@@ -91,41 +75,6 @@ class ListCustomListCoordinator: Coordinator, Presentable, Presenting {
addChild(coordinator)
}
- private func updateRelayConstraint(
- _ relayConstraint: RelayConstraint<UserSelectedRelays>,
- for action: EditCustomListCoordinator.FinishAction,
- in list: CustomList
- ) -> RelayConstraint<UserSelectedRelays> {
- var relayConstraint = relayConstraint
-
- guard let customListSelection = relayConstraint.value?.customListSelection,
- customListSelection.listId == list.id
- else { return relayConstraint }
-
- switch action {
- case .save:
- if customListSelection.isList {
- let selectedRelays = UserSelectedRelays(
- locations: list.locations,
- customListSelection: UserSelectedRelays.CustomListSelection(listId: list.id, isList: true)
- )
- relayConstraint = .only(selectedRelays)
- } else {
- let selectedConstraintIsRemovedFromList = list.locations.filter {
- relayConstraint.value?.locations.contains($0) ?? false
- }.isEmpty
-
- if selectedConstraintIsRemovedFromList {
- relayConstraint = .only(UserSelectedRelays(locations: []))
- }
- }
- case .delete:
- relayConstraint = .only(UserSelectedRelays(locations: []))
- }
-
- return relayConstraint
- }
-
private func popToList() {
if interactor.fetchAll().isEmpty {
navigationController.dismiss(animated: true)
diff --git a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift
index 744f7f037f..b19f39e92c 100644
--- a/ios/MullvadVPN/Coordinators/LocationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/LocationCoordinator.swift
@@ -10,7 +10,7 @@ import MullvadREST
import MullvadSettings
import MullvadTypes
import Routing
-import UIKit
+import SwiftUI
class LocationCoordinator: Coordinator, Presentable, Presenting {
private let tunnelManager: TunnelManager
@@ -24,11 +24,7 @@ class LocationCoordinator: Coordinator, Presentable, Presenting {
navigationController
}
- var locationViewControllerWrapper: LocationViewControllerWrapper? {
- return navigationController.viewControllers.first {
- $0 is LocationViewControllerWrapper
- } as? LocationViewControllerWrapper
- }
+ var selectLocationViewModel: (any SelectLocationViewModel)!
var didFinish: ((LocationCoordinator) -> Void)?
@@ -45,56 +41,61 @@ class LocationCoordinator: Coordinator, Presentable, Presenting {
}
func start() {
- // If multihop is enabled, we should check if there's a DAITA related error when opening the location
- // view. If there is, help the user by showing the entry instead of the exit view.
- var startContext: LocationViewControllerWrapper.MultihopContext = .exit
- if tunnelManager.settings.tunnelMultihopState.isEnabled {
- startContext =
- if case .noRelaysSatisfyingDaitaConstraints = tunnelManager.tunnelStatus.observedState
- .blockedState?.reason
- { .entry } else { .exit }
- }
-
- let locationViewControllerWrapper = LocationViewControllerWrapper(
- settings: tunnelManager.settings,
+ let selectLocationViewModelImpl = SelectLocationViewModelImpl(
+ tunnelManager: tunnelManager,
relaySelectorWrapper: relaySelectorWrapper,
customListRepository: customListRepository,
- startContext: startContext
- )
-
- locationViewControllerWrapper.delegate = self
-
- locationViewControllerWrapper.didFinish = { [weak self] in
- guard let self else { return }
-
- if let tunnelObserver {
- tunnelManager.removeObserver(tunnelObserver)
- }
- didFinish?(self)
- }
-
- addTunnelObserver()
-
- navigationController.pushViewController(locationViewControllerWrapper, animated: false)
- }
-
- private func addTunnelObserver() {
- let tunnelObserver =
- TunnelBlockObserver(
- didUpdateTunnelSettings: { [weak self] _, settings in
+ delegate: .init(
+ showDaitaSettings: { [weak self] in
+ self?.navigateToDaitaSettings()
+ },
+ showObfuscationSettings: { [weak self] in
+ self?.navigateToObfuscationSettings()
+ },
+ showFilterView: { [weak self] in
+ self?.navigateToFilter()
+ },
+ showEditCustomListView: { [weak self] locations, customList in
+ if let customList {
+ self?.showEditCustomList(
+ list: customList,
+ nodes: locations
+ )
+ } else {
+ self?.showEditCustomLists(nodes: locations)
+ }
+ },
+ showAddCustomListView: { [weak self] locations in
+ self?.showAddCustomList(nodes: locations)
+ },
+ didSelectExitRelayLocations: { [weak self] relays in
+ guard let self else { return }
+ self.didSelectExitRelays(relays)
+ self.didFinish?(self)
+ },
+ didSelectEntryRelayLocations: { [weak self] relays in
+ self?.didSelectEntryRelays(relays)
+ },
+ didFinish: { [weak self] in
guard let self else { return }
- locationViewControllerWrapper?.onNewSettings?(settings)
+ self.didFinish?(self)
}
)
+ )
+ selectLocationViewModel = selectLocationViewModelImpl
+ let hostingController = UIHostingController(
+ rootView: SelectLocationView(
+ viewModel: selectLocationViewModelImpl)
+ )
- tunnelManager.addObserver(tunnelObserver)
- self.tunnelObserver = tunnelObserver
+ navigationController.pushViewController(hostingController, animated: false)
}
private func showAddCustomList(nodes: [LocationNode]) {
let coordinator = AddCustomListCoordinator(
navigationController: CustomNavigationController(),
interactor: CustomListInteractor(
+ tunnelManager: tunnelManager,
repository: customListRepository
),
nodes: nodes
@@ -102,7 +103,7 @@ class LocationCoordinator: Coordinator, Presentable, Presenting {
coordinator.didFinish = { [weak self] addCustomListCoordinator in
addCustomListCoordinator.dismiss(animated: true)
- self?.locationViewControllerWrapper?.refreshCustomLists()
+ self?.selectLocationViewModel?.customListsChanged()
}
coordinator.start()
@@ -112,14 +113,39 @@ class LocationCoordinator: Coordinator, Presentable, Presenting {
private func showEditCustomLists(nodes: [LocationNode]) {
let coordinator = ListCustomListCoordinator(
navigationController: InterceptibleNavigationController(),
- interactor: CustomListInteractor(repository: customListRepository),
+ interactor: CustomListInteractor(
+ tunnelManager: tunnelManager,
+ repository: customListRepository
+ ),
tunnelManager: tunnelManager,
nodes: nodes
)
coordinator.didFinish = { [weak self] listCustomListCoordinator in
listCustomListCoordinator.dismiss(animated: true)
- self?.locationViewControllerWrapper?.refreshCustomLists()
+ self?.selectLocationViewModel?.customListsChanged()
+ }
+
+ coordinator.start()
+ presentChild(coordinator, animated: true)
+
+ coordinator.presentedViewController.presentationController?.delegate = self
+ }
+
+ private func showEditCustomList(list: CustomList, nodes: [LocationNode]) {
+ let coordinator = EditCustomListCoordinator(
+ navigationController: InterceptibleNavigationController(),
+ customListInteractor: CustomListInteractor(
+ tunnelManager: tunnelManager,
+ repository: customListRepository
+ ),
+ customList: list,
+ nodes: nodes
+ )
+
+ coordinator.didFinish = { [weak self] editCustomListCoordinator, list in
+ editCustomListCoordinator.dismiss(animated: true)
+ self?.selectLocationViewModel?.customListsChanged()
}
coordinator.start()
@@ -127,17 +153,18 @@ class LocationCoordinator: Coordinator, Presentable, Presenting {
coordinator.presentedViewController.presentationController?.delegate = self
}
+
}
// Intercept dismissal (by down swipe) of ListCustomListCoordinator and apply custom actions.
// See showEditCustomLists() above.
extension LocationCoordinator: UIAdaptivePresentationControllerDelegate {
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
- locationViewControllerWrapper?.refreshCustomLists()
+ selectLocationViewModel?.customListsChanged()
}
}
-extension LocationCoordinator: @preconcurrency LocationViewControllerWrapperDelegate {
+extension LocationCoordinator {
func navigateToFilter() {
let relayFilterCoordinator = RelayFilterCoordinator(
navigationController: CustomNavigationController(),
@@ -166,6 +193,10 @@ extension LocationCoordinator: @preconcurrency LocationViewControllerWrapperDele
applicationRouter?.present(.daita)
}
+ func navigateToObfuscationSettings() {
+ applicationRouter?.present(.vpnSettings(.obfuscation))
+ }
+
func didSelectExitRelays(_ relays: UserSelectedRelays) {
var relayConstraints = tunnelManager.settings.relayConstraints
relayConstraints.exitLocations = .only(relays)
diff --git a/ios/MullvadVPN/Extensions/Image+Assets.swift b/ios/MullvadVPN/Extensions/Image+Assets.swift
index 4986084fae..920c0f7e9f 100644
--- a/ios/MullvadVPN/Extensions/Image+Assets.swift
+++ b/ios/MullvadVPN/Extensions/Image+Assets.swift
@@ -13,4 +13,9 @@ extension Image {
static let mullvadIconFail = Image("IconFail")
static let mullvadIconSearch = Image("IconSearch")
static let mullvadIconCross = Image("IconCross")
+ static let mullvadIconChevron = Image("IconChevron")
+ static let mullvadIconAdd = Image("IconAdd")
+ static let mullvadIconEdit = Image("IconEdit")
+ static let mullvadIconTick = Image("IconTick")
+ static let mullvadRedDot = Image("RedDot")
}
diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconAdd.imageset/Add icon.svg b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconAdd.imageset/Add icon.svg
new file mode 100644
index 0000000000..841bb2b99d
--- /dev/null
+++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconAdd.imageset/Add icon.svg
@@ -0,0 +1,8 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<mask id="mask0_8162_36044" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
+<path d="M24 0H0V24H24V0Z" fill="#D9D9D9"/>
+</mask>
+<g mask="url(#mask0_8162_36044)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M5 12C5 11.4477 5.44772 11 6 11H11V6C11 5.44772 11.4477 5 12 5C12.5523 5 13 5.44772 13 6V11H18C18.5523 11 19 11.4477 19 12C19 12.5523 18.5523 13 18 13H13V18C13 18.5523 12.5523 19 12 19C11.4477 19 11 18.5523 11 18V13H6C5.44772 13 5 12.5523 5 12Z" fill="white"/>
+</g>
+</svg>
diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconAdd.imageset/Contents.json b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconAdd.imageset/Contents.json
new file mode 100644
index 0000000000..ea62443dd7
--- /dev/null
+++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconAdd.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "Add icon.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconEdit.imageset/Contents.json b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconEdit.imageset/Contents.json
new file mode 100644
index 0000000000..70846686f5
--- /dev/null
+++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconEdit.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "EditIcon.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconEdit.imageset/EditIcon.svg b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconEdit.imageset/EditIcon.svg
new file mode 100644
index 0000000000..bd005c5b25
--- /dev/null
+++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconEdit.imageset/EditIcon.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M19.06 3.58988L20.41 4.93988C21.2 5.71988 21.2 6.98988 20.41 7.76988L7.18 20.9999H3V16.8199L13.4 6.40988L16.23 3.58988C17.01 2.80988 18.28 2.80988 19.06 3.58988ZM5 18.9999L6.41 19.0599L16.23 9.22988L14.82 7.81988L5 17.6399V18.9999Z" fill="white"/>
+</svg>
diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/RedDot.imageset/Contents.json b/ios/MullvadVPN/Supporting Files/Assets.xcassets/RedDot.imageset/Contents.json
new file mode 100644
index 0000000000..22a40209cf
--- /dev/null
+++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/RedDot.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "RedDot.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/RedDot.imageset/RedDot.png b/ios/MullvadVPN/Supporting Files/Assets.xcassets/RedDot.imageset/RedDot.png
new file mode 100644
index 0000000000..b85443e5d7
--- /dev/null
+++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/RedDot.imageset/RedDot.png
Binary files differ
diff --git a/ios/MullvadVPN/UI appearance/Color+Mullvad.swift b/ios/MullvadVPN/UI appearance/Color+Mullvad.swift
index a06a00b0a3..32b5d4e64c 100644
--- a/ios/MullvadVPN/UI appearance/Color+Mullvad.swift
+++ b/ios/MullvadVPN/UI appearance/Color+Mullvad.swift
@@ -5,10 +5,11 @@ extension Color {
private static let mullvadSecondaryColor = MullvadDarkBlue.base
private static let mullvadWarningColor = UIColor.warningColor.color
private static let mullvadDangerColor = UIColor.dangerColor.color
- private static let mullvadSuccessColor = UIColor.successColor.color
+ static let mullvadSuccessColor = UIColor.successColor.color
static let mullvadBackground: Color = .mullvadSecondaryColor
static let mullvadTextPrimary: Color = UIColor.primaryTextColor.color
+ static let mullvadTextSecondary: Color = MullvadWhite._60
static let mullvadTextPrimaryDisabled: Color = .mullvadTextPrimary.opacity(
0.2
)
@@ -74,6 +75,13 @@ extension Color {
enum MullvadList {
static let separator: Color = .mullvadSecondaryColor
static let background: Color = .mullvadPrimaryColor
+ enum Item {
+ static let parent: Color = .mullvadPrimaryColor
+ static let child1 = Color.MullvadBlue._60
+ static let child2 = Color.MullvadBlue._40
+ static let child3 = Color.MullvadBlue._20
+ static let child4 = Color.MullvadBlue._10
+ }
}
enum MullvadTextField {
diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift
index 8904090618..6c1f827c6e 100644
--- a/ios/MullvadVPN/UI appearance/UIMetrics.swift
+++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift
@@ -122,13 +122,6 @@ enum UIMetrics {
static let secondaryButton = CGSize(width: 42, height: 42)
}
- enum FilterView {
- static let interChipViewSpacing: CGFloat = 8
- static let chipViewCornerRadius: CGFloat = 8
- static let chipViewLayoutMargins = UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8)
- static let chipViewLabelSpacing: CGFloat = 7
- }
-
enum ConnectionPanelView {
static let inRowHeight: CGFloat = 22
static let outRowHeight: CGFloat = 44
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/ChipCollectionView.swift b/ios/MullvadVPN/View controllers/RelayFilter/ChipCollectionView.swift
deleted file mode 100644
index cf5667c4f3..0000000000
--- a/ios/MullvadVPN/View controllers/RelayFilter/ChipCollectionView.swift
+++ /dev/null
@@ -1,61 +0,0 @@
-//
-// ChipCollectionView.swift
-// MullvadVPN
-//
-// Created by Mojgan on 2024-10-31.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import Foundation
-import UIKit
-
-class ChipCollectionView: UIView {
- private var chips: [ChipConfiguration] = []
- private let cellReuseIdentifier = String(describing: ChipViewCell.self)
-
- private(set) lazy var collectionView: UICollectionView = {
- let collectionView = UICollectionView(frame: .zero, collectionViewLayout: ChipFlowLayout())
- collectionView.contentInset = .zero
- collectionView.backgroundColor = .clear
- collectionView.translatesAutoresizingMaskIntoConstraints = false
- return collectionView
- }()
-
- init() {
- super.init(frame: .zero)
- setupCollectionView()
- }
-
- required init?(coder: NSCoder) {
- super.init(coder: coder)
- setupCollectionView()
- }
-
- private func setupCollectionView() {
- collectionView.dataSource = self
- collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: cellReuseIdentifier)
- addConstrainedSubviews([collectionView]) {
- collectionView.pinEdgesToSuperview()
- }
- }
-
- func setChips(_ values: [ChipConfiguration]) {
- chips = values
- collectionView.reloadData()
- }
-}
-
-extension ChipCollectionView: UICollectionViewDataSource {
- func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
- return chips.count
- }
-
- func collectionView(
- _ collectionView: UICollectionView,
- cellForItemAt indexPath: IndexPath
- ) -> UICollectionViewCell {
- let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellReuseIdentifier, for: indexPath)
- cell.contentConfiguration = chips[indexPath.row]
- return cell
- }
-}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/ChipFlowLayout.swift b/ios/MullvadVPN/View controllers/RelayFilter/ChipFlowLayout.swift
deleted file mode 100644
index 46ac7354a6..0000000000
--- a/ios/MullvadVPN/View controllers/RelayFilter/ChipFlowLayout.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-//
-// ChipFlowLayout.swift
-// MullvadVPN
-//
-// Created by Mojgan on 2024-10-31.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import UIKit
-
-class ChipFlowLayout: UICollectionViewFlowLayout {
- override init() {
- super.init()
- estimatedItemSize = UICollectionViewFlowLayout.automaticSize
- scrollDirection = .vertical
- minimumInteritemSpacing = UIMetrics.FilterView.interChipViewSpacing
- minimumLineSpacing = UIMetrics.FilterView.interChipViewSpacing
- sectionInset = .zero
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
- guard let originalAttributes = super.layoutAttributesForElements(in: rect) else {
- return nil
- }
-
- let attributes = originalAttributes.compactMap { $0.copy() as? UICollectionViewLayoutAttributes }
-
- // Detect RTL
- let languageCode = Locale.current.language.languageCode?.identifier ?? "en"
- let isRTL = Locale.Language(identifier: languageCode).characterDirection == .rightToLeft
-
- var currentLineY: CGFloat = -1
- var currentLineAttributes: [UICollectionViewLayoutAttributes] = []
-
- for attribute in attributes where attribute.representedElementCategory == .cell {
- if abs(attribute.frame.origin.y - currentLineY) > 1 {
- // Align previous line before starting new
- align(attributes: currentLineAttributes, isRTL: isRTL)
- currentLineY = attribute.frame.origin.y
- currentLineAttributes = [attribute]
- } else {
- currentLineAttributes.append(attribute)
- }
- }
-
- // Align last line
- align(attributes: currentLineAttributes, isRTL: isRTL)
-
- return attributes
- }
-
- private func align(attributes: [UICollectionViewLayoutAttributes], isRTL: Bool) {
- guard !attributes.isEmpty else { return }
-
- var currentX = isRTL ? collectionViewContentSize.width - sectionInset.right : sectionInset.left
-
- for attr in isRTL ? attributes.reversed() : attributes {
- var frame = attr.frame
- frame.origin.x = currentX - (isRTL ? frame.width : 0)
- attr.frame = frame
- currentX += (isRTL ? -1 : 1) * (frame.width + minimumInteritemSpacing)
- }
- }
-}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/ChipViewCell.swift b/ios/MullvadVPN/View controllers/RelayFilter/ChipViewCell.swift
deleted file mode 100644
index 0515af7a23..0000000000
--- a/ios/MullvadVPN/View controllers/RelayFilter/ChipViewCell.swift
+++ /dev/null
@@ -1,133 +0,0 @@
-//
-// ChipViewCell.swift
-// MullvadVPN
-//
-// Created by Jon Petersson on 2023-06-20.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import UIKit
-
-class ChipViewCell: UIView, UIContentView {
- var configuration: UIContentConfiguration {
- didSet {
- set(configuration: configuration)
- }
- }
-
- private let container = {
- let container = UIView()
- container.backgroundColor = .primaryColor
- container.layer.cornerRadius = UIMetrics.FilterView.chipViewCornerRadius
- container.layoutMargins = UIMetrics.FilterView.chipViewLayoutMargins
- return container
- }()
-
- private let titleLabel: UILabel = {
- let label = UILabel()
- label.setAccessibilityIdentifier(.relayFilterChipLabel)
- label.adjustsFontForContentSizeCategory = true
- label.translatesAutoresizingMaskIntoConstraints = false
- label.numberOfLines = 1
- label.setContentCompressionResistancePriority(.required, for: .horizontal)
- label.setContentHuggingPriority(.required, for: .horizontal)
- return label
- }()
-
- private let closeButton: IncreasedHitButton = {
- let button = IncreasedHitButton()
- var buttonConfiguration = UIButton.Configuration.plain()
- buttonConfiguration.image = UIImage.Buttons.closeSmall.withTintColor(.white.withAlphaComponent(0.6))
- buttonConfiguration.contentInsets = .zero
- button.setAccessibilityIdentifier(.relayFilterChipCloseButton)
- button.configuration = buttonConfiguration
- return button
- }()
-
- private lazy var closeButtonActionHandler: UIAction = {
- return UIAction { [weak self] action in
- guard let self,
- let chipConfiguration = configuration as? ChipConfiguration,
- let action = chipConfiguration.didTapButton
- else {
- return
- }
- action()
- }
- }()
-
- init(configuration: UIContentConfiguration) {
- self.configuration = configuration
- super.init(frame: .zero)
- addSubviews()
- set(configuration: configuration)
- }
-
- override init(frame: CGRect) {
- self.configuration = ChipConfiguration(group: .filter, title: "", didTapButton: nil)
- super.init(frame: .zero)
- addSubviews()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func addSubviews() {
- self.setAccessibilityIdentifier(.relayFilterChipView)
-
- let stackView = UIStackView(arrangedSubviews: [titleLabel, closeButton])
- stackView.spacing = UIMetrics.FilterView.chipViewLabelSpacing
-
- container.addConstrainedSubviews([stackView]) {
- stackView.pinEdgesToSuperviewMargins()
- }
- addConstrainedSubviews([container]) {
- container.pinEdgesToSuperview()
- }
- }
-
- private func set(configuration: UIContentConfiguration) {
- guard let chipConfiguration = configuration as? ChipConfiguration else { return }
- container.backgroundColor = chipConfiguration.backgroundColor
- titleLabel.text = chipConfiguration.title
- titleLabel.textColor = chipConfiguration.textColor
- titleLabel.font = chipConfiguration.font
- closeButton.isHidden = chipConfiguration.didTapButton == nil
- titleLabel.accessibilityIdentifier = chipConfiguration.accessibilityId?.asString
- if chipConfiguration.didTapButton != nil {
- closeButton.addAction(closeButtonActionHandler, for: .touchUpInside)
- } else {
- closeButton.removeAction(closeButtonActionHandler, for: .touchUpInside)
- }
- }
-}
-
-// Custom content configuration
-struct ChipConfiguration: UIContentConfiguration {
- enum Group: Hashable {
- case filter, settings
- }
-
- var group: Group
- var title: String
- var accessibilityId: AccessibilityIdentifier?
- var textColor: UIColor = .white
- var font = UIFont.preferredFont(forTextStyle: .caption1)
- var backgroundColor: UIColor = .primaryColor
- let didTapButton: (() -> Void)?
-
- func makeContentView() -> UIView & UIContentView {
- return ChipViewCell(configuration: self)
- }
-
- func updated(for state: UIConfigurationState) -> ChipConfiguration {
- return self
- }
-}
-
-extension ChipConfiguration: Equatable {
- static func == (lhs: ChipConfiguration, rhs: ChipConfiguration) -> Bool {
- lhs.title == rhs.title
- }
-}
diff --git a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift b/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift
deleted file mode 100644
index e771ffd3be..0000000000
--- a/ios/MullvadVPN/View controllers/RelayFilter/RelayFilterView.swift
+++ /dev/null
@@ -1,187 +0,0 @@
-//
-// RelayFilterAppliedView.swift
-// MullvadVPN
-//
-// Created by Jon Petersson on 2023-06-19.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import MullvadTypes
-import UIKit
-
-class RelayFilterView: UIView {
- enum Filter {
- case ownership
- case providers
- }
-
- private let titleLabel: UILabel = {
- let label = UILabel()
- label.text = NSLocalizedString("Filtered:", comment: "")
- label.font = UIFont.preferredFont(forTextStyle: .caption1)
- label.adjustsFontForContentSizeCategory = true
- label.textColor = .white
- return label
- }()
-
- private var chips: [ChipConfiguration] = []
- private var chipsView = ChipCollectionView()
- private var collectionViewHeightConstraint: NSLayoutConstraint!
- private var filter: RelayFilter?
- private var contentSizeObservation: NSKeyValueObservation?
-
- var didUpdateFilter: ((RelayFilter) -> Void)?
-
- init() {
- super.init(frame: .zero)
-
- setUpViews()
- }
-
- required init?(coder aDecoder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func setFilter(_ filter: RelayFilter) {
- let filterChips = createFilterChips(for: filter)
- self.filter = filter
- chips.removeAll(where: { $0.group == .filter })
- chips += filterChips
- chipsView.setChips(chips)
- hideIfNeeded()
- }
-
- func setDaita(_ enabled: Bool) {
- let chip = ChipConfiguration(
- group: .settings,
- title: String(format: NSLocalizedString("Setting: %@", comment: ""), "DAITA"),
- accessibilityId: .daitaFilterPill,
- didTapButton: nil
- )
-
- setChip(chip, enabled: enabled)
- }
-
- func setObfuscation(_ enabled: Bool) {
- let chip = ChipConfiguration(
- group: .settings,
- title: String(format: NSLocalizedString("Setting: %@", comment: ""), "Obfuscation"),
- accessibilityId: .obfuscationFilterPill,
- didTapButton: nil
- )
-
- setChip(chip, enabled: enabled)
- }
-
- // MARK: - Private
-
- private func setChip(_ chip: ChipConfiguration, enabled: Bool) {
- if enabled {
- if !chips.contains(chip) {
- chips.insert(chip, at: 0)
- }
- } else {
- chips.removeAll { $0 == chip }
- }
-
- chipsView.setChips(chips)
- }
-
- private func setUpViews() {
- let dummyView = UIView()
- dummyView.layoutMargins = UIMetrics.FilterView.chipViewLayoutMargins
-
- let contentContainer = UIStackView(arrangedSubviews: [dummyView, chipsView])
- contentContainer.distribution = .fill
-
- collectionViewHeightConstraint = chipsView.collectionView.heightAnchor
- .constraint(greaterThanOrEqualToConstant: 8)
- collectionViewHeightConstraint.isActive = true
-
- dummyView.addConstrainedSubviews([titleLabel]) {
- titleLabel.pinEdgesToSuperviewMargins()
- }
-
- addConstrainedSubviews([contentContainer]) {
- contentContainer.pinEdgesToSuperview(PinnableEdges([.top(8), .bottom(8), .leading(4), .trailing(4)]))
- }
-
- // Add KVO for observing collectionView's contentSize changes
- observeContentSize()
- }
-
- private func hideIfNeeded() {
- isHidden = chips.isEmpty
- }
-
- private func createFilterChips(for filter: RelayFilter) -> [ChipConfiguration] {
- var filterChips: [ChipConfiguration] = []
-
- // Ownership Chip
- if let ownershipChip = createOwnershipChip(for: filter.ownership) {
- filterChips.append(ownershipChip)
- }
-
- // Providers Chip
- if let providersChip = createProvidersChip(for: filter.providers) {
- filterChips.append(providersChip)
- }
-
- return filterChips
- }
-
- private func createOwnershipChip(for ownership: RelayFilter.Ownership) -> ChipConfiguration? {
- switch ownership {
- case .any:
- return nil
- case .owned, .rented:
- let title =
- ownership == .owned
- ? RelayFilterDataSourceItem.ownedOwnershipItem.name
- : RelayFilterDataSourceItem.rentedOwnershipItem.name
- return ChipConfiguration(
- group: .filter, title: title,
- didTapButton: { [weak self] in
- guard var filter = self?.filter else { return }
- filter.ownership = .any
- self?.didUpdateFilter?(filter)
- })
- }
- }
-
- private func createProvidersChip(for providers: RelayConstraint<[String]>) -> ChipConfiguration? {
- switch providers {
- case .any:
- return nil
- case let .only(providerList):
- let title = String(
- format: NSLocalizedString("Providers: %d", comment: ""),
- providerList.count
- )
- return ChipConfiguration(
- group: .filter, title: title,
- didTapButton: { [weak self] in
- guard var filter = self?.filter else { return }
- filter.providers = .any
- self?.didUpdateFilter?(filter)
- })
- }
- }
-
- private func observeContentSize() {
- contentSizeObservation = chipsView.collectionView.observe(
- \.contentSize,
- options: [
- .new,
- .old,
- ]
- ) { [weak self] _, change in
- guard let self, let newSize = change.newValue else { return }
- Task { @MainActor in
- let height = newSize.height == .zero ? 8 : newSize.height
- collectionViewHeightConstraint.constant = height > 80 ? 80 : height
- layoutIfNeeded() // Update the layout
- }
- }
- }
-}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/DAITAInfoView.swift b/ios/MullvadVPN/View controllers/SelectLocation/DAITAInfoView.swift
deleted file mode 100644
index be9b6f9765..0000000000
--- a/ios/MullvadVPN/View controllers/SelectLocation/DAITAInfoView.swift
+++ /dev/null
@@ -1,82 +0,0 @@
-//
-// DAITAInfoView.swift
-// MullvadVPN
-//
-// Created by Jon Petersson on 2024-10-10.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import UIKit
-
-class DAITAInfoView: UIView {
- let infoLabel: UILabel = {
- let label = UILabel()
- label.numberOfLines = 0
-
- let infoTextParagraphStyle = NSMutableParagraphStyle()
- infoTextParagraphStyle.lineSpacing = 1.3
- infoTextParagraphStyle.alignment = .center
-
- label.attributedText = NSAttributedString(
- string: NSLocalizedString(
- String(
- format: NSLocalizedString(
- "The entry server for %@ is currently overridden by %@. To select an entry server, "
- + "please first enable “%@” or disable “%@“ in the settings.",
- comment: ""
- ),
- NSLocalizedString("multihop", comment: ""),
- NSLocalizedString("DAITA", comment: ""),
- NSLocalizedString("Direct only", comment: ""),
- NSLocalizedString("DAITA", comment: "")
- ),
- comment: ""
- ),
- attributes: [
- .font: UIFont.mullvadSmall,
- .foregroundColor: UIColor.white,
- .paragraphStyle: infoTextParagraphStyle,
- ]
- )
- label.adjustsFontForContentSizeCategory = true
-
- return label
- }()
-
- let settingsButton: UIButton = {
- let settingsButton = AppButton(style: .default)
-
- settingsButton.setTitle(
- String(format: NSLocalizedString("Open %@ settings", comment: ""), NSLocalizedString("DAITA", comment: "")),
- for: .normal
- )
-
- return settingsButton
- }()
-
- var didPressDaitaSettingsButton: (() -> Void)?
-
- init() {
- super.init(frame: .zero)
-
- backgroundColor = .secondaryColor
- layoutMargins = UIMetrics.contentInsets
-
- settingsButton.addTarget(self, action: #selector(didPressButton), for: .touchUpInside)
-
- addConstrainedSubviews([infoLabel, settingsButton]) {
- infoLabel.pinEdgesToSuperviewMargins(.init([.leading(24), .trailing(24), .top(8)]))
-
- settingsButton.pinEdgesToSuperviewMargins(.init([.leading(0), .trailing(0)]))
- settingsButton.topAnchor.constraint(equalTo: infoLabel.bottomAnchor, constant: 32)
- }
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- @objc private func didPressButton() {
- didPressDaitaSettingsButton?()
- }
-}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/AllLocationDataSource.swift
index bc42270719..333e028027 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/AllLocationDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/AllLocationDataSource.swift
@@ -13,10 +13,6 @@ import MullvadTypes
class AllLocationDataSource: LocationDataSourceProtocol {
private(set) var nodes = [LocationNode]()
- var searchableNodes: [LocationNode] {
- nodes
- }
-
/// 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.
@@ -49,19 +45,6 @@ class AllLocationDataSource: LocationDataSourceProtocol {
nodes = rootNode.children
}
- func node(by location: RelayLocation) -> LocationNode? {
- let rootNode = RootLocationNode(children: nodes)
-
- return switch location {
- case let .country(countryCode):
- rootNode.descendantNodeFor(codes: [countryCode])
- case let .city(countryCode, cityCode):
- rootNode.descendantNodeFor(codes: [countryCode, cityCode])
- case let .hostname(_, _, hostCode):
- rootNode.descendantNodeFor(codes: [hostCode])
- }
- }
-
private func addLocation(
_ location: RelayLocation,
rootNode: LocationNode,
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListLocationNodeBuilder.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/CustomListLocationNodeBuilder.swift
index dddebc6f6a..dddebc6f6a 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/CustomListLocationNodeBuilder.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/CustomListLocationNodeBuilder.swift
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/CustomListsDataSource.swift
index 0848981922..59c8ae21a5 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/CustomListsDataSource.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/CustomListsDataSource.swift
@@ -19,10 +19,6 @@ class CustomListsDataSource: LocationDataSourceProtocol {
self.repository = repository
}
- 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]) {
@@ -44,29 +40,4 @@ class CustomListsDataSource: LocationDataSourceProtocol {
return listNode
}
}
-
- func node(by relays: UserSelectedRelays, for customList: CustomList) -> LocationNode? {
- guard let listNode = nodes.first(where: { $0.name == customList.name }) else { return nil }
-
- if relays.customListSelection?.isList == true {
- return listNode
- } else {
- // Each search for descendant nodes needs the parent custom list node code to be
- // prefixed in order to get a match. See comment in reload() above.
- return switch relays.locations.first {
- case let .country(countryCode):
- listNode.descendantNodeFor(codes: [listNode.code, countryCode])
- case let .city(countryCode, cityCode):
- listNode.descendantNodeFor(codes: [listNode.code, countryCode, cityCode])
- case let .hostname(_, _, hostCode):
- listNode.descendantNodeFor(codes: [listNode.code, hostCode])
- case .none:
- nil
- }
- }
- }
-
- func customList(by id: UUID) -> CustomList? {
- repository.fetch(by: id)
- }
}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift
new file mode 100644
index 0000000000..691d1a8af2
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDataSourceProtocol.swift
@@ -0,0 +1,136 @@
+//
+// LocationDataSourceProtocol.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2024-02-07.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadREST
+import MullvadTypes
+
+protocol LocationDataSourceProtocol {
+ var nodes: [LocationNode] { get }
+}
+
+extension LocationDataSourceProtocol {
+
+ func setConnectedRelay(hostname: String?) {
+ nodes.forEachNode { node in
+ node.isConnected = node.name == hostname
+ }
+ }
+
+ /// Excludeds nodes from being selectable. A node gets excluded if the selection only allows for one possible relay.
+ /// This is used in multihop to make sure that the during relay selection entry and exit can different.
+ /// It prevent the user from making a selection that would lead to the blocked state.
+ /// - Parameters:
+ /// - excludedSelection: The selection that should be checked for exclusion.
+ func setExcludedNode(excludedSelection: UserSelectedRelays?) {
+ nodes.forEachNode { node in
+ node.isExcluded = false
+ }
+ guard let selectedRelayLocations = excludedSelection?.locations,
+ selectedRelayLocations.count == 1,
+ let selectedRelayLocation = selectedRelayLocations.first
+ else {
+ return
+ }
+ nodes.forEachNode { node in
+ let locations = Set((node.flattened + [node]).flatMap { $0.locations })
+ if locations
+ .contains(selectedRelayLocation) && node.activeRelayNodes.count == 1
+ {
+ node.isExcluded = true
+ node.forEachDescendant { child in
+ child.isExcluded = true
+ }
+ }
+ }
+ }
+
+ func setSelectedNode(selectedRelays: UserSelectedRelays?) {
+ nodes.forEachNode { node in
+ node.isSelected = false
+ }
+ guard let selectedRelays else { return }
+ let selectedNode = node(by: selectedRelays)
+ selectedNode?.isSelected = true
+ }
+
+ func expandSelection() {
+ nodes.forEachNode { node in
+ if node.isSelected {
+ node.forEachAncestor { $0.showsChildren = true }
+ }
+ }
+ }
+
+ func search(by text: String) {
+ nodes.forEachNode { node in
+ node.isHiddenFromSearch = false
+ node.showsChildren = false
+ }
+ guard !text.isEmpty else {
+ return
+ }
+ nodes.forEach { node in
+ _ = hideInSearch(
+ node: node,
+ searchText: text
+ )
+ }
+ }
+
+ private func hideInSearch(node: LocationNode, searchText: String) -> Bool {
+ let matchesSelf = node.name.fuzzyMatch(searchText)
+ var childMatches = false
+ for child in node.children where !hideInSearch(node: child, searchText: searchText) {
+ childMatches = true
+ }
+ if matchesSelf && !childMatches {
+ node.forEachDescendant { child in
+ child.isHiddenFromSearch = false
+ child.showsChildren = false
+ }
+ }
+ node.isHiddenFromSearch = !matchesSelf && !childMatches
+ node.showsChildren = childMatches
+ return node.isHiddenFromSearch
+ }
+
+ func node(by selectedRelays: UserSelectedRelays) -> LocationNode? {
+ let rootNode = RootLocationNode(children: nodes)
+
+ guard let location = selectedRelays.locations.first else {
+ return nil
+ }
+ let descendantNodeFor: ([String]) -> LocationNode? = { codes in
+ switch location {
+ case let .country(countryCode):
+ rootNode.descendantNodeFor(codes: codes + [countryCode])
+ case let .city(countryCode, cityCode):
+ rootNode.descendantNodeFor(codes: codes + [countryCode, cityCode])
+ case let .hostname(_, _, hostCode):
+ rootNode.descendantNodeFor(codes: codes + [hostCode])
+ }
+ }
+
+ if let customListSelection = selectedRelays.customListSelection {
+ let selectedCustomListNode = nodes.first(where: {
+ $0.asCustomListNode?.customList.id == customListSelection.listId
+ })
+
+ guard let selectedCustomListNode else { return nil }
+
+ if customListSelection.isList {
+ return selectedCustomListNode
+ }
+
+ return descendantNodeFor([selectedCustomListNode.code])
+ } else {
+ return descendantNodeFor([])
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDiffableDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDiffableDataSourceProtocol.swift
index 3db271723b..3db271723b 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDiffableDataSourceProtocol.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationDiffableDataSourceProtocol.swift
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift
index 53c5f0dfd6..59db93fa9c 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationNode.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift
@@ -18,6 +18,9 @@ class LocationNode: @unchecked Sendable {
var children: [LocationNode]
var showsChildren: Bool
var isHiddenFromSearch: Bool
+ var isConnected: Bool
+ var isSelected: Bool
+ var isExcluded: Bool
init(
name: String,
@@ -27,7 +30,10 @@ class LocationNode: @unchecked Sendable {
parent: LocationNode? = nil,
children: [LocationNode] = [],
showsChildren: Bool = false,
- isHiddenFromSearch: Bool = false
+ isHiddenFromSearch: Bool = false,
+ isConnected: Bool = false,
+ isSelected: Bool = false,
+ isExcluded: Bool = false
) {
self.name = name
self.code = code
@@ -37,6 +43,9 @@ class LocationNode: @unchecked Sendable {
self.children = children
self.showsChildren = showsChildren
self.isHiddenFromSearch = isHiddenFromSearch
+ self.isConnected = isConnected
+ self.isSelected = isSelected
+ self.isExcluded = isExcluded
}
}
@@ -45,10 +54,23 @@ extension LocationNode {
parent?.root ?? self
}
- var hierarchyLevel: Int {
- var level = 0
- forEachAncestor { _ in level += 1 }
- return level
+ var asCustomListNode: CustomListLocationNode? {
+ self as? CustomListLocationNode
+ }
+
+ var userSelectedRelays: UserSelectedRelays {
+ var customListSelection: UserSelectedRelays.CustomListSelection?
+ if let topmostNode = root.asCustomListNode {
+ customListSelection = UserSelectedRelays.CustomListSelection(
+ listId: topmostNode.customList.id,
+ isList: topmostNode == self
+ )
+ }
+
+ return UserSelectedRelays(
+ locations: locations,
+ customListSelection: customListSelection
+ )
}
func countryFor(code: String) -> LocationNode? {
@@ -90,6 +112,19 @@ extension LocationNode {
var flattened: [LocationNode] {
children + children.flatMap { $0.flattened }
}
+
+ var activeRelayNodes: [LocationNode] {
+ ([self] + flattened).filter { !($0 is CustomListLocationNode) }
+ .filter(\.self.isActive)
+ .filter {
+ switch $0.locations.first {
+ case .hostname:
+ return true
+ default:
+ return false
+ }
+ }
+ }
}
extension LocationNode {
@@ -104,7 +139,10 @@ extension LocationNode {
parent: parent,
children: [],
showsChildren: showsChildren,
- isHiddenFromSearch: isHiddenFromSearch
+ isHiddenFromSearch: isHiddenFromSearch,
+ isConnected: isConnected,
+ isSelected: false, // explicity set to false since it's a different node
+ isExcluded: isExcluded
)
node.children = recursivelyCopyChildren(withParent: node)
@@ -133,6 +171,15 @@ extension LocationNode: Comparable {
}
}
+extension Array where Element == LocationNode {
+ func forEachNode(_ body: (LocationNode) -> Void) {
+ for element in self {
+ body(element)
+ element.children.forEachNode(body)
+ }
+ }
+}
+
/// Proxy class for building and/or searching node trees.
class RootLocationNode: LocationNode, @unchecked Sendable {
init(name: String = "", code: String = "", children: [LocationNode] = []) {
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationRelays.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationRelays.swift
index 804cf0ab95..804cf0ab95 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationRelays.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationRelays.swift
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/RelaySelection.swift
index 5ec18d4c4b..5ec18d4c4b 100644
--- a/ios/MullvadVPN/View controllers/SelectLocation/RelaySelection.swift
+++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/RelaySelection.swift
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift
new file mode 100644
index 0000000000..f3193cbe1c
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationContext.swift
@@ -0,0 +1,20 @@
+struct LocationContext {
+ var locations: [LocationNode]
+ var customLists: [LocationNode]
+ var filter: [SelectLocationFilter]
+ let selectLocation: (LocationNode) -> Void
+
+ init(
+ locations: [LocationNode] = [],
+ customLists: [LocationNode] = [],
+ filter: [SelectLocationFilter] = [],
+ selectedLocation: LocationNode? = nil,
+ connectedRelayHostname: String? = nil,
+ selectLocation: @escaping (LocationNode) -> Void = { _ in }
+ ) {
+ self.locations = locations
+ self.customLists = customLists
+ self.filter = filter
+ self.selectLocation = selectLocation
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
deleted file mode 100644
index 32f2e7f0be..0000000000
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSource.swift
+++ /dev/null
@@ -1,398 +0,0 @@
-//
-// LocationDataSource.swift
-// MullvadVPN
-//
-// Created by pronebird on 11/03/2021.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import Combine
-import MullvadREST
-import MullvadSettings
-import MullvadTypes
-import UIKit
-
-final class LocationDataSource:
- UITableViewDiffableDataSource<LocationSection, LocationCellViewModel>,
- LocationDiffableDataSourceProtocol
-{
- nonisolated(unsafe) private var currentSearchString = ""
- nonisolated(unsafe) private var dataSources: [LocationDataSourceProtocol] = []
- // The selected location.
- nonisolated(unsafe) private var selectedLocation: LocationCellViewModel?
- // When multihop is enabled, this is the "inverted" selected location, ie. entry
- // if in exit mode and exit if in entry mode.
- nonisolated(unsafe) private var excludedLocation: LocationCellViewModel?
- let tableView: UITableView
- let sections: [LocationSection]
-
- var didSelectRelayLocations: (@Sendable (UserSelectedRelays) -> Void)?
- var didTapEditCustomLists: (@Sendable () -> Void)?
-
- init(
- tableView: UITableView,
- allLocations: LocationDataSourceProtocol,
- customLists: LocationDataSourceProtocol
- ) {
- self.tableView = tableView
-
- let sections: [LocationSection] = LocationSection.allCases
- self.sections = sections
-
- self.dataSources.append(contentsOf: [customLists, allLocations])
-
- super.init(tableView: tableView) { _, indexPath, itemIdentifier in
- let cell =
- tableView.dequeueReusableView(
- withIdentifier: sections[indexPath.section],
- for: indexPath
- ) as! LocationCell
- cell.configure(item: itemIdentifier, behavior: .select)
- return cell
- }
-
- tableView.delegate = self
- tableView.registerReusableViews(from: LocationSection.self)
- defaultRowAnimation = .fade
- }
-
- func setRelays(_ relaysWithLocation: LocationRelays, selectedRelays: RelaySelection) {
- Task { @MainActor in
- guard
- let allLocationsDataSource =
- dataSources
- .first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource,
- let customListsDataSource =
- dataSources
- .first(where: { $0 is CustomListsDataSource }) as? CustomListsDataSource
- else { return }
- allLocationsDataSource.reload(relaysWithLocation)
- customListsDataSource.reload(allLocationNodes: allLocationsDataSource.nodes)
- Task { @MainActor in
- setSelectedRelays(selectedRelays)
- filterRelays(by: currentSearchString)
- }
- }
- }
-
- func filterRelays(by searchString: String) {
- Task { @MainActor in
- currentSearchString = searchString
-
- let list = sections.enumerated().map { index, section in
- self.dataSources[index]
- .search(by: searchString)
- .flatMap { node in
- let rootNode = RootLocationNode(children: [node])
- return self.recursivelyCreateCellViewModelTree(
- for: rootNode,
- in: section,
- indentationLevel: 0
- )
- }
- }
-
- DispatchQueue.main.async {
- self.reloadDataSnapshot(with: list) {
- if searchString.isEmpty, let selectedLocation = self.selectedLocation {
- self.updateSelection(
- selectedLocation: selectedLocation,
- completion: {
- self.scrollToSelectedRelay()
- })
- } else {
- self.scrollToTop(animated: false)
- }
- }
- }
- }
- }
-
- /// Refreshes the custom list section and keeps all modifications intact (selection and expanded states).
- func refreshCustomLists() {
- Task { @MainActor in
- guard
- let allLocationsDataSource =
- dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource,
- let customListsDataSource =
- dataSources.first(where: { $0 is CustomListsDataSource }) as? CustomListsDataSource
- else {
- return
- }
-
- // Reload data source with (possibly) updated custom lists.
- customListsDataSource.reload(allLocationNodes: allLocationsDataSource.nodes)
- self.filterRelays(by: currentSearchString)
- }
- }
-
- func setSelectedRelays(_ selectedRelays: RelaySelection) {
- Task { @MainActor in
- guard let _selectedLocation = mapSelection(from: selectedRelays.selected) else { return }
- selectedLocation = _selectedLocation
- excludedLocation = mapSelection(from: selectedRelays.excluded)
- excludedLocation?.excludedRelayTitle = selectedRelays.excludedTitle
- self.updateSelection(
- selectedLocation: _selectedLocation,
- completion: {
- self.scrollToSelectedRelay()
- })
- }
- }
-
- // MARK: - Private functions
-
- private func scrollToSelectedRelay() {
- indexPathForSelectedRelay()
- .flatMap {
- tableView.scrollToRow(at: $0, at: .middle, animated: false)
- }
- }
-
- private func indexPathForSelectedRelay() -> IndexPath? {
- selectedLocation.flatMap { indexPath(for: $0) }
- }
-
- private func mapSelection(from selectedRelays: UserSelectedRelays?) -> LocationCellViewModel? {
- let allLocationsDataSource =
- dataSources.first(where: { $0 is AllLocationDataSource }) as? AllLocationDataSource
-
- let customListsDataSource =
- dataSources.first(where: { $0 is CustomListsDataSource }) as? CustomListsDataSource
-
- if let selectedRelays {
- // Look for a matching custom list node.
- if let customListSelection = selectedRelays.customListSelection,
- let customList = customListsDataSource?.customList(by: customListSelection.listId),
- let selectedNode = customListsDataSource?.node(by: selectedRelays, for: customList)
- {
- return LocationCellViewModel(
- section: .customLists,
- node: selectedNode,
- indentationLevel: selectedNode.hierarchyLevel
- )
- // Look for a matching all locations node.
- } else if let location = selectedRelays.locations.first,
- let selectedNode = allLocationsDataSource?.node(by: location)
- {
- return LocationCellViewModel(
- section: .allLocations,
- node: selectedNode,
- indentationLevel: selectedNode.hierarchyLevel
- )
- }
- }
-
- return nil
- }
-
- private func updateSelection(selectedLocation: LocationCellViewModel, completion: (() -> Void)? = nil) {
- let rootNode = selectedLocation.node.root
- var snapshot = snapshot()
-
- // Exit early if no changes to the node tree should be made.
- guard selectedLocation.node != rootNode else {
- // Apply the updated snapshot
- DispatchQueue.main.async {
- self.applySnapshotUsingReloadData(snapshot, completion: completion)
- }
- return
- }
-
- // Make sure we have an index path for the selected item.
- guard
- let indexPath = indexPath(
- for: LocationCellViewModel(
- section: selectedLocation.section,
- node: rootNode
- ))
- else { return }
-
- // Walk tree backwards to determine which nodes should be expanded.
- selectedLocation.node.forEachAncestor { node in
- node.showsChildren = true
- }
-
- // Construct node tree.
- let nodesToAdd = recursivelyCreateCellViewModelTree(
- for: rootNode,
- in: selectedLocation.section,
- indentationLevel: 1
- )
-
- let existingItems = snapshot.itemIdentifiers(inSection: selectedLocation.section)
- snapshot.deleteItems(nodesToAdd)
- snapshot.insertItems(nodesToAdd, afterItem: existingItems[indexPath.row])
-
- // Apply the updated snapshot
- DispatchQueue.main.async {
- self.applySnapshotUsingReloadData(snapshot, completion: completion)
- }
- }
-
- override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let cell = super.tableView(tableView, cellForRowAt: indexPath)
- guard let cell = cell as? LocationCell, let item = itemIdentifier(for: indexPath) else {
- return cell
- }
-
- cell.delegate = self
-
- if item.shouldExcludeLocation(excludedLocation) {
- // Only host locations should have an excluded title. Since custom list nodes contain
- // all locations of all child nodes, its first location could possibly be a host.
- // Therefore we need to check for that as well.
- if case .hostname = item.node.locations.first, !(item.node is CustomListLocationNode) {
- cell.setExcluded(relayTitle: excludedLocation?.excludedRelayTitle)
- } else {
- cell.setExcluded()
- }
- }
-
- return cell
- }
-}
-
-// MARK: - Called from LocationDiffableDataSourceProtocol
-
-extension LocationDataSource {
- func nodeShowsChildren(_ node: LocationNode) -> Bool {
- node.showsChildren
- }
-
- func nodeShouldBeSelected(_ node: LocationNode) -> Bool {
- false // N/A
- }
-}
-
-extension LocationDataSource: UITableViewDelegate {
- func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
- guard
- let headerView =
- tableView
- .dequeueReusableHeaderFooterView(
- withIdentifier: LocationSectionHeaderFooterView
- .reuseIdentifier
- ) as? LocationSectionHeaderFooterView
- else { return nil }
-
- switch sections[section] {
- case .allLocations:
- headerView.configure(
- configuration: LocationSectionHeaderFooterView.Configuration(
- name: LocationSection.allLocations.header,
- style: .header
- ))
- case .customLists:
- headerView.configure(
- configuration: LocationSectionHeaderFooterView.Configuration(
- name: LocationSection.customLists.header,
- style: .header,
- primaryAction: UIAction { [weak self] _ in
- self?.didTapEditCustomLists?()
- }
- ))
- }
-
- return headerView
- }
-
- func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
- guard
- let footerView =
- tableView
- .dequeueReusableHeaderFooterView(
- withIdentifier: LocationSectionHeaderFooterView
- .reuseIdentifier
- ) as? LocationSectionHeaderFooterView
- else { return nil }
-
- switch sections[section] {
- case .allLocations:
- guard dataSources[section].nodes.isEmpty else {
- return nil
- }
- footerView.configure(
- configuration: LocationSectionHeaderFooterView.Configuration(
- name: LocationSection.allLocations.footer,
- style: .footer
- ))
- case .customLists:
- guard dataSources[section].nodes.isEmpty else {
- return nil
- }
- footerView.configure(
- configuration: LocationSectionHeaderFooterView.Configuration(
- name: LocationSection.customLists.footer,
- style: .footer,
- directionalEdgeInsets: NSDirectionalEdgeInsets(top: 11, leading: 16, bottom: 24, trailing: 8)
- ))
- }
-
- return footerView
- }
-
- func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
- guard let item = itemIdentifier(for: indexPath) else { return false }
- return !item.shouldExcludeLocation(excludedLocation) && item.node.isActive
- }
-
- func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int {
- itemIdentifier(for: indexPath)?.indentationLevel ?? 0
- }
-
- func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
- if let item = itemIdentifier(for: indexPath) {
- cell.setSelected(item == selectedLocation, animated: false)
- }
- }
-
- func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
- if let indexPath = indexPathForSelectedRelay() {
- tableView.deselectRow(at: indexPath, animated: false)
- }
- return indexPath
- }
-
- func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- guard let cell = tableView.cellForRow(at: indexPath) as? LocationCell else {
- return
- }
- toggleSelecting(cell: cell)
- }
-
- private func scrollToTop(animated: Bool) {
- tableView.setContentOffset(.zero, animated: animated)
- }
-}
-
-extension LocationDataSource: @preconcurrency LocationCellDelegate {
- func toggleExpanding(cell: LocationCell) {
- guard let indexPath = tableView.indexPath(for: cell),
- let item = itemIdentifier(for: indexPath)
- else { return }
- toggleItems(for: cell) {
- self.scroll(to: item, animated: true)
- }
- }
-
- func toggleSelecting(cell: LocationCell) {
- guard let indexPath = tableView.indexPath(for: cell),
- let item = itemIdentifier(for: indexPath)
- else { return }
- selectedLocation = item
- var customListSelection: UserSelectedRelays.CustomListSelection?
- if let topmostNode = item.node.root as? CustomListLocationNode {
- customListSelection = UserSelectedRelays.CustomListSelection(
- listId: topmostNode.customList.id,
- isList: topmostNode == item.node
- )
- }
-
- let relayLocations = UserSelectedRelays(
- locations: item.node.locations,
- customListSelection: customListSelection
- )
- didSelectRelayLocations?(relayLocations)
- }
-}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift
deleted file mode 100644
index 8227cae41e..0000000000
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationDataSourceProtocol.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-//
-// LocationDataSourceProtocol.swift
-// MullvadVPN
-//
-// Created by Mojgan on 2024-02-07.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import Foundation
-import MullvadREST
-import MullvadTypes
-
-protocol LocationDataSourceProtocol {
- var nodes: [LocationNode] { get }
- var searchableNodes: [LocationNode] { get }
-}
-
-extension LocationDataSourceProtocol {
- func search(by text: String) -> [LocationNode] {
- guard !text.isEmpty else {
- return nodes
- }
-
- var filteredNodes: [LocationNode] = []
-
- searchableNodes.forEach { node in
- // Use a copy of the node to preserve the expanded state,
- // allowing us to restore the previous view state after a search.
- let countryNode = node.copy()
-
- countryNode.showsChildren = false
-
- if countryNode.name.fuzzyMatch(text) {
- filteredNodes.append(countryNode)
- }
-
- countryNode.children.forEach { cityNode in
- cityNode.showsChildren = false
- cityNode.isHiddenFromSearch = true
-
- 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
- }
-}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift
deleted file mode 100644
index 11a858a109..0000000000
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationSectionHeaderFooterView.swift
+++ /dev/null
@@ -1,95 +0,0 @@
-//
-// LocationSectionHeaderFooterView.swift
-// MullvadVPN
-//
-// Created by Mojgan on 2024-01-25.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import Foundation
-import UIKit
-
-class LocationSectionHeaderFooterView: UITableViewHeaderFooterView {
- static let reuseIdentifier = "LocationSectionHeaderFooterView"
-
- private let label = UILabel()
- private let button = UIButton(type: .system)
-
- override init(reuseIdentifier: String?) {
- super.init(reuseIdentifier: reuseIdentifier)
-
- // Configure button
- button.setImage(UIImage(systemName: "ellipsis"), for: .normal)
- button.tintColor = UIColor(white: 1, alpha: 0.6)
- button.accessibilityIdentifier = AccessibilityIdentifier.openCustomListsMenuButton.asString
-
- contentView.addConstrainedSubviews([label, button]) {
- label.pinEdgesToSuperviewMargins(.all().excluding(.trailing))
- button.pinEdgesToSuperviewMargins(.all().excluding(.leading))
- button.leadingAnchor.constraint(greaterThanOrEqualTo: label.trailingAnchor, constant: 8)
- }
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func configure(configuration: Configuration) {
- var contentConfig = UIListContentConfiguration.groupedHeader()
- contentConfig.text = configuration.name
- contentConfig.textProperties.alignment = configuration.style.textAlignment
- contentConfig.textProperties.color = configuration.style.textColor
- contentConfig.textProperties.font = configuration.style.font
- contentConfig.textProperties.adjustsFontForContentSizeCategory = true
-
- contentView.backgroundColor = configuration.style.backgroundColor
- directionalLayoutMargins = configuration.directionalEdgeInsets
-
- // Apply the font and color directly to the label:
- label.text = configuration.name
- label.font = contentConfig.textProperties.font
- label.textColor = contentConfig.textProperties.color
- label.adjustsFontForContentSizeCategory = true
- label.numberOfLines = 0
- label.lineBreakMode = .byWordWrapping
- label.setContentCompressionResistancePriority(.required, for: .horizontal)
-
- if let buttonAction = configuration.primaryAction {
- button.isHidden = false
- button.removeTarget(nil, action: nil, for: .allEvents)
- button.addAction(buttonAction, for: .touchUpInside)
- } else {
- button.isHidden = true
- }
- }
-}
-
-extension LocationSectionHeaderFooterView {
- struct Style: Equatable, @unchecked Sendable {
- let font: UIFont
- let textColor: UIColor
- let textAlignment: UIListContentConfiguration.TextProperties.TextAlignment
- let backgroundColor: UIColor
-
- static let header = Style(
- font: .mullvadSmallSemiBold,
- textColor: .primaryTextColor,
- textAlignment: .natural,
- backgroundColor: .primaryColor
- )
-
- static let footer = Style(
- font: .mullvadTiny,
- textColor: .secondaryTextColor,
- textAlignment: .center,
- backgroundColor: .clear
- )
- }
-
- struct Configuration {
- let name: String
- let style: Style
- var directionalEdgeInsets = NSDirectionalEdgeInsets(top: 11, leading: 16, bottom: 11, trailing: 8)
- var primaryAction: UIAction?
- }
-}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
deleted file mode 100644
index 072375bae2..0000000000
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewController.swift
+++ /dev/null
@@ -1,228 +0,0 @@
-//
-// LocationViewController.swift
-// MullvadVPN
-//
-// Created by pronebird on 02/05/2019.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import MullvadREST
-import MullvadSettings
-import MullvadTypes
-import UIKit
-
-protocol LocationViewControllerDelegate: AnyObject {
- func navigateToCustomLists(nodes: [LocationNode])
- func navigateToDaitaSettings()
- func didSelectRelays(relays: UserSelectedRelays)
- func didUpdateFilter(filter: RelayFilter)
-}
-
-final class LocationViewController: UIViewController {
- private let searchBar = UISearchBar()
- private let tableView = UITableView(frame: .zero, style: .grouped)
- private let topContentView = UIStackView()
- private let filterView = RelayFilterView()
- private var daitaInfoView: UIView?
- private var dataSource: LocationDataSource?
- private var relaysWithLocation: LocationRelays?
- private var filter = RelayFilter()
- private var selectedRelays: RelaySelection
- private var shouldFilterDaita: Bool
- private var shouldFilterObfuscation: Bool
- weak var delegate: LocationViewControllerDelegate?
- var customListRepository: CustomListRepositoryProtocol
-
- override var preferredStatusBarStyle: UIStatusBarStyle {
- .lightContent
- }
-
- init(
- customListRepository: CustomListRepositoryProtocol,
- selectedRelays: RelaySelection,
- shouldFilterDaita: Bool,
- shouldFilterObfuscation: Bool
- ) {
- self.customListRepository = customListRepository
- self.selectedRelays = selectedRelays
- self.shouldFilterDaita = shouldFilterDaita
- self.shouldFilterObfuscation = shouldFilterObfuscation
-
- super.init(nibName: nil, bundle: nil)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- // MARK: - View lifecycle
-
- override func viewDidLoad() {
- super.viewDidLoad()
-
- view.setAccessibilityIdentifier(.selectLocationView)
- view.backgroundColor = .secondaryColor
-
- setUpDataSource()
- setUpTableView()
- setUpTopContent()
-
- view.addConstrainedSubviews([topContentView, tableView]) {
- topContentView.pinEdgesToSuperviewMargins(.all().excluding(.bottom))
-
- tableView.pinEdgesToSuperview(.all().excluding(.top))
- tableView.topAnchor.constraint(equalTo: topContentView.bottomAnchor, constant: 8)
- }
- }
-
- override func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- tableView.flashScrollIndicators()
- }
-
- // MARK: - Public
-
- func setRelaysWithLocation(_ relaysWithLocation: LocationRelays, filter: RelayFilter) {
- self.relaysWithLocation = relaysWithLocation
- self.filter = filter
- filterView.setFilter(filter)
- dataSource?.setRelays(relaysWithLocation, selectedRelays: selectedRelays)
- }
-
- func setDaitaChip(_ isEnabled: Bool) {
- self.shouldFilterDaita = isEnabled
- filterView.setDaita(isEnabled)
- }
-
- func setObfuscationChip(_ isEnabled: Bool) {
- self.shouldFilterObfuscation = isEnabled
- filterView.setObfuscation(isEnabled)
- }
-
- func refreshCustomLists() {
- dataSource?.refreshCustomLists()
- }
-
- func setSelectedRelays(_ selectedRelays: RelaySelection) {
- self.selectedRelays = selectedRelays
- dataSource?.setSelectedRelays(selectedRelays)
- }
-
- func toggleDaitaAutomaticRouting(isEnabled: Bool) {
- guard isEnabled else {
- daitaInfoView?.removeFromSuperview()
- daitaInfoView = nil
-
- searchBar.searchTextField.isEnabled = true
- UITextField.SearchTextFieldAppearance.inactive.apply(to: searchBar)
- return
- }
-
- guard daitaInfoView == nil else { return }
-
- let daitaInfoView = DAITAInfoView()
- self.daitaInfoView = daitaInfoView
-
- daitaInfoView.didPressDaitaSettingsButton = { [weak self] in
- self?.delegate?.navigateToDaitaSettings()
- }
-
- view.addConstrainedSubviews([daitaInfoView]) {
- daitaInfoView.pinEdgesToSuperview(.all().excluding(.top))
- daitaInfoView.topAnchor.constraint(equalTo: tableView.topAnchor)
- }
-
- searchBar.searchTextField.isEnabled = false
- }
-
- // MARK: - Private
-
- private func setUpDataSource() {
- dataSource = LocationDataSource(
- tableView: tableView,
- allLocations: AllLocationDataSource(),
- customLists: CustomListsDataSource(repository: customListRepository)
- )
-
- dataSource?.didSelectRelayLocations = { [weak self] relays in
- Task { @MainActor in
- self?.delegate?.didSelectRelays(relays: relays)
- }
- }
-
- dataSource?.didTapEditCustomLists = { [weak self] in
- guard let self else { return }
-
- Task { @MainActor in
- if let relaysWithLocation {
- let allLocationDataSource = AllLocationDataSource()
- allLocationDataSource.reload(relaysWithLocation)
- delegate?.navigateToCustomLists(nodes: allLocationDataSource.nodes)
- }
- }
- }
-
- if let relaysWithLocation {
- dataSource?.setRelays(relaysWithLocation, selectedRelays: selectedRelays)
- }
- }
-
- private func setUpTableView() {
- tableView.backgroundColor = view.backgroundColor
- tableView.separatorColor = .secondaryColor
- tableView.separatorInset = .zero
- tableView.estimatedRowHeight = UIMetrics.TableView.rowHeight
- tableView.rowHeight = UITableView.automaticDimension
- tableView.estimatedSectionHeaderHeight = UIMetrics.TableView.rowHeight
- tableView.sectionHeaderHeight = UITableView.automaticDimension
- tableView.estimatedSectionFooterHeight = UIMetrics.TableView.rowHeight
- tableView.sectionFooterHeight = UITableView.automaticDimension
- tableView.indicatorStyle = .white
- tableView.keyboardDismissMode = .onDrag
- tableView.setAccessibilityIdentifier(.selectLocationTableView)
-
- tableView.register(
- LocationSectionHeaderFooterView.self,
- forHeaderFooterViewReuseIdentifier: LocationSectionHeaderFooterView.reuseIdentifier
- )
- }
-
- private func setUpTopContent() {
- topContentView.axis = .vertical
- topContentView.addArrangedSubview(filterView)
- topContentView.addArrangedSubview(searchBar)
- filterView.setDaita(shouldFilterDaita)
- filterView.setObfuscation(shouldFilterObfuscation)
-
- filterView.didUpdateFilter = { [weak self] in
- self?.delegate?.didUpdateFilter(filter: $0)
- }
-
- setUpSearchBar()
- }
-
- private func setUpSearchBar() {
- searchBar.delegate = self
- searchBar.searchBarStyle = .minimal
- searchBar.layer.cornerRadius = 8
- searchBar.clipsToBounds = true
- searchBar.placeholder = NSLocalizedString("Search for...", comment: "")
- searchBar.searchTextField.setAccessibilityIdentifier(.selectLocationSearchTextField)
-
- UITextField.SearchTextFieldAppearance.inactive.apply(to: searchBar)
- }
-}
-
-extension LocationViewController: UISearchBarDelegate {
- func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
- dataSource?.filterRelays(by: searchText)
- }
-
- func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
- UITextField.SearchTextFieldAppearance.active.apply(to: searchBar)
- }
-
- func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
- UITextField.SearchTextFieldAppearance.inactive.apply(to: searchBar)
- }
-}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
deleted file mode 100644
index 3bbf066830..0000000000
--- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift
+++ /dev/null
@@ -1,304 +0,0 @@
-//
-// LocationViewControllerWrapper.swift
-// MullvadVPN
-//
-// Created by Jon Petersson on 2024-04-23.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import MullvadREST
-import MullvadSettings
-import MullvadTypes
-import SwiftUI
-import UIKit
-
-protocol LocationViewControllerWrapperDelegate: AnyObject {
- func navigateToCustomLists(nodes: [LocationNode])
- func navigateToFilter()
- func navigateToDaitaSettings()
- func didSelectEntryRelays(_ relays: UserSelectedRelays)
- func didSelectExitRelays(_ relays: UserSelectedRelays)
- func didUpdateFilter(_ filter: RelayFilter)
-}
-
-final class LocationViewControllerWrapper: UIViewController {
- enum MultihopContext: Int, CaseIterable, CustomStringConvertible {
- case entry, exit
-
- var description: String {
- switch self {
- case .entry:
- NSLocalizedString("Entry", comment: "")
- case .exit:
- NSLocalizedString("Exit", comment: "")
- }
- }
- }
-
- private var entryLocationViewController: LocationViewController?
- private let exitLocationViewController: LocationViewController
- private var segmentedControlView: UIView!
- private let locationViewContainer = UIView()
- private var segmentedViewModel = SegmentedControlViewModel()
- private var settings: LatestTunnelSettings
- private var relaySelectorWrapper: RelaySelectorWrapper
-
- private var multihopContext: MultihopContext = .exit
- private var selectedEntry: UserSelectedRelays?
- private var selectedExit: UserSelectedRelays?
-
- weak var delegate: LocationViewControllerWrapperDelegate?
-
- var onNewSettings: ((LatestTunnelSettings) -> Void)?
-
- private var relayFilter: RelayFilter {
- settings.relayConstraints.filter.value ?? RelayFilter()
- }
-
- init(
- settings: LatestTunnelSettings,
- relaySelectorWrapper: RelaySelectorWrapper,
- customListRepository: CustomListRepositoryProtocol,
- startContext: MultihopContext
- ) {
- self.selectedEntry = settings.relayConstraints.entryLocations.value
- self.selectedExit = settings.relayConstraints.exitLocations.value
- self.settings = settings
- self.relaySelectorWrapper = relaySelectorWrapper
- self.multihopContext = startContext
-
- entryLocationViewController = LocationViewController(
- customListRepository: customListRepository,
- selectedRelays: RelaySelection(),
- shouldFilterDaita: false,
- shouldFilterObfuscation: false
- )
-
- exitLocationViewController = LocationViewController(
- customListRepository: customListRepository,
- selectedRelays: RelaySelection(),
- shouldFilterDaita: false,
- shouldFilterObfuscation: false
- )
-
- super.init(nibName: nil, bundle: nil)
-
- self.onNewSettings = { [weak self] newSettings in
- self?.settings = newSettings
- self?.setRelaysWithLocation()
- }
-
- setRelaysWithLocation()
-
- updateViewControllers {
- $0.delegate = self
- }
- }
-
- var didFinish: (() -> Void)?
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
-
- view.setAccessibilityIdentifier(.selectLocationViewWrapper)
- view.backgroundColor = .secondaryColor
-
- setUpNavigation()
- setUpSegmentedControl()
- addSubviews()
- add(entryLocationViewController)
- add(exitLocationViewController)
- swapViewController()
- }
-
- private func setRelaysWithLocation() {
- let emptyResult = LocationRelays(relays: [], locations: [:])
- let relaysCandidates = try? relaySelectorWrapper.findCandidates(tunnelSettings: settings)
-
- let isMultihop = settings.tunnelMultihopState.isEnabled
- let isDirectOnly = settings.daita.isDirectOnly
- let isAutomaticRouting = settings.daita.isAutomaticRouting
- let isObfuscation = settings.wireGuardObfuscation.state.affectsRelaySelection
-
- if isMultihop {
- entryLocationViewController?.setObfuscationChip(isObfuscation && !isAutomaticRouting)
- entryLocationViewController?.setDaitaChip(isDirectOnly)
- entryLocationViewController?.toggleDaitaAutomaticRouting(isEnabled: isAutomaticRouting)
- } else {
- segmentedControlView?.isHidden = true
- exitLocationViewController.setObfuscationChip(isObfuscation)
- exitLocationViewController.setDaitaChip(isDirectOnly)
- }
-
- if let entryRelays = relaysCandidates?.entryRelays {
- entryLocationViewController?.setRelaysWithLocation(entryRelays.toLocationRelays(), filter: relayFilter)
- } else {
- entryLocationViewController?.setRelaysWithLocation(
- emptyResult,
- filter: relayFilter
- )
- }
- exitLocationViewController.setRelaysWithLocation(
- relaysCandidates?.exitRelays.toLocationRelays() ?? emptyResult,
- filter: relayFilter
- )
- }
-
- func refreshCustomLists() {
- updateViewControllers {
- $0.refreshCustomLists()
- }
- }
-
- private func updateViewControllers(callback: (LocationViewController) -> Void) {
- [entryLocationViewController, exitLocationViewController]
- .compactMap { $0 }
- .forEach { callback($0) }
- }
-
- private func setUpNavigation() {
- navigationItem.largeTitleDisplayMode = .never
-
- navigationItem.title = NSLocalizedString("Select location", comment: "")
-
- navigationItem.leftBarButtonItem = UIBarButtonItem(
- title: NSLocalizedString("Filter", comment: ""),
- primaryAction: UIAction(handler: { [weak self] _ in
- guard let self = self else { return }
- delegate?.navigateToFilter()
- })
- )
- navigationItem.leftBarButtonItem?.setAccessibilityIdentifier(.selectLocationFilterButton)
-
- navigationItem.rightBarButtonItem = UIBarButtonItem(
- systemItem: .done,
- primaryAction: UIAction(handler: { [weak self] _ in
- self?.didFinish?()
- })
- )
- navigationItem.rightBarButtonItem?.setAccessibilityIdentifier(.closeSelectLocationButton)
- }
-
- private func setUpSegmentedControl() {
- let swiftUISegmentedControl = SegmentedControl(
- segments: MultihopContext.allCases.map { $0.description },
- viewModel: segmentedViewModel,
- onSelectedSegment: segmentedControlDidChange
- )
-
- let host = UIHostingController(rootView: swiftUISegmentedControl)
- addChild(host)
- host.didMove(toParent: self)
- segmentedControlView = host.view!
- segmentedViewModel.selectedSegmentIndex = multihopContext.rawValue
- host.view.backgroundColor = .clear
- }
-
- private func addSubviews() {
- view.addConstrainedSubviews([segmentedControlView, locationViewContainer]) {
- segmentedControlView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
- segmentedControlView.pinEdgesToSuperviewMargins(PinnableEdges([.top(0), .leading(8), .trailing(8)]))
-
- locationViewContainer.pinEdgesToSuperview(.all().excluding(.top))
-
- if settings.tunnelMultihopState.isEnabled {
- locationViewContainer.topAnchor.constraint(equalTo: segmentedControlView.bottomAnchor, constant: 4)
- } else {
- locationViewContainer.pinEdgeToSuperviewMargin(.top(0))
- }
- }
- }
-
- private func add(_ locationViewController: LocationViewController?) {
- guard let locationViewController else { return }
- addChild(locationViewController)
- locationViewController.didMove(toParent: self)
- locationViewContainer.addConstrainedSubviews([locationViewController.view]) {
- locationViewController.view.pinEdgesToSuperview()
- }
- }
-
- private func segmentedControlDidChange(selectedIndex: Int) {
- multihopContext = .allCases[selectedIndex]
- swapViewController()
- }
-
- private func swapViewController() {
- var selectedRelays: RelaySelection
- var oldViewController: LocationViewController?
- var newViewController: LocationViewController?
-
- (selectedRelays, oldViewController, newViewController) =
- switch multihopContext {
- case .entry:
- (
- RelaySelection(
- selected: selectedEntry,
- excluded: selectedExit,
- excludedTitle: MultihopContext.exit.description
- ),
- exitLocationViewController,
- entryLocationViewController
- )
- case .exit:
- (
- RelaySelection(
- selected: selectedExit,
- excluded: settings.tunnelMultihopState.isEnabled ? selectedEntry : nil,
- excludedTitle: MultihopContext.entry.description
- ),
- entryLocationViewController,
- exitLocationViewController
- )
- }
- newViewController?.setSelectedRelays(selectedRelays)
- oldViewController?.view.isUserInteractionEnabled = false
- newViewController?.view.isUserInteractionEnabled = true
- UIView.animate(withDuration: 0.0) {
- oldViewController?.view.alpha = 0
- newViewController?.view.alpha = 1
- }
- }
-}
-
-extension LocationViewControllerWrapper: @preconcurrency LocationViewControllerDelegate {
- func navigateToCustomLists(nodes: [LocationNode]) {
- delegate?.navigateToCustomLists(nodes: nodes)
- }
-
- func navigateToDaitaSettings() {
- delegate?.navigateToDaitaSettings()
- }
-
- func didSelectRelays(relays: UserSelectedRelays) {
- switch multihopContext {
- case .entry:
- selectedEntry = relays
- delegate?.didSelectEntryRelays(relays)
- segmentedViewModel.selectedSegmentIndex = MultihopContext.exit.rawValue
- segmentedControlDidChange(selectedIndex: MultihopContext.exit.rawValue)
- case .exit:
- delegate?.didSelectExitRelays(relays)
- didFinish?()
- }
- }
-
- func didUpdateFilter(filter: RelayFilter) {
- delegate?.didUpdateFilter(filter)
- }
-}
-
-private extension WireGuardObfuscationState {
- /// This flag affects whether the "Setting: Obfuscation" pill is shown when selecting a location
- var affectsRelaySelection: Bool {
- switch self {
- case .shadowsocks, .quic:
- true
- default: false
- }
- }
-}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/MockSelectLocationViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/MockSelectLocationViewModel.swift
new file mode 100644
index 0000000000..8bd18c5438
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/MockSelectLocationViewModel.swift
@@ -0,0 +1,197 @@
+import Foundation
+
+class MockSelectLocationViewModel: SelectLocationViewModel {
+ var isMultihopEnabled: Bool = true
+
+ var showDAITAInfo = false
+
+ var entryContext: LocationContext
+ var exitContext: LocationContext
+
+ @Published var multihopContext: MultihopContext = .entry
+
+ init() {
+ entryContext = LocationContext()
+ exitContext = LocationContext(
+ locations: [
+ LocationNode(
+ name: "Sweden", code: "se",
+ children: [
+ LocationNode(
+ name: "Stockholm",
+ code: "sth",
+ children: [
+ LocationNode(name: sth1, code: sth1),
+ LocationNode(name: sth2, code: sth2),
+ LocationNode(name: sth3, code: sth3),
+ ]
+ ),
+ LocationNode(
+ name: "Gothenburg", code: "got",
+ children: [
+ LocationNode(name: got1, code: got1),
+ LocationNode(name: got2, code: got2),
+ LocationNode(name: got3, code: got3),
+ ]),
+ ], showsChildren: true),
+ LocationNode(
+ name: "Germany", code: "de",
+ children: [
+ LocationNode(
+ name: "Berlin", code: "ber",
+ children: [
+ LocationNode(name: ber1, code: ber1),
+ LocationNode(name: ber2, code: ber2),
+ LocationNode(name: ber3, code: ber3),
+ ]),
+ LocationNode(
+ name: "Frankfurt", code: "fra",
+ children: [
+ LocationNode(name: fra1, code: fra1),
+ LocationNode(name: fra2, code: fra2),
+ LocationNode(name: fra3, code: fra3),
+ ]),
+ ]),
+ LocationNode(
+ name: "France", code: "fr",
+ children: [
+ LocationNode(
+ name: "Paris", code: "par",
+ children: [
+ LocationNode(name: par1, code: par1),
+ LocationNode(name: par2, code: par2),
+ LocationNode(name: par3, code: par3),
+ ]),
+ LocationNode(
+ name: "Lyon", code: "lyo",
+ children: [
+ LocationNode(name: lyo1, code: lyo1),
+ LocationNode(name: lyo2, code: lyo2),
+ LocationNode(name: lyo3, code: lyo3),
+ ]),
+ ]),
+ ],
+ customLists: [
+ LocationNode(
+ name: "MyList1", code: "mylist1",
+ children: [
+ LocationNode(
+ name: "Sweden", code: "se",
+ children: [
+ LocationNode(
+ name: "Stockholm",
+ code: "sth",
+ ),
+ LocationNode(
+ name: "Gothenburg", code: "got",
+ children: [
+ LocationNode(name: got1, code: got1),
+ LocationNode(name: got2, code: got2),
+ LocationNode(name: got3, code: got3),
+ ]),
+ ]),
+ LocationNode(
+ name: "Gothenburg", code: "got",
+ children: [
+ LocationNode(name: got1, code: got1),
+ LocationNode(name: got2, code: got2),
+ ]),
+ LocationNode(name: got3, code: got3),
+ ]),
+ LocationNode(
+ name: "MyList2", code: "mylist2",
+ children: [
+ LocationNode(
+ name: "Germany", code: "de",
+ children: [
+ LocationNode(
+ name: "Berlin", code: "ber",
+ children: [
+ LocationNode(name: ber1, code: ber1),
+ LocationNode(name: ber2, code: ber2),
+ LocationNode(name: ber3, code: ber3),
+ ]),
+ LocationNode(
+ name: "Frankfurt", code: "fra",
+ children: [
+ LocationNode(name: fra1, code: fra1),
+ LocationNode(name: fra2, code: fra2),
+ LocationNode(name: fra3, code: fra3),
+ ]),
+ ])
+ ]),
+ LocationNode(
+ name: "Stockholm",
+ code: "sth",
+ ),
+ ],
+ filter: [
+ .daita,
+ .obfuscation,
+ .rented,
+ .owned,
+ .provider(12),
+ ],
+ selectedLocation: nil,
+ connectedRelayHostname: nil
+ ) { _ in
+ }
+ }
+
+ func onFilterTapped(_ filter: SelectLocationFilter) {
+ print("show filter: \(filter)")
+ }
+
+ func onFilterRemoved(_ filter: SelectLocationFilter) {
+ print("remove filter: \(filter)")
+ }
+
+ var searchText: String = ""
+
+ func customListsChanged() {}
+
+ func addLocationToCustomList(
+ location: LocationNode,
+ customListName: String
+ ) {}
+
+ func deleteCustomList(name: String) {}
+
+ func showEditCustomList(name: String) {}
+
+ func removeLocationFromCustomList(
+ location: LocationNode,
+ customListName: String
+ ) {}
+
+ func didFinish() {}
+
+ func showDaitaSettings() {}
+
+ func showEditCustomListView(locations: [LocationNode]) {}
+
+ func showAddCustomListView(locations: [LocationNode]) {}
+
+ func showFilterView() {}
+
+ func expandSelectedLocation() {}
+
+ private var got1 = "se-got-001"
+ private let got2 = "se-got-002"
+ private let got3 = "se-got-003"
+ private let sth1 = "se-sto-001"
+ private let sth2 = "se-sto-002"
+ private let sth3 = "se-sto-003"
+ private let ber1 = "de-ber-001"
+ private let ber2 = "de-ber-002"
+ private let ber3 = "de-ber-003"
+ private let fra1 = "de-fra-001"
+ private let fra2 = "de-fra-002"
+ private let fra3 = "de-fra-003"
+ private let par1 = "fr-par-001"
+ private let par2 = "fr-par-002"
+ private let par3 = "fr-par-003"
+ private let lyo1 = "fr-lyo-001"
+ private let lyo2 = "fr-lyo-002"
+ private let lyo3 = "fr-lyo-003"
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/MultihopContext.swift b/ios/MullvadVPN/View controllers/SelectLocation/MultihopContext.swift
new file mode 100644
index 0000000000..dbcdf4c4e5
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/MultihopContext.swift
@@ -0,0 +1,14 @@
+import SwiftUI
+
+enum MultihopContext: CaseIterable, CustomStringConvertible, Hashable {
+ case entry, exit
+
+ var description: String {
+ switch self {
+ case .entry:
+ NSLocalizedString("Entry", comment: "")
+ case .exit:
+ NSLocalizedString("Exit", comment: "")
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift
new file mode 100644
index 0000000000..c7524e5630
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift
@@ -0,0 +1,99 @@
+import MullvadSettings
+import SwiftUI
+
+enum SelectLocationFilter: Hashable {
+ case daita
+ case obfuscation
+ case owned
+ case rented
+ case provider(Int)
+
+ var isRemovable: Bool {
+ switch self {
+ case .daita, .obfuscation:
+ false
+ case .provider, .owned, .rented:
+ true
+ }
+ }
+
+ var title: LocalizedStringKey {
+ switch self {
+ case .daita:
+ "Setting: \("DAITA")"
+ case .obfuscation:
+ "Setting: \("Obfuscation")"
+ case .owned:
+ "Owned"
+ case .rented:
+ "Rented"
+ case .provider(let count):
+ "Providers: \(count)"
+ }
+ }
+
+ var accessibilityIdentifier: AccessibilityIdentifier? {
+ switch self {
+ case .daita:
+ .daitaFilterPill
+ case .obfuscation:
+ .obfuscationFilterPill
+ case .owned, .rented, .provider:
+ .selectLocationFilterButton
+ }
+ }
+
+ static func getActiveFilters(_ settings: LatestTunnelSettings) -> (
+ [SelectLocationFilter],
+ [SelectLocationFilter]
+ ) {
+ var activeEntryFilter: [SelectLocationFilter] = []
+ var activeExitFilter: [SelectLocationFilter] = []
+
+ let isMultihop = settings.tunnelMultihopState.isEnabled
+ if let ownershipFilter = settings.relayConstraints.filter.value {
+ switch ownershipFilter.ownership {
+ case .any:
+ break
+ case .owned:
+ activeEntryFilter.append(.owned)
+ activeExitFilter.append(.owned)
+ case .rented:
+ activeEntryFilter.append(.rented)
+ activeExitFilter.append(.rented)
+ }
+ if let provider = ownershipFilter.providers.value {
+ activeEntryFilter.append(.provider(provider.count))
+ activeExitFilter.append(.provider(provider.count))
+ }
+ }
+ if settings.daita.isDirectOnly {
+ if isMultihop {
+ activeEntryFilter.append(.daita)
+ } else {
+ activeExitFilter.append(.daita)
+ }
+ }
+
+ let isObfuscation = settings.wireGuardObfuscation.state.affectsRelaySelection
+ if isObfuscation {
+ if isMultihop {
+ activeEntryFilter.append(.obfuscation)
+ } else {
+ activeExitFilter.append(.obfuscation)
+ }
+ }
+ return (activeEntryFilter, activeExitFilter)
+ }
+}
+
+private extension WireGuardObfuscationState {
+ /// This flag affects whether the "Setting: Obfuscation" pill is shown when selecting a location
+ var affectsRelaySelection: Bool {
+ switch self {
+ case .shadowsocks, .quic:
+ true
+ default: false
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift
new file mode 100644
index 0000000000..cc78eb3151
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationViewModel.swift
@@ -0,0 +1,369 @@
+import Combine
+import MullvadREST
+import MullvadSettings
+import MullvadTypes
+
+@MainActor
+protocol SelectLocationViewModel: ObservableObject {
+ var exitContext: LocationContext { get set }
+ var entryContext: LocationContext { get set }
+ var multihopContext: MultihopContext { get set }
+ var searchText: String { get set }
+ var showDAITAInfo: Bool { get }
+ var isMultihopEnabled: Bool { get }
+ func onFilterTapped(_ filter: SelectLocationFilter)
+ func onFilterRemoved(_ filter: SelectLocationFilter)
+ func customListsChanged()
+ func addLocationToCustomList(location: LocationNode, customListName: String)
+ func removeLocationFromCustomList(location: LocationNode, customListName: String)
+ func deleteCustomList(name: String)
+ func showEditCustomList(name: String)
+ func didFinish()
+ func showDaitaSettings()
+ func showEditCustomListView(locations: [LocationNode])
+ func showAddCustomListView(locations: [LocationNode])
+ func showFilterView()
+}
+
+struct SelectLocationDelegate {
+ let showDaitaSettings: () -> Void
+ let showObfuscationSettings: () -> Void
+ let showFilterView: () -> Void
+ let showEditCustomListView: ([LocationNode], CustomList?) -> Void
+ let showAddCustomListView: ([LocationNode]) -> Void
+ let didSelectExitRelayLocations: (UserSelectedRelays) -> Void
+ let didSelectEntryRelayLocations: (UserSelectedRelays) -> Void
+ let didFinish: () -> Void
+}
+
+@MainActor
+class SelectLocationViewModelImpl: SelectLocationViewModel {
+ @Published var isMultihopEnabled: Bool
+ @Published var multihopContext: MultihopContext = .exit
+
+ @Published var exitContext = LocationContext()
+ @Published var entryContext = LocationContext()
+ @Published var searchText: String = ""
+ @Published var showDAITAInfo: Bool
+
+ private let exitLocationsDataSource = AllLocationDataSource()
+ private let entryLocationsDataSource = AllLocationDataSource()
+ private let entryCustomListsDataSource: CustomListsDataSource
+ private let exitCustomListsDataSource: CustomListsDataSource
+
+ private let relaySelectorWrapper: RelaySelectorWrapper
+ private let tunnelManager: TunnelManager
+ private let customListInteractor: CustomListInteractorProtocol
+ private var relaysCandidates: RelayCandidates?
+
+ private var tunnelObserver: TunnelBlockObserver?
+
+ private let delegate: SelectLocationDelegate
+
+ private var cancellables: [Combine.AnyCancellable] = []
+
+ private var allLocations: [LocationNode] {
+ exitContext.locations + exitContext.customLists + entryContext.locations + entryContext.customLists
+ }
+
+ init(
+ tunnelManager: TunnelManager,
+ relaySelectorWrapper: RelaySelectorWrapper,
+ customListRepository: CustomListRepositoryProtocol,
+ delegate: SelectLocationDelegate
+ ) {
+ self.tunnelManager = tunnelManager
+ self.relaySelectorWrapper = relaySelectorWrapper
+ self.customListInteractor = CustomListInteractor(
+ tunnelManager: tunnelManager,
+ repository: customListRepository
+ )
+ self.delegate = delegate
+ self.entryCustomListsDataSource = CustomListsDataSource(
+ repository: customListRepository
+ )
+ self.exitCustomListsDataSource = CustomListsDataSource(
+ repository: customListRepository
+ )
+
+ showDAITAInfo = tunnelManager.settings.daita.isAutomaticRouting
+
+ // If multihop is enabled, we should check if there's a DAITA related error when opening the location
+ // view. If there is, help the user by showing the entry instead of the exit view.
+ isMultihopEnabled = tunnelManager.settings.tunnelMultihopState.isEnabled
+ if isMultihopEnabled {
+ self.multihopContext =
+ if case .noRelaysSatisfyingDaitaConstraints = tunnelManager.tunnelStatus.observedState
+ .blockedState?.reason
+ { .entry } else { .exit }
+ }
+
+ self.entryContext = LocationContext(
+ filter: SelectLocationFilter.getActiveFilters(tunnelManager.settings).0,
+ selectLocation: { [weak self] location in
+ delegate
+ .didSelectEntryRelayLocations(location.userSelectedRelays)
+ self?.multihopContext = .exit
+ }
+ )
+ self.exitContext = LocationContext(
+ filter: SelectLocationFilter.getActiveFilters(tunnelManager.settings).1,
+ selectLocation: { location in
+ delegate
+ .didSelectExitRelayLocations(location.userSelectedRelays)
+ }
+ )
+ let tunnelObserver =
+ TunnelBlockObserver(
+ didUpdateTunnelStatus: { [weak self] _, status in
+ self?.updateConnectedLocations(status)
+ },
+ didUpdateTunnelSettings: { [weak self] _, settings in
+ guard let self else { return }
+ fetchLocations()
+ refreshCustomLists()
+ updateSelections(
+ selectedExitRelays: settings.relayConstraints.exitLocations.value,
+ selectedEntryRelays: settings.relayConstraints.entryLocations.value
+ )
+ updateConnectedLocations(tunnelManager.tunnelStatus)
+ if !searchText.isEmpty {
+ search(searchText: searchText)
+ }
+
+ showDAITAInfo = tunnelManager.settings.daita.isAutomaticRouting
+
+ let (activeEntryFilter, activeExitFilter) = SelectLocationFilter.getActiveFilters(
+ settings
+ )
+ entryContext.filter = activeEntryFilter
+ exitContext.filter = activeExitFilter
+
+ }
+ )
+
+ $searchText
+ .removeDuplicates()
+ .withPreviousValue()
+ .sink { [weak self] prevValue, newValue in
+ if prevValue == newValue { return }
+ if prevValue == nil && newValue == "" { return }
+ self?.search(searchText: newValue)
+ if newValue == "" {
+ self?.expandSelectedLocation()
+ }
+ }.store(in: &cancellables)
+
+ tunnelManager.addObserver(tunnelObserver)
+ self.tunnelObserver = tunnelObserver
+
+ fetchLocations()
+ refreshCustomLists()
+ updateSelections(
+ selectedExitRelays: tunnelManager.settings.relayConstraints.exitLocations.value,
+ selectedEntryRelays: tunnelManager.settings.relayConstraints.entryLocations.value
+ )
+ updateConnectedLocations(tunnelManager.tunnelStatus)
+ expandSelectedLocation()
+ }
+
+ deinit {
+ guard let tunnelObserver else { return }
+ tunnelManager.removeObserver(tunnelObserver)
+ }
+
+ func onFilterTapped(_ filter: SelectLocationFilter) {
+ switch filter {
+ case .owned, .rented, .provider:
+ delegate.showFilterView()
+ case .daita:
+ delegate.showDaitaSettings()
+ case .obfuscation:
+ delegate.showObfuscationSettings()
+ }
+ }
+
+ func onFilterRemoved(_ filter: SelectLocationFilter) {
+ switch filter {
+ case .owned, .rented:
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ guard var filter = relayConstraints.filter.value else { return }
+ filter.ownership = .any
+ relayConstraints.filter = .only(filter)
+ tunnelManager.updateSettings([.relayConstraints(relayConstraints)])
+ case .provider:
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ guard var filter = relayConstraints.filter.value else { return }
+ filter.providers = .any
+ relayConstraints.filter = .only(filter)
+ tunnelManager.updateSettings([.relayConstraints(relayConstraints)])
+ default:
+ break
+ }
+ }
+
+ func deleteCustomList(name: String) {
+ guard let customList = customListInteractor.fetchAll().first(where: { $0.name == name }) else {
+ return
+ }
+ customListInteractor.delete(customList: customList)
+ customListsChanged()
+ }
+
+ func showEditCustomList(name: String) {
+ guard let customList = customListInteractor.fetchAll().first(where: { $0.name == name }) else {
+ return
+ }
+ switch multihopContext {
+ case .entry:
+ delegate
+ .showEditCustomListView(entryContext.locations, customList)
+ case .exit:
+ delegate
+ .showEditCustomListView(exitContext.locations, customList)
+ }
+ }
+
+ func addLocationToCustomList(location: LocationNode, customListName: String) {
+ try? customListInteractor
+ .addLocationToCustomList(
+ relayLocations: location.locations,
+ customListName: customListName
+ )
+ customListsChanged()
+ }
+
+ func removeLocationFromCustomList(
+ location: LocationNode,
+ customListName: String
+ ) {
+ try? customListInteractor
+ .removeLocationFromCustomList(
+ relayLocations: location.locations,
+ customListName: customListName
+ )
+ customListsChanged()
+ }
+
+ func customListsChanged() {
+ refreshCustomLists()
+ updateSelections(
+ selectedExitRelays: tunnelManager.settings.relayConstraints.exitLocations.value,
+ selectedEntryRelays: tunnelManager.settings.relayConstraints.entryLocations.value
+ )
+ updateConnectedLocations(tunnelManager.tunnelStatus)
+ }
+
+ private func refreshCustomLists() {
+ exitCustomListsDataSource.reload(allLocationNodes: exitContext.locations)
+ entryCustomListsDataSource.reload(allLocationNodes: entryContext.locations)
+
+ exitContext.customLists = exitCustomListsDataSource.nodes
+ entryContext.customLists = entryCustomListsDataSource.nodes
+ }
+
+ private func fetchLocations() {
+ relaysCandidates = try? relaySelectorWrapper.findCandidates(
+ tunnelSettings: tunnelManager.settings
+ )
+ if let relaysCandidates {
+ exitLocationsDataSource
+ .reload(relaysCandidates.exitRelays.toLocationRelays())
+ exitContext.locations = exitLocationsDataSource.nodes
+
+ if let entryRelays = relaysCandidates.entryRelays {
+ entryLocationsDataSource
+ .reload(entryRelays.toLocationRelays())
+ entryContext.locations =
+ entryLocationsDataSource.nodes
+ }
+ } else {
+ entryContext.locations = []
+ exitContext.locations = []
+ }
+ }
+
+ private func updateConnectedLocations(_ status: TunnelStatus) {
+ exitLocationsDataSource
+ .setConnectedRelay(hostname: status.state.relays?.exit.hostname)
+ exitCustomListsDataSource
+ .setConnectedRelay(hostname: status.state.relays?.exit.hostname)
+ entryLocationsDataSource
+ .setConnectedRelay(hostname: status.state.relays?.entry?.hostname)
+ entryCustomListsDataSource
+ .setConnectedRelay(hostname: status.state.relays?.entry?.hostname)
+ }
+
+ private func search(searchText: String) {
+ exitLocationsDataSource
+ .search(by: searchText)
+ exitCustomListsDataSource
+ .search(by: searchText)
+ entryLocationsDataSource
+ .search(by: searchText)
+ entryCustomListsDataSource
+ .search(by: searchText)
+ }
+
+ private func updateSelections(
+ selectedExitRelays: UserSelectedRelays?,
+ selectedEntryRelays: UserSelectedRelays?
+ ) {
+ // set exit selection
+ exitLocationsDataSource
+ .setSelectedNode(selectedRelays: selectedExitRelays)
+ exitCustomListsDataSource
+ .setSelectedNode(selectedRelays: selectedExitRelays)
+
+ if isMultihopEnabled {
+ // set entry selection
+ entryLocationsDataSource
+ .setSelectedNode(selectedRelays: selectedEntryRelays)
+ entryCustomListsDataSource
+ .setSelectedNode(selectedRelays: selectedEntryRelays)
+
+ // exclude selected entry relays in exit lists
+ exitLocationsDataSource
+ .setExcludedNode(excludedSelection: selectedEntryRelays)
+ exitCustomListsDataSource
+ .setExcludedNode(excludedSelection: selectedEntryRelays)
+
+ // exclude selected exit relays in entry lists
+ entryLocationsDataSource
+ .setExcludedNode(excludedSelection: selectedExitRelays)
+ entryCustomListsDataSource
+ .setExcludedNode(excludedSelection: selectedExitRelays)
+ }
+ }
+
+ private func expandSelectedLocation() {
+ exitLocationsDataSource
+ .expandSelection()
+ exitCustomListsDataSource
+ .expandSelection()
+ entryLocationsDataSource
+ .expandSelection()
+ entryCustomListsDataSource
+ .expandSelection()
+ }
+
+ func didFinish() {
+ delegate.didFinish()
+ }
+
+ func showDaitaSettings() {
+ delegate.showDaitaSettings()
+ }
+
+ func showEditCustomListView(locations: [LocationNode]) {
+ delegate.showEditCustomListView(locations, nil)
+ }
+
+ func showAddCustomListView(locations: [LocationNode]) {
+ delegate.showAddCustomListView(locations)
+ }
+
+ func showFilterView() {
+ delegate.showFilterView()
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/ActiveFilterView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/ActiveFilterView.swift
new file mode 100644
index 0000000000..a1ea82b6d2
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/ActiveFilterView.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+
+struct ActiveFilterView: View {
+ let activeFilter: [SelectLocationFilter]
+ let onSelect: (SelectLocationFilter) -> Void
+ let onRemove: (SelectLocationFilter) -> Void
+
+ // Show filters that can't be removed to the left
+ private var sortedFilters: [SelectLocationFilter] {
+ activeFilter
+ .sorted {
+ !$0.isRemovable && $1.isRemovable
+ }
+ }
+ var body: some View {
+ ScrollView(.horizontal) {
+ HStack {
+ ForEach(sortedFilters, id: \.hashValue) { filter in
+ Button {
+ onSelect(filter)
+ } label: {
+ HStack {
+ Text(filter.title)
+ if filter.isRemovable {
+ Button {
+ onRemove(filter)
+ } label: {
+ Image(systemName: "xmark")
+ }
+ .accessibilityIdentifier(.relayFilterChipCloseButton)
+ }
+ }
+ .foregroundStyle(Color.mullvadTextPrimary)
+ .font(.mullvadMiniSemiBold)
+ .padding(8)
+ .background {
+ RoundedRectangle(cornerRadius: 8)
+ .foregroundStyle(Color.MullvadButton.primary)
+ }
+ }
+ .accessibilityIdentifier(filter.accessibilityIdentifier)
+ }
+ }
+ .padding(.horizontal)
+ }
+ .scrollIndicators(.never)
+ }
+}
+
+#Preview {
+ Text("")
+ .sheet(isPresented: .constant(true)) {
+ NavigationView {
+ ScrollView {
+ ActiveFilterView(
+ activeFilter: [
+ .daita,
+ .owned,
+ .rented,
+ .provider(2),
+ .obfuscation,
+ ],
+ onSelect: { _ in
+ },
+ onRemove: { _ in }
+ )
+ }
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/DaitaWarningView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/DaitaWarningView.swift
new file mode 100644
index 0000000000..f0af57f305
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/DaitaWarningView.swift
@@ -0,0 +1,29 @@
+import SwiftUI
+
+struct DaitaWarningView: View {
+ let onOpenDaitaSettings: () -> Void
+ var body: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ Text(
+ """
+ The entry server for \("multihop") is currently overridden by \("DAITA"). To select an entry server, \
+ please first enable “\("Direct only")” or disable “\("DAITA")” in the settings.
+ """
+ )
+ .multilineTextAlignment(.center)
+ .foregroundStyle(Color.mullvadTextSecondary)
+ .font(.mullvadSmall)
+ MainButton(text: "Open \("DAITA") settings", style: .default) {
+ onOpenDaitaSettings()
+ }
+ Spacer()
+ }
+ .padding()
+ }
+}
+
+#Preview {
+ DaitaWarningView(onOpenDaitaSettings: {})
+ .background(Color.mullvadBackground)
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/EntryLocationView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/EntryLocationView.swift
new file mode 100644
index 0000000000..66e9b04c0b
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/EntryLocationView.swift
@@ -0,0 +1,15 @@
+import SwiftUI
+
+struct EntryLocationView<ViewModel: SelectLocationViewModel>: View {
+ @ObservedObject var viewModel: ViewModel
+
+ var body: some View {
+ if viewModel.showDAITAInfo {
+ DaitaWarningView {
+ viewModel.showDaitaSettings()
+ }
+ } else {
+ ExitLocationView(viewModel: viewModel, context: $viewModel.entryContext)
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift
new file mode 100644
index 0000000000..c03d6c5282
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/ExitLocationView.swift
@@ -0,0 +1,160 @@
+import SwiftUI
+
+struct ExitLocationView<ViewModel: SelectLocationViewModel>: View {
+ @ObservedObject var viewModel: ViewModel
+ @Binding var context: LocationContext
+ @State var newCustomListAlert: MullvadInputAlert?
+ @State var alert: MullvadAlert?
+
+ var isShowingCustomListsSection: Bool {
+ viewModel.searchText.isEmpty
+ || (!viewModel.searchText.isEmpty
+ && !context.customLists
+ .filter {
+ !$0.isHiddenFromSearch
+ }.isEmpty)
+ }
+ var isShowingAllLocationsSection: Bool {
+ !context.locations.filter({ !$0.isHiddenFromSearch }).isEmpty
+ }
+
+ var body: some View {
+ ScrollViewReader { scrollProxy in
+ // All items in the list are arranged in a flat hierarchy
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ Group {
+ if !context.filter.isEmpty {
+ ActiveFilterView(
+ activeFilter: context.filter
+ ) { filter in
+ viewModel.onFilterTapped(filter)
+ } onRemove: { filter in
+ viewModel.onFilterRemoved(filter)
+ }
+ .padding(.bottom, 16)
+ }
+ Group {
+ if isShowingCustomListsSection {
+ customListSection(isShowingHeader: isShowingAllLocationsSection)
+ }
+ if isShowingAllLocationsSection {
+ allLocationsSection(isShowingHeader: isShowingCustomListsSection)
+ }
+ if !isShowingCustomListsSection && !isShowingAllLocationsSection {
+ Text("No result for \"\(viewModel.searchText)\", please try a different search term.")
+ .font(.mullvadMiniSemiBold)
+ .foregroundStyle(Color.mullvadTextPrimary.opacity(0.6))
+ .padding(.vertical)
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+ .zIndex(3) // prevent wrong overlapping during animations
+ }
+ }
+ .onAppear {
+ guard viewModel.searchText.isEmpty else { return }
+ let selectedLocation = (context.locations + context.customLists)
+ .flatMap { $0.flattened + [$0] }
+ .first { $0.isSelected }
+
+ if let selectedLocation {
+ scrollProxy.scrollTo(selectedLocation.code, anchor: .center)
+ }
+ }
+ .accessibilityIdentifier(.selectLocationView)
+ }
+ .mullvadInputAlert(item: $newCustomListAlert)
+ .mullvadAlert(item: $alert)
+ }
+
+ @ViewBuilder
+ func allLocationsSection(isShowingHeader: Bool) -> some View {
+ if isShowingHeader {
+ MullvadListSectionHeader(title: "All locations")
+ }
+ LocationsListView(
+ locations: $context.locations,
+ multihopContext: viewModel.multihopContext,
+ ) { location in
+ context.selectLocation(location)
+ } contextMenu: { location in
+ locationContextMenu(location)
+ }
+ }
+
+ @ViewBuilder
+ func customListSection(isShowingHeader: Bool) -> some View {
+ if isShowingHeader {
+ HStack(spacing: 0) {
+ MullvadListSectionHeader(title: "Custom lists")
+ Button {
+ viewModel.showAddCustomListView(
+ locations: context
+ .locations)
+ } label: {
+ Image.mullvadIconAdd
+ .padding(.horizontal, 10)
+ }
+ .accessibilityIdentifier(.addNewCustomListButton)
+ if !context.customLists.isEmpty {
+ Button {
+ viewModel.showEditCustomListView(
+ locations: context.locations
+ )
+ } label: {
+ Image.mullvadIconEdit
+ .padding(.horizontal, 10)
+ }
+ .accessibilityIdentifier(.editCustomListButton)
+ }
+ }
+ }
+ LocationsListView(
+ locations: $context.customLists,
+ multihopContext: viewModel.multihopContext,
+ ) { location in
+ context.selectLocation(location)
+ } contextMenu: { location in
+ customListContextMenu(location)
+ }
+
+ let text: LocalizedStringKey =
+ context.customLists.isEmpty
+ ? """
+ To create a custom list press the “+” or long press on a country, city, or server.
+ """
+ : """
+ To add locations to a list, press the pen or long press on a country, city, or server.
+ """
+ Text(text)
+ .font(.mullvadMini)
+ .foregroundStyle(Color.mullvadTextPrimary.opacity(0.6))
+ .padding(.horizontal, context.customLists.isEmpty ? 0 : 16)
+ .padding(.top, context.customLists.isEmpty ? 0 : 4)
+ .padding(.bottom, 24)
+ }
+}
+
+#Preview {
+ @Previewable @State var viewModel = MockSelectLocationViewModel()
+ ExitLocationView(
+ viewModel: viewModel,
+ context: $viewModel.exitContext,
+ newCustomListAlert: nil,
+ alert: nil
+ )
+ .background(Color.mullvadBackground)
+}
+
+#Preview("Empty lists") {
+ @Previewable @State var viewModel = MockSelectLocationViewModel()
+ ExitLocationView(
+ viewModel: viewModel,
+ context: $viewModel.entryContext,
+ newCustomListAlert: nil,
+ alert: nil
+ )
+ .background(Color.mullvadBackground)
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationContextMenu.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationContextMenu.swift
new file mode 100644
index 0000000000..4ca89b0c54
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationContextMenu.swift
@@ -0,0 +1,109 @@
+import MullvadTypes
+import SwiftUI
+
+extension ExitLocationView {
+
+ @ViewBuilder
+ func customListContextMenu(_ location: LocationNode) -> some View {
+ VStack {
+ switch location {
+ case let location as CustomListLocationNode:
+ Button("Edit") {
+ viewModel.showEditCustomList(name: location.name)
+ }
+
+ Button("Delete") {
+ alert = .init(
+ type: .warning,
+ messages: ["Do you want to delete the list **\(location.name)**?"],
+ action: .init(
+ type: .danger,
+ title: "Delete list",
+ identifier: nil,
+ handler: {
+ viewModel.deleteCustomList(name: location.name)
+ alert = nil
+ }
+ ),
+ dismissButtonTitle: "Cancel"
+ )
+ }
+
+ default:
+ if let customListNode = location.parent?.asCustomListNode {
+ Button("Remove") {
+ viewModel
+ .removeLocationFromCustomList(
+ location: location,
+ customListName: customListNode.name
+ )
+ UIImpactFeedbackGenerator(
+ style: .medium
+ )
+ .impactOccurred()
+ }
+ } else {
+ // Only top level nodes can be removed from a custom list
+ EmptyView()
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ func locationContextMenu(_ location: LocationNode) -> some View {
+ Section("Add \(location.name) to list") {
+ ForEach(
+ context.customLists,
+ id: \.code
+ ) { customList in
+ var isAlreadyInList: Bool {
+ var isAlreadyInList = false
+ customList.forEachDescendant {
+ if $0.locations == location.locations {
+ isAlreadyInList = true
+ }
+ }
+ return isAlreadyInList
+ }
+ Button(customList.name) {
+ viewModel
+ .addLocationToCustomList(
+ location: location,
+ customListName: customList.name
+ )
+ UIImpactFeedbackGenerator(
+ style: .medium
+ )
+ .impactOccurred()
+ }
+ .disabled(isAlreadyInList)
+ }
+ Button {
+ newCustomListAlert = .init(
+ title: "Create new list",
+ placeholder: "List name",
+ action: .init(
+ type: .default,
+ title: "Create",
+ identifier: nil,
+ handler: { listName in
+ viewModel
+ .addLocationToCustomList(
+ location: location,
+ customListName: listName
+ )
+ newCustomListAlert = nil
+ }
+ ),
+ validate: { listName in
+ !listName.isEmpty && listName.count <= NameInputFormatter.maxLength
+ },
+ dismissButtonTitle: "Cancel"
+ )
+ } label: {
+ Label("New list", systemImage: "plus")
+ }
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationDisclosureGroup.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationDisclosureGroup.swift
new file mode 100644
index 0000000000..5795d4a03f
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationDisclosureGroup.swift
@@ -0,0 +1,110 @@
+import SwiftUI
+
+struct LocationDisclosureGroup<Label: View, Content: View, ContextMenu: View>: View {
+ @Binding private var isExpanded: Bool
+
+ let level: Int
+ let isLastInList: Bool
+ let isActive: Bool
+ let label: () -> Label
+ let content: () -> Content
+ let onSelect: (() -> Void)?
+ let contextMenu: () -> ContextMenu
+ let accessibilityIdentifier: AccessibilityIdentifier?
+
+ private var topRadius: CGFloat {
+ level == 0 ? 16 : 0
+ }
+ private var bottomRadius: CGFloat {
+ isLastInList && !isExpanded ? 16 : 0
+ }
+
+ init(
+ level: Int,
+ isLastInList: Bool,
+ isActive: Bool = true,
+ isExpanded: Binding<Bool>,
+ @ViewBuilder contextMenu: @escaping () -> ContextMenu,
+ accessibilityIdentifier: AccessibilityIdentifier? = nil,
+ @ViewBuilder content: @escaping () -> Content,
+ @ViewBuilder label: @escaping () -> Label,
+ onSelect: (() -> Void)? = nil,
+ ) {
+ self.level = level
+ self.isLastInList = isLastInList
+ self.isActive = isActive
+ self._isExpanded = isExpanded
+ self.accessibilityIdentifier = accessibilityIdentifier
+
+ self.label = label
+ self.content = content
+ self.onSelect = onSelect
+ self.contextMenu = contextMenu
+ }
+
+ var body: some View {
+ HStack(spacing: 2) {
+ Button {
+ onSelect?()
+ } label: {
+ HStack {
+ label()
+ Spacer()
+ }
+ .frame(maxHeight: .infinity)
+ .background {
+ Color.colorForLevel(level)
+ }
+ }
+ .disabled(!isActive)
+ Button {
+ withAnimation(.default.speed(3)) {
+ isExpanded.toggle()
+ }
+ } label: {
+ Image.mullvadIconChevron
+ .rotationEffect(.degrees(isExpanded ? -90 : 90))
+ .padding(16)
+ .frame(maxHeight: .infinity)
+ .background {
+ Color.colorForLevel(level)
+ }
+ }
+ .accessibilityLabel(isExpanded ? Text("Collapse") : Text("Expand"))
+ .accessibilityIdentifier(.expandButton)
+ .contentShape(Rectangle())
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityIdentifier(accessibilityIdentifier)
+ .clipShape(
+ UnevenRoundedRectangle(
+ cornerRadii: .init(
+ topLeading: topRadius,
+ bottomLeading: bottomRadius,
+ bottomTrailing: bottomRadius,
+ topTrailing: topRadius
+ )
+ )
+ )
+ .contextMenu {
+ contextMenu()
+ }
+ .padding(.top, level == 0 ? 4 : 1)
+
+ if isExpanded {
+ content()
+ }
+ }
+}
+
+extension Color {
+ static func colorForLevel(_ level: Int) -> Color {
+ switch level {
+ case 1: Color.MullvadList.Item.child1
+ case 2: Color.MullvadList.Item.child2
+ case 3: Color.MullvadList.Item.child3
+ case 4: Color.MullvadList.Item.child4
+ default: Color.MullvadList.Item.parent
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift
new file mode 100644
index 0000000000..5991775b32
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift
@@ -0,0 +1,116 @@
+import SwiftUI
+
+struct LocationListItem<ContextMenu>: View where ContextMenu: View {
+ @Binding var location: LocationNode
+ var isLastInList: Bool = true
+ let multihopContext: MultihopContext
+ let onSelect: (LocationNode) -> Void
+ let contextMenu: (LocationNode) -> ContextMenu
+ var level = 0
+
+ var filteredChildrenIndices: [Int] {
+ location.children
+ .enumerated()
+ .filter { !$0.element.isHiddenFromSearch }
+ .map { $0.offset }
+ }
+
+ var body: some View {
+ Group {
+ if location.children.isEmpty {
+ RelayItemView(
+ location: location,
+ multihopContext: multihopContext,
+ level: level,
+ isLastInList: isLastInList,
+ onSelect: { onSelect(location) }
+ )
+ .accessibilityIdentifier(.locationListItem(location.name))
+ .contextMenu {
+ contextMenu(location)
+ }
+ .padding(.top, level == 0 ? 4 : 1)
+ } else {
+ LocationDisclosureGroup(
+ level: level,
+ isLastInList: isLastInList,
+ isActive: location.isActive && !location.isExcluded,
+ isExpanded: $location.showsChildren,
+ contextMenu: { contextMenu(location) },
+ accessibilityIdentifier: .locationListItem(location.name)
+ ) {
+ ForEach(
+ Array(filteredChildrenIndices.enumerated()),
+ id: \.element
+ ) { index, indexInChildrenList in
+ let location = $location.children[indexInChildrenList]
+ LocationListItem(
+ location: location,
+ isLastInList: isLastInList && index == (filteredChildrenIndices.count - 1),
+ multihopContext: multihopContext,
+ onSelect: onSelect,
+ contextMenu: { location in contextMenu(location) },
+ level: level + 1,
+ )
+ }
+ } label: {
+ HStack {
+ if !location.isActive {
+ Image.mullvadRedDot
+ } else if location.isSelected {
+ Image.mullvadIconTick
+ .foregroundStyle(Color.mullvadSuccessColor)
+ }
+ Text(location.name)
+ .foregroundStyle(
+ location.isActive && !location.isExcluded
+ ? location.isSelected
+ ? Color.mullvadSuccessColor
+ : Color.mullvadTextPrimary
+ : Color.mullvadTextPrimaryDisabled
+ )
+ .font(.mullvadSmallSemiBold)
+ .multilineTextAlignment(.leading)
+ }
+ .padding(.leading, CGFloat(16 * (level + 1)))
+ .padding(.trailing, 8)
+ .padding(.vertical, 16)
+ } onSelect: {
+ onSelect(location)
+ }
+ }
+ }
+ .zIndex(level == 0 ? 2 : 1 / Double(level)) // prevent wrong overlapping during animations
+ .id(location.code) // to be able to scroll to this item programmatically
+ }
+}
+
+#Preview {
+ let viewModel = MockSelectLocationViewModel()
+ Text("")
+ .sheet(isPresented: .constant(true)) {
+ ScrollView {
+ LocationListItem(
+ location:
+ .constant(
+ .init(name: "test", code: "test")
+ ),
+ multihopContext: .exit,
+ onSelect: { _ in },
+ contextMenu: { _ in EmptyView() },
+ level: 0
+ )
+ LocationListItem(
+ location:
+ .constant(
+ viewModel.exitContext.locations.first!
+ ),
+ multihopContext: .exit,
+ onSelect: { _ in
+ },
+ contextMenu: { _ in EmptyView() },
+ level: 0
+ )
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift
new file mode 100644
index 0000000000..2a67fb6d0d
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift
@@ -0,0 +1,50 @@
+import SwiftUI
+
+struct LocationsListView<ContextMenu>: View where ContextMenu: View {
+ @Binding var locations: [LocationNode]
+ let multihopContext: MultihopContext
+ let onSelectLocation: (LocationNode) -> Void
+ let contextMenu: (LocationNode) -> ContextMenu
+
+ var filteredLocationIndices: [Int] {
+ locations
+ .enumerated()
+ .filter { !$0.element.isHiddenFromSearch }
+ .map { $0.offset }
+ }
+
+ var body: some View {
+ ForEach(
+ Array(filteredLocationIndices.enumerated()),
+ id: \.element
+ ) {
+ index,
+ indexInLocationList in
+ let location = $locations[indexInLocationList]
+ LocationListItem(
+ location: location,
+ multihopContext: multihopContext,
+ onSelect: onSelectLocation,
+ contextMenu: { location in contextMenu(location) }
+ )
+ }
+ }
+}
+
+#Preview {
+ @Previewable @StateObject var viewModel = MockSelectLocationViewModel()
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ LocationsListView(
+ locations: $viewModel.exitContext.customLists,
+ multihopContext: .exit,
+ onSelectLocation: { location in
+ print("Selected: \(location.name)")
+ },
+ contextMenu: { location in Text("Add \(location.name) to list") }
+ )
+ .padding(.horizontal)
+ }
+ }
+ .background(Color.mullvadBackground)
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/RelayItemView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/RelayItemView.swift
new file mode 100644
index 0000000000..2fe9f0b22d
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/RelayItemView.swift
@@ -0,0 +1,106 @@
+import SwiftUI
+
+struct RelayItemView: View {
+ let location: LocationNode
+ let multihopContext: MultihopContext
+ let level: Int
+ var isLastInList = true
+ let onSelect: () -> Void
+
+ var disabled: Bool {
+ !location.isActive || location.isExcluded
+ }
+
+ var subtitle: LocalizedStringKey? {
+ if location.isConnected && !location.isSelected {
+ return "Connected server"
+ }
+ return nil
+ }
+
+ var title: String {
+ if location.isExcluded {
+ switch multihopContext {
+ case .entry:
+ return """
+ \(location.name) (\(String(localized:
+ String
+ .LocalizationValue(MultihopContext.exit.description))))
+ """
+ case .exit:
+ return """
+ \(location.name) (\(String(localized:
+ String
+ .LocalizationValue(MultihopContext.entry.description))))
+ """
+ }
+ }
+ return "\(location.name)"
+ }
+
+ var body: some View {
+ Button {
+ onSelect()
+ } label: {
+ HStack {
+ locationStatusIndicator()
+ VStack(alignment: .leading) {
+ Text(title)
+ .font(.mullvadSmallSemiBold)
+ .foregroundStyle(
+ disabled
+ ? Color.mullvadTextPrimaryDisabled
+ : location.isSelected
+ ? Color.mullvadSuccessColor
+ : Color.mullvadTextPrimary
+ )
+ if let subtitle {
+ Text(subtitle)
+ .font(.mullvadMiniSemiBold)
+ .foregroundStyle(Color.mullvadTextPrimary.opacity(0.6))
+ }
+ }
+ Spacer()
+ }
+ .padding(.vertical, subtitle != nil ? 8 : 16)
+ .padding(.horizontal, CGFloat(16 * (level + 1)))
+ .background {
+ Color.colorForLevel(level)
+ }
+ }
+ .disabled(disabled)
+ .clipShape(
+ UnevenRoundedRectangle(
+ cornerRadii: .init(
+ topLeading: level == 0 ? 16 : 0,
+ bottomLeading: isLastInList ? 16 : 0,
+ bottomTrailing: isLastInList ? 16 : 0,
+ topTrailing: level == 0 ? 16 : 0
+ )
+ )
+ )
+
+ }
+
+ @ViewBuilder
+ func locationStatusIndicator() -> some View {
+ if !location.isActive {
+ Image.mullvadRedDot
+ } else if location.isSelected {
+ Image.mullvadIconTick
+ .foregroundStyle(Color.mullvadSuccessColor)
+ }
+ }
+}
+
+#Preview {
+ RelayItemView(
+ location: LocationNode(
+ name: "A great location",
+ code: "a-great-location"
+ ),
+ multihopContext: .exit,
+ level: 0,
+ onSelect: {}
+ )
+}
diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift
new file mode 100644
index 0000000000..b3c1088ee9
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/SelectLocationView.swift
@@ -0,0 +1,94 @@
+import MullvadTypes
+import SwiftUI
+
+struct SelectLocationView<ViewModel>: View where ViewModel: SelectLocationViewModel {
+ @ObservedObject var viewModel: ViewModel
+
+ var showSearchField: Bool { !viewModel.showDAITAInfo || viewModel.multihopContext == .exit }
+
+ var body: some View {
+ VStack(spacing: 16) {
+ if viewModel.isMultihopEnabled {
+ SegmentedControl(
+ segments: MultihopContext.allCases,
+ selectedSegment: $viewModel.multihopContext
+ )
+ .padding(.horizontal)
+ }
+ if showSearchField {
+ MullvadSecondaryTextField(
+ placeholder: "Search for locations or servers...",
+ text: $viewModel.searchText
+ )
+ .padding(.horizontal)
+ .animation(.default, value: showSearchField)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ switch viewModel.multihopContext {
+ case .exit:
+ ExitLocationView(
+ viewModel: viewModel,
+ context: $viewModel.exitContext
+ )
+ .transition(
+ .move(edge: .trailing).combined(with: .opacity)
+ )
+ .geometryGroup()
+ case .entry:
+ EntryLocationView(viewModel: viewModel)
+ .transition(
+ .move(edge: .leading).combined(with: .opacity)
+ )
+ .geometryGroup()
+ }
+ }
+ .animation(.default, value: viewModel.multihopContext)
+ .background(Color.mullvadBackground)
+ .navigationTitle("Select location")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(
+ placement: .topBarTrailing,
+ content: {
+ Button("Done") {
+ viewModel.didFinish()
+ }
+ .foregroundStyle(Color.mullvadTextPrimary)
+ .accessibilityIdentifier(.closeSelectLocationButton)
+ }
+ )
+ ToolbarItem(
+ placement: .topBarLeading,
+ content: {
+ Menu {
+ Button {
+ viewModel.showFilterView()
+ } label: {
+ HStack {
+ Image(systemName: "line.3.horizontal.decrease")
+ Text("Filters")
+ }
+ .foregroundStyle(Color.mullvadTextPrimary)
+ }
+ .accessibilityIdentifier(.selectLocationFilterButton)
+ } label: {
+ Image(systemName: "ellipsis.circle.fill")
+ .foregroundStyle(Color.mullvadTextPrimary)
+ .accessibilityIdentifier(.selectLocationToolbarMenu)
+ }
+ }
+ )
+ }
+ }
+}
+
+#Preview {
+ Text("")
+ .sheet(isPresented: .constant(true)) {
+ NavigationView {
+ SelectLocationView(
+ viewModel: MockSelectLocationViewModel()
+ )
+ }
+ }
+}
diff --git a/ios/MullvadVPN/Views/MullvadAlert.swift b/ios/MullvadVPN/Views/MullvadAlert.swift
index 1aecec8908..88c862b95c 100644
--- a/ios/MullvadVPN/Views/MullvadAlert.swift
+++ b/ios/MullvadVPN/Views/MullvadAlert.swift
@@ -25,6 +25,22 @@ struct MullvadAlert: Identifiable {
let dismissButtonTitle: LocalizedStringKey
}
+struct MullvadInputAlert: Identifiable {
+ struct Action {
+ let type: MainButtonStyle.Style
+ let title: LocalizedStringKey
+ let identifier: AccessibilityIdentifier?
+ let handler: (String) async -> Void
+ }
+
+ let id = UUID()
+ let title: LocalizedStringKey
+ let placeholder: LocalizedStringKey
+ let action: Action
+ let validate: ((String) -> Bool)?
+ let dismissButtonTitle: LocalizedStringKey
+}
+
struct AlertModifier: ViewModifier {
@Binding var alert: MullvadAlert?
@State var loading = false
@@ -117,10 +133,77 @@ struct AlertModifier: ViewModifier {
}
}
+struct InputAlertModifier: ViewModifier {
+ @Binding var alert: MullvadInputAlert?
+ @State var loading = false
+ @State var text = ""
+
+ func body(content: Content) -> some View {
+ content
+ .fullScreenCover(item: $alert) { alert in
+ VStack {
+ Spacer()
+ VStack(alignment: .leading, spacing: 16) {
+ Text(alert.title)
+ .font(.mullvadLarge)
+ .foregroundStyle(Color.mullvadTextPrimary)
+ .lineLimit(nil)
+ .fixedSize(horizontal: false, vertical: true)
+ MullvadPrimaryTextField(
+ label: "",
+ placeholder: alert.placeholder,
+ text: $text,
+ isFocused: .constant(true),
+ validate: alert.validate
+ )
+ VStack(spacing: 16) {
+ MainButton(
+ text: alert.action.title,
+ style: alert.action.type,
+ action: {
+ Task {
+ loading = true
+ await alert.action.handler(text)
+ loading = false
+ }
+ }
+ )
+ .disabled(!(alert.validate?(text) ?? true))
+ .accessibilityIdentifier(alert.action.identifier)
+ MainButton(
+ text: alert.dismissButtonTitle,
+ style: .default,
+ action: { self.alert = nil }
+ )
+ }
+ }
+ .padding()
+ .background(Color.mullvadBackground)
+ .cornerRadius(8)
+ Spacer()
+ }
+ .onAppear {
+ text = ""
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier(.alertContainerView)
+ .padding()
+ .background(ClearBackgroundView())
+ }
+ .transaction {
+ $0.disablesAnimations = true
+ }
+ }
+}
+
extension View {
func mullvadAlert(item: Binding<MullvadAlert?>) -> some View {
modifier(AlertModifier(alert: item))
}
+
+ func mullvadInputAlert(item: Binding<MullvadInputAlert?>) -> some View {
+ modifier(InputAlertModifier(alert: item))
+ }
}
#Preview {
@@ -142,3 +225,24 @@ extension View {
)
)
}
+
+#Preview("Input") {
+ Text("Hello, World!")
+ .mullvadInputAlert(
+ item:
+ .constant(
+ .init(
+ title: "Title",
+ placeholder: "Placeholder",
+ action: .init(
+ type: .default,
+ title: "Do it!",
+ identifier: nil,
+ handler: { _ in }
+ ),
+ validate: nil,
+ dismissButtonTitle: "Cancel"
+ )
+ )
+ )
+}
diff --git a/ios/MullvadVPN/Views/MullvadListSectionHeader.swift b/ios/MullvadVPN/Views/MullvadListSectionHeader.swift
new file mode 100644
index 0000000000..86d951a099
--- /dev/null
+++ b/ios/MullvadVPN/Views/MullvadListSectionHeader.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+struct MullvadListSectionHeader: View {
+ let title: LocalizedStringKey
+
+ var body: some View {
+ HStack {
+ Text(title)
+ .font(.mullvadTiny)
+ .foregroundStyle(Color.mullvadTextPrimary)
+ .layoutPriority(1)
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(Color.mullvadTextPrimary.opacity(0.2))
+ }
+ .frame(minHeight: 44, alignment: .center)
+ }
+}
+
+#Preview {
+ MullvadListSectionHeader(title: "Custom lists")
+}
diff --git a/ios/MullvadVPN/Views/MullvadPrimaryTextField.swift b/ios/MullvadVPN/Views/MullvadPrimaryTextField.swift
index 2af39bf158..ba82566483 100644
--- a/ios/MullvadVPN/Views/MullvadPrimaryTextField.swift
+++ b/ios/MullvadVPN/Views/MullvadPrimaryTextField.swift
@@ -1,17 +1,19 @@
import SwiftUI
struct MullvadPrimaryTextField: View {
- private let label: String
- private let placeholder: String
+ private let label: LocalizedStringKey
+ private let placeholder: LocalizedStringKey
@Binding private var text: String
@Binding private var suggestion: String?
private let validate: ((String) -> Bool)?
private let keyboardType: UIKeyboardType?
+ @Binding private var isFocused: Bool
init(
- label: String,
- placeholder: String,
+ label: LocalizedStringKey,
+ placeholder: LocalizedStringKey,
text: Binding<String>,
+ isFocused: Binding<Bool>? = nil,
suggestion: Binding<String?>? = nil,
validate: ((String) -> Bool)? = nil,
keyboardType: UIKeyboardType? = nil
@@ -19,16 +21,22 @@ struct MullvadPrimaryTextField: View {
self.label = label
self.placeholder = placeholder
self._text = text
+ self._isFocused = isFocused ?? .constant(false)
self._suggestion = suggestion ?? .constant(nil)
self.validate = validate
self.keyboardType = keyboardType
}
+ @State private var hasHadInput = false
+
var isValid: Bool {
- validate?(text) ?? true
+ if !hasHadInput {
+ return true
+ }
+ return validate?(text) ?? true
}
- @FocusState private var isFocused: Bool
+ @FocusState private var isFocusedInner: Bool
@Environment(\.isEnabled) private var isEnabled
private var showSuggestion: Bool {
@@ -51,8 +59,26 @@ struct MullvadPrimaryTextField: View {
isEnabled ? .MullvadTextField.inputPlaceholder : .MullvadTextField.textDisabled
)
)
- .focused($isFocused)
+ .focused($isFocusedInner)
.padding(.vertical, 12)
+ .onAppear {
+ isFocusedInner = isFocused
+ }
+ .onChange(of: isFocused) {
+ if isFocused != isFocusedInner {
+ isFocusedInner = isFocused
+ }
+ }
+ .onChange(of: isFocusedInner) {
+ if isFocusedInner != isFocused {
+ isFocused = isFocusedInner
+ }
+ }
+ .onChange(of: text) {
+ if !text.isEmpty {
+ hasHadInput = true
+ }
+ }
}
var body: some View {
@@ -229,8 +255,8 @@ class UIMullvadPrimaryTextField: UIHostingController<UIMullvadPrimaryTextField.W
}
struct Wrapper: View {
- let label: String
- let placeholder: String
+ let label: LocalizedStringKey
+ let placeholder: LocalizedStringKey
@State var text = ""
@State var suggestion: String?
let validate: ((String) -> Bool)?
@@ -258,8 +284,8 @@ class UIMullvadPrimaryTextField: UIHostingController<UIMullvadPrimaryTextField.W
}
init(
- label: String,
- placeholder: String,
+ label: LocalizedStringKey,
+ placeholder: LocalizedStringKey,
validate: ((String) -> Bool)? = nil,
contentType: UITextContentType? = nil,
keyboardType: UIKeyboardType = .default
diff --git a/ios/MullvadVPN/Views/SegmentedControl.swift b/ios/MullvadVPN/Views/SegmentedControl.swift
index 86bf4bccf0..509620e60b 100644
--- a/ios/MullvadVPN/Views/SegmentedControl.swift
+++ b/ios/MullvadVPN/Views/SegmentedControl.swift
@@ -8,70 +8,60 @@
import SwiftUI
-class SegmentedControlViewModel: ObservableObject {
- @Published var selectedSegmentIndex = 0
-}
-
-struct SegmentedControl<Segment: StringProtocol>: View {
- var segments: [Segment]
- @ObservedObject var viewModel: SegmentedControlViewModel
- public var onSelectedSegment: ((Int) -> Void)?
-
- func isSelected(segment: Segment) -> Bool {
- viewModel.selectedSegmentIndex == segments.firstIndex(of: segment)
- }
-
+struct SegmentedControl<Segment>: View where Segment: CustomStringConvertible, Segment: Hashable {
+ let segments: [Segment]
+ @Binding var selectedSegment: Segment
+ @State private var id: UUID = .init()
+ @Namespace var animation
var body: some View {
- GeometryReader { proxy in
- HStack(spacing: 0) {
- ForEach(segments, id: \.self) { segment in
- // The segments are expected to be already localised
- Text(segment)
- .font(.mullvadSmallSemiBold)
+ HStack(spacing: 0) {
+ ForEach(segments, id: \.self) { segment in
+ // The segments are expected to be already localised
+ Button {
+ withAnimation {
+ selectedSegment = segment
+ }
+ } label: {
+ Text(segment.description)
+ .padding(5)
+ .font(.mullvadTinySemiBold)
.foregroundStyle(.white)
.frame(maxWidth: .infinity) // Makes the text take all the available space
.contentShape(Rectangle()) // Makes the tappable area extend beyond just the text
- .onTapGesture {
- withAnimation(.easeInOut(duration: 0.25)) {
- viewModel.selectedSegmentIndex = segments.firstIndex(of: segment)!
- onSelectedSegment?(viewModel.selectedSegmentIndex)
- }
- }
.background(
Group {
- if isSelected(segment: segment) {
+ if segment == selectedSegment {
Capsule()
.fill(UIColor.SegmentedControl.selectedColor.color)
- .frame(height: 36)
+ .matchedGeometryEffect(id: id, in: animation)
} else {
Capsule()
- .fill(UIColor.SegmentedControl.backgroundColor.color)
- .frame(height: 36)
+ .fill(.clear)
}
}
)
+ .frame(maxWidth: .infinity)
}
+ .disabled(segment == selectedSegment)
}
- .padding([.leading, .trailing], 4) // Insets the inner shape to not overlay with the outer one
- .frame(maxWidth: .infinity, maxHeight: proxy.size.height)
- .background(
- Capsule(style: .circular)
- .fill(UIColor.SegmentedControl.backgroundColor.color)
- )
- .clipShape(Capsule())
}
+ .padding(4)
+ .background {
+ Capsule(style: .circular)
+ .fill(UIColor.SegmentedControl.backgroundColor.color)
+ }
+ .frame(minHeight: 44)
}
}
#Preview {
+ @Previewable @State var selectedSegment = "Exit"
VStack {
Spacer()
SegmentedControl(
segments: ["Entry", "Exit"],
- viewModel: SegmentedControlViewModel(),
- onSelectedSegment: { newIndex in print("Selected \(newIndex)") }
+ selectedSegment: $selectedSegment
)
- .frame(height: 44)
Spacer()
}
.background(Color.mullvadBackground)
diff --git a/ios/MullvadVPNTests/MullvadSettings/CustomListsRepositoryStub.swift b/ios/MullvadVPNTests/MullvadSettings/CustomListsRepositoryStub.swift
index 6055c20338..8b3fba26e3 100644
--- a/ios/MullvadVPNTests/MullvadSettings/CustomListsRepositoryStub.swift
+++ b/ios/MullvadVPNTests/MullvadSettings/CustomListsRepositoryStub.swift
@@ -10,15 +10,24 @@ import Combine
import MullvadSettings
import MullvadTypes
-struct CustomListsRepositoryStub: CustomListRepositoryProtocol {
- let customLists: [CustomList]
+class CustomListsRepositoryStub: CustomListRepositoryProtocol {
+ var customLists: [CustomList]
- func save(list: CustomList) throws {}
+ init(customLists: [CustomList] = []) {
+ self.customLists = customLists
+ }
+
+ func save(list: CustomList) throws {
+ delete(id: list.id)
+ customLists.append(list)
+ }
- func delete(id: UUID) {}
+ func delete(id: UUID) {
+ customLists.removeAll { $0.id == id }
+ }
func fetch(by id: UUID) -> CustomList? {
- nil
+ customLists.first { $0.id == id }
}
func fetchAll() -> [CustomList] {
diff --git a/ios/MullvadVPNTests/MullvadVPN/Interactors/CustomListInteractorTests.swift b/ios/MullvadVPNTests/MullvadVPN/Interactors/CustomListInteractorTests.swift
new file mode 100644
index 0000000000..dd383cd01f
--- /dev/null
+++ b/ios/MullvadVPNTests/MullvadVPN/Interactors/CustomListInteractorTests.swift
@@ -0,0 +1,295 @@
+import Testing
+
+@testable import MullvadMockData
+@testable import MullvadREST
+@testable import MullvadRustRuntime
+@testable import MullvadSettings
+@testable import MullvadTypes
+@testable import WireGuardKitTypes
+
+struct CustomListInteractorTests {
+ static let store = InMemorySettingsStore<SettingNotFound>()
+
+ init() {
+ SettingsManager.unitTestStore = CustomListInteractorTests.store
+ }
+
+ private func makeDependencies() -> (
+ customListInteractor: CustomListInteractor,
+ tunnelManager: SettingsUpdatingMock
+ ) {
+ let tunnelManager = SettingsUpdatingMock()
+ let customListInteractor = CustomListInteractor(
+ tunnelManager: tunnelManager,
+ repository: CustomListsRepositoryStub()
+ )
+
+ return (customListInteractor, tunnelManager)
+ }
+
+ @Test(
+ "Adds custom list to repository"
+ )
+ func addCustomList() throws {
+ let (customListInteractor, _) = makeDependencies()
+ let customList = CustomList(name: "MyCustomList", locations: [])
+ try? customListInteractor.save(list: customList)
+
+ #expect(customListInteractor.fetch(by: customList.id) != nil)
+ }
+
+ @Test(
+ "Add location to custom list"
+ )
+ func addLocationToCustomList() throws {
+ let (customListInteractor, _) = makeDependencies()
+ let customList = CustomList(name: "MyCustomList", locations: [])
+ try? customListInteractor.save(list: customList)
+ let location1 = RelayLocation.country("se")
+ #expect(customListInteractor.fetch(by: customList.id)?.locations.isEmpty == true)
+
+ try customListInteractor.addLocationToCustomList(relayLocations: [location1], customListName: customList.name)
+
+ #expect(
+ customListInteractor.fetch(by: customList.id)?.locations.first == location1
+ )
+ }
+
+ @Test(
+ "Custom list should not allow duplicate locations"
+ )
+ func doNotAddDuplicateLocations() throws {
+ let (customListInteractor, _) = makeDependencies()
+ let location1 = RelayLocation.country("se")
+ let customList = CustomList(name: "MyCustomList", locations: [location1])
+ try? customListInteractor.save(list: customList)
+
+ #expect(
+ customListInteractor.fetch(by: customList.id)?.locations.count == 1
+ )
+
+ try customListInteractor.addLocationToCustomList(relayLocations: [location1], customListName: customList.name)
+
+ #expect(
+ customListInteractor.fetch(by: customList.id)?.locations.count == 1
+ )
+ }
+
+ @Test(
+ "Removes a child location it the parent gets added to a custom list"
+ )
+ func removeChildIfParentGetsAdded() throws {
+ let (customListInteractor, _) = makeDependencies()
+ let childLocation = RelayLocation.city("se", "got")
+ let customList = CustomList(name: "MyCustomList", locations: [childLocation])
+ try? customListInteractor.save(list: customList)
+
+ let parentLocation = RelayLocation.country("se")
+
+ try customListInteractor.addLocationToCustomList(
+ relayLocations: [parentLocation],
+ customListName: customList.name)
+
+ #expect(
+ customListInteractor.fetch(by: customList.id)?.locations.count == 1
+ )
+ #expect(
+ customListInteractor.fetch(by: customList.id)?.locations.first == parentLocation
+ )
+ }
+
+ @Test(
+ "Remove location from custom list"
+ )
+ func removeLocation() throws {
+ let (customListInteractor, _) = makeDependencies()
+ let location1 = RelayLocation.country("se")
+ let customList = CustomList(name: "MyCustomList", locations: [location1])
+ try? customListInteractor.save(list: customList)
+
+ try customListInteractor.removeLocationFromCustomList(
+ relayLocations: [location1],
+ customListName: customList.name)
+
+ #expect(
+ customListInteractor.fetch(by: customList.id)?.locations.count == 0
+ )
+ }
+
+ @Test(
+ "If a list is selected as exit location and the list gets modified, the constraints should update"
+ )
+ func updateConstraintsIfRemovedFromList() async throws {
+ let (customListInteractor, tunnelManager) = makeDependencies()
+ let location1 = RelayLocation.country("se")
+ let customList = CustomList(name: "MyCustomList", locations: [location1])
+
+ let selection = UserSelectedRelays(
+ locations: [location1],
+ customListSelection: .init(listId: customList.id, isList: true)
+ )
+ try? customListInteractor.save(list: customList)
+
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ relayConstraints.exitLocations = .only(
+ selection
+ )
+ tunnelManager
+ .updateSettings(
+ [.relayConstraints(relayConstraints)],
+ completionHandler: nil
+ )
+ #expect(
+ tunnelManager.settings.relayConstraints.exitLocations == .only(selection)
+ )
+ try? customListInteractor
+ .removeLocationFromCustomList(
+ relayLocations: [location1],
+ customListName: customList.name
+ )
+ #expect(tunnelManager.updateCalled)
+ #expect(
+ tunnelManager.settings.relayConstraints.exitLocations
+ == .only(
+ .init(
+ locations: [],
+ customListSelection: selection.customListSelection
+ )
+ )
+ )
+ }
+
+ @Test(
+ "The constraints should not update on custom list change if the list is not selected"
+ )
+ func doNotUpdateConstraintsIfSelectionNotAffected() async throws {
+ let (customListInteractor, tunnelManager) = makeDependencies()
+ let location1 = RelayLocation.country("se")
+ let customList1 = CustomList(name: "MyCustomList1", locations: [location1])
+ let customList2 = CustomList(name: "MyCustomList2", locations: [location1])
+
+ try? customListInteractor.save(list: customList1)
+ try? customListInteractor.save(list: customList2)
+
+ let selection = UserSelectedRelays(
+ locations: [location1],
+ customListSelection: .init(listId: customList1.id, isList: true)
+ )
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ relayConstraints.exitLocations = .only(
+ selection
+ )
+ tunnelManager.updateSettings(
+ [.relayConstraints(relayConstraints)],
+ completionHandler: nil
+ )
+ #expect(
+ tunnelManager.settings.relayConstraints.exitLocations == .only(selection)
+ )
+ tunnelManager.updateCalled = false
+
+ try? customListInteractor
+ .removeLocationFromCustomList(
+ relayLocations: [location1],
+ customListName: customList2.name
+ )
+ #expect(
+ tunnelManager.updateCalled == false
+ )
+ #expect(
+ tunnelManager.settings.relayConstraints.exitLocations == .only(selection)
+ )
+ }
+
+ @Test(
+ "Removes the constraint when a custom list is removed"
+ )
+ func removeConstraintIfListRemoved() async throws {
+ let (customListInteractor, tunnelManager) = makeDependencies()
+ let location1 = RelayLocation.country("se")
+ let customList1 = CustomList(name: "MyCustomList1", locations: [location1])
+
+ try? customListInteractor.save(list: customList1)
+
+ let selection = UserSelectedRelays(
+ locations: [location1],
+ customListSelection: .init(listId: customList1.id, isList: true)
+ )
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ relayConstraints.exitLocations = .only(
+ selection
+ )
+ tunnelManager.updateSettings(
+ [.relayConstraints(relayConstraints)],
+ completionHandler: nil
+ )
+ #expect(
+ tunnelManager.settings.relayConstraints.exitLocations == .only(selection)
+ )
+ tunnelManager.updateCalled = false
+
+ customListInteractor
+ .delete(customList: customList1)
+
+ #expect(
+ tunnelManager.updateCalled == true
+ )
+ #expect(
+ tunnelManager.settings.relayConstraints.exitLocations == .only(.init(locations: []))
+ )
+ }
+
+ @Test(
+ "Removes the constraint when a location inside a custom list is removed"
+ )
+ func removeConstraintIfLocationRemoved() async throws {
+ let (customListInteractor, tunnelManager) = makeDependencies()
+ let location1 = RelayLocation.country("se")
+ let location2 = RelayLocation.country("es")
+ let customList1 = CustomList(name: "MyCustomList1", locations: [location1, location2])
+
+ try? customListInteractor.save(list: customList1)
+
+ let selection = UserSelectedRelays(
+ locations: [location1],
+ customListSelection: .init(listId: customList1.id, isList: false)
+ )
+ var relayConstraints = tunnelManager.settings.relayConstraints
+ relayConstraints.exitLocations = .only(
+ selection
+ )
+ tunnelManager.updateSettings(
+ [.relayConstraints(relayConstraints)],
+ completionHandler: nil
+ )
+ #expect(
+ tunnelManager.settings.relayConstraints.exitLocations == .only(selection)
+ )
+ tunnelManager.updateCalled = false
+
+ try? customListInteractor
+ .removeLocationFromCustomList(relayLocations: [location1], customListName: customList1.name)
+
+ #expect(
+ tunnelManager.updateCalled == true
+ )
+ #expect(
+ tunnelManager.settings.relayConstraints.exitLocations == .only(.init(locations: []))
+ )
+ }
+}
+
+private class SettingsUpdatingMock: SettingsUpdating {
+ var updateCalled = false
+ func updateSettings(
+ _ updates: [MullvadSettings.TunnelSettingsUpdate],
+ completionHandler: (@Sendable () -> Void)?
+ ) {
+ for update in updates {
+ update.apply(to: &settings)
+ }
+ updateCalled = true
+ }
+
+ var settings = LatestTunnelSettings()
+}
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
index 3d3a356dcf..fcfd6af20b 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/AllLocationsDataSourceTests.swift
@@ -7,6 +7,7 @@
//
import MullvadMockData
+import MullvadTypes
import XCTest
@testable import MullvadSettings
@@ -29,32 +30,156 @@ class AllLocationsDataSourceTests: XCTestCase {
XCTAssertNotNil(rootNode.descendantNodeFor(codes: ["se2-wireguard"]))
}
- func testSearch() throws {
- let nodes = dataSource.search(by: "got")
- let rootNode = RootLocationNode(children: nodes)
+ func testSearchCity() throws {
+ dataSource.search(by: "got")
+ let rootNode = RootLocationNode(children: dataSource.nodes)
XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se", "got"])?.isHiddenFromSearch == false)
XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se", "sto"])?.isHiddenFromSearch == true)
}
+ func testSearchShowsParentsAndChildrenIfBothMatch() throws {
+ dataSource.search(by: "se")
+ let rootNode = RootLocationNode(children: dataSource.nodes)
+
+ XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se"])?.isHiddenFromSearch == false)
+ XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se", "got"])?.isHiddenFromSearch == false)
+ XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se10-wireguard"])?.isHiddenFromSearch == false)
+ XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se", "sto"])?.isHiddenFromSearch == false)
+ XCTAssertTrue(rootNode.descendantNodeFor(codes: ["se2-wireguard"])?.isHiddenFromSearch == false)
+ }
+
+ func testSearchCityExpandsParents() throws {
+ dataSource.search(by: "Sweden")
+ let rootNode = RootLocationNode(children: dataSource.nodes)
+ let node = rootNode.descendantNodeFor(codes: ["se"])!
+
+ node.forEachAncestor { location in
+ XCTAssertFalse(location.isHiddenFromSearch)
+ XCTAssertTrue(location.showsChildren)
+ }
+ XCTAssertFalse(node.isHiddenFromSearch)
+ XCTAssertFalse(node.showsChildren)
+ }
+
+ func testSearchCityIncludesChildren() throws {
+ dataSource.search(by: "Sweden")
+ let rootNode = RootLocationNode(children: dataSource.nodes)
+ let node = rootNode.descendantNodeFor(codes: ["se"])!
+
+ node.forEachDescendant { child in
+ XCTAssertFalse(child.isHiddenFromSearch)
+ XCTAssertFalse(child.showsChildren)
+ }
+ XCTAssertFalse(node.isHiddenFromSearch)
+ XCTAssertFalse(node.showsChildren)
+ }
+
func testSearchWithEmptyText() throws {
- let nodes = dataSource.search(by: "")
- XCTAssertEqual(nodes, dataSource.nodes)
+ dataSource.search(by: "")
+ dataSource.nodes.forEachNode {
+ XCTAssertFalse($0.isHiddenFromSearch)
+ }
}
func testNodeByLocation() throws {
- var nodeByLocation = dataSource.node(by: .country("es"))
+ var nodeByLocation = dataSource.node(by: .init(locations: [.country("es")]))
var nodeByCode = dataSource.nodes.first?.descendantNodeFor(codes: ["es"])
XCTAssertEqual(nodeByLocation, nodeByCode)
- nodeByLocation = dataSource.node(by: .city("es", "mad"))
+ nodeByLocation = dataSource.node(by: .init(locations: [.city("es", "mad")]))
nodeByCode = dataSource.nodes.first?.descendantNodeFor(codes: ["es", "mad"])
XCTAssertEqual(nodeByLocation, nodeByCode)
- nodeByLocation = dataSource.node(by: .hostname("es", "mad", "es1-wireguard"))
+ nodeByLocation = dataSource.node(by: .init(locations: [.hostname("es", "mad", "es1-wireguard")]))
nodeByCode = dataSource.nodes.first?.descendantNodeFor(codes: ["es1-wireguard"])
XCTAssertEqual(nodeByLocation, nodeByCode)
}
+
+ func testConnectedNode() throws {
+ let hostname = "es1-wireguard"
+ dataSource.setConnectedRelay(hostname: hostname)
+ dataSource.nodes.forEachNode { node in
+ XCTAssertEqual(node.isConnected, node.name == hostname)
+ }
+
+ dataSource.setConnectedRelay(hostname: "invalid-hostname")
+ dataSource.nodes.forEachNode { node in
+ XCTAssertFalse(node.isConnected)
+ }
+ }
+
+ func testSetSelectedLocation() throws {
+ dataSource.setSelectedNode(selectedRelays: .init(locations: [.country("es")]))
+
+ dataSource.nodes.forEachNode { node in
+ if node.locations == [.country("es")] {
+ XCTAssertTrue(node.isSelected)
+ } else {
+ XCTAssertFalse(node.isSelected)
+ }
+ }
+
+ dataSource
+ .setSelectedNode(
+ selectedRelays: .init(locations: [.country("invalid")])
+ )
+ dataSource.nodes.forEachNode { node in
+ XCTAssertFalse(node.isSelected)
+ }
+ }
+
+ func testDoNotSetSelectedCustomListLocation() throws {
+ let selectedRelays: UserSelectedRelays = .init(
+ locations: [
+ .country("es")
+ ],
+ customListSelection: .init(listId: .init(), isList: false)
+ )
+
+ dataSource.setSelectedNode(selectedRelays: selectedRelays)
+
+ dataSource.nodes.forEachNode { node in
+ XCTAssertFalse(node.isSelected)
+ }
+ }
+
+ func testExcludeLocation() throws {
+ let excludedRelays = UserSelectedRelays(locations: [.hostname("se", "sto", "se2-wireguard")])
+ dataSource.setExcludedNode(excludedSelection: excludedRelays)
+ let excludedNode = dataSource.node(by: excludedRelays)!
+
+ XCTAssertTrue(excludedNode.isExcluded)
+
+ excludedNode.forEachAncestor { ancestor in
+ XCTAssertFalse(ancestor.isExcluded)
+ }
+
+ let includedNode = dataSource.node(by: .init(locations: [.country("es")]))!
+ XCTAssertFalse(includedNode.isExcluded)
+ includedNode.forEachDescendant { child in
+ XCTAssertFalse(child.isExcluded)
+ }
+ }
+
+ func testExcludeLocationIncludesAncestors() throws {
+ let excludedRelays = UserSelectedRelays(locations: [.hostname("es", "mad", "es1-wireguard")])
+ dataSource.setExcludedNode(excludedSelection: excludedRelays)
+ let excludedNode = dataSource.node(by: excludedRelays)!
+
+ XCTAssertTrue(excludedNode.isExcluded)
+
+ // All ancestors are exluded when single child is excluded
+ excludedNode.forEachAncestor { ancestor in
+ XCTAssertTrue(ancestor.isExcluded)
+ }
+
+ let includedNode = dataSource.node(by: .init(locations: [.country("se")]))!
+ XCTAssertFalse(includedNode.isExcluded)
+ includedNode.forEachDescendant { child in
+ XCTAssertFalse(child.isExcluded)
+ }
+ }
}
extension AllLocationsDataSourceTests {
diff --git a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
index a2d619b1ca..e9946d8c7b 100644
--- a/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
+++ b/ios/MullvadVPNTests/MullvadVPN/View controllers/SelectLocation/CustomListsDataSourceTests.swift
@@ -47,31 +47,84 @@ class CustomListsDataSourceTests: XCTestCase {
}
func testSearch() throws {
- let nodes = dataSource.search(by: "got")
- let rootNode = RootLocationNode(children: nodes)
+ dataSource.search(by: "got")
+ let rootNode = RootLocationNode(children: dataSource.nodes)
XCTAssertTrue(rootNode.descendantNodeFor(codes: ["Netflix", "se", "got"])?.isHiddenFromSearch == false)
XCTAssertTrue(rootNode.descendantNodeFor(codes: ["Netflix", "se", "sto"])?.isHiddenFromSearch == true)
}
func testSearchWithEmptyText() throws {
- let nodes = dataSource.search(by: "")
- XCTAssertEqual(nodes, dataSource.nodes)
+ dataSource.search(by: "")
+ dataSource.nodes.forEachNode {
+ XCTAssertFalse($0.isHiddenFromSearch)
+ }
}
func testSearchYieldsNoListNodes() throws {
- let nodes = dataSource.search(by: "net")
- XCTAssertFalse(nodes.contains(where: { $0.name == "Netflix" }))
+ dataSource.search(by: "net")
+ dataSource.nodes.forEachNode {
+ if $0.name == "Netflix" {
+ XCTAssertFalse($0.isHiddenFromSearch)
+ }
+ }
}
func testNodeByLocations() throws {
- let relays = UserSelectedRelays(locations: [.hostname("es", "mad", "es1-wireguard")], customListSelection: nil)
+ let customListId = (dataSource.nodes.first! as! CustomListLocationNode).customList.id
+ let relays = UserSelectedRelays(
+ locations: [.hostname("es", "mad", "es1-wireguard")],
+ customListSelection: .init(listId: customListId, isList: false)
+ )
- let nodeByLocations = dataSource.node(by: relays, for: customLists.first!)
+ let nodeByLocations = dataSource.node(by: relays)
let nodeByCode = dataSource.nodes.first?.descendantNodeFor(codes: ["Netflix", "es1-wireguard"])
XCTAssertEqual(nodeByLocations, nodeByCode)
}
+
+ func testSetSelection() throws {
+ let customListId = (dataSource.nodes.first! as! CustomListLocationNode).customList.id
+ let userSelectedRelays = UserSelectedRelays(
+ locations: [.country("se")],
+ customListSelection: .init(listId: customListId, isList: false)
+ )
+
+ dataSource
+ .setSelectedNode(
+ selectedRelays: userSelectedRelays
+ )
+
+ dataSource.nodes.forEachNode { node in
+ if node.locations == [.country("se")] {
+ XCTAssertTrue(node.isSelected)
+ } else {
+ XCTAssertFalse(node.isSelected)
+ }
+ }
+
+ dataSource
+ .setSelectedNode(
+ selectedRelays: .init(locations: [.country("invalid")])
+ )
+ dataSource.nodes.forEachNode { node in
+ XCTAssertFalse(node.isSelected)
+ }
+ }
+
+ func testDoNotSetSelectedLocation() throws {
+ let selectedRelays: UserSelectedRelays = .init(
+ locations: [
+ .country("se")
+ ]
+ )
+
+ dataSource.setSelectedNode(selectedRelays: selectedRelays)
+
+ dataSource.nodes.forEachNode { node in
+ XCTAssertFalse(node.isSelected)
+ }
+ }
}
extension CustomListsDataSourceTests {
diff --git a/ios/MullvadVPNUITests/Base/BaseUITestCase.swift b/ios/MullvadVPNUITests/Base/BaseUITestCase.swift
index b7b8283e89..a776d4794b 100644
--- a/ios/MullvadVPNUITests/Base/BaseUITestCase.swift
+++ b/ios/MullvadVPNUITests/Base/BaseUITestCase.swift
@@ -33,6 +33,10 @@ class BaseUITestCase: XCTestCase {
static let testsDefaultCityName = "Gothenburg"
static let testsDefaultCityIdentifier = "se-got"
+ /// Default Mullvad owned relays to use in tests
+ static let testsDefaultMullvadOwnedCityName = "Stockholm"
+ static let testsDefaultMullvadOwnedRelayName = "se-sto-wg-001"
+
/// Default relay to use in tests
static let testsDefaultRelayName = "se-got-wg-001"
diff --git a/ios/MullvadVPNUITests/ConnectivityTests.swift b/ios/MullvadVPNUITests/ConnectivityTests.swift
index e23b9b568a..7ecb5a599b 100644
--- a/ios/MullvadVPNUITests/ConnectivityTests.swift
+++ b/ios/MullvadVPNUITests/ConnectivityTests.swift
@@ -91,6 +91,7 @@ class ConnectivityTests: LoggedOutUITestCase {
.tapSelectLocationButton()
SelectLocationPage(app)
+ .tapMenuButton()
.tapFilterButton()
SelectLocationFilterPage(app)
@@ -99,12 +100,10 @@ class ConnectivityTests: LoggedOutUITestCase {
// Select the first country, its first city and its first relay
SelectLocationPage(app)
- .tapCountryLocationCellExpandButton(
- withName: BaseUITestCase
- .testsDefaultCountryName
- ) // Must be a little specific here in order to avoid using relay services country with experimental relays
- .tapCityLocationCellExpandButton(withIndex: 0)
- .tapRelayLocationCell(withIndex: 0)
+ .tapLocationCellExpandButton(withName: BaseUITestCase.testsDefaultCountryName)
+ // Must be a little specific here in order to avoid using relay services country with experimental relays
+ .tapLocationCellExpandButton(withName: BaseUITestCase.testsDefaultMullvadOwnedCityName)
+ .tapLocationCell(withName: BaseUITestCase.testsDefaultMullvadOwnedRelayName)
allowAddVPNConfigurationsIfAsked()
@@ -112,6 +111,7 @@ class ConnectivityTests: LoggedOutUITestCase {
.tapSelectLocationButton()
SelectLocationPage(app)
+ .tapMenuButton()
.tapFilterButton()
SelectLocationFilterPage(app)
diff --git a/ios/MullvadVPNUITests/CustomListsTests.swift b/ios/MullvadVPNUITests/CustomListsTests.swift
index f387aafe16..1e1f48adec 100644
--- a/ios/MullvadVPNUITests/CustomListsTests.swift
+++ b/ios/MullvadVPNUITests/CustomListsTests.swift
@@ -70,7 +70,10 @@ class CustomListsTests: LoggedInWithTimeUITestCase {
ListCustomListsPage(app)
.tapDoneButton()
- XCTAssertTrue(app.staticTexts[customListName].exists)
+ let customListItem = SelectLocationPage(app)
+ .cellWithIdentifier(identifier: .locationListItem(customListName))
+
+ XCTAssertTrue(customListItem.exists)
}
func testAddSingleLocationToCustomList() throws {
@@ -99,17 +102,16 @@ class CustomListsTests: LoggedInWithTimeUITestCase {
ListCustomListsPage(app)
.tapDoneButton()
- SelectLocationPage(app)
+ let customListLocation = SelectLocationPage(app)
.tapLocationCellExpandButton(withName: customListName)
- let customListLocationName = "\(customListName)-\(BaseUITestCase.testsDefaultRelayName)"
- let customListLocationCell = SelectLocationPage(app).cellWithIdentifier(identifier: customListLocationName)
- XCTAssertTrue(customListLocationCell.exists)
+ .cellWithIdentifier(identifier: .locationListItem(BaseUITestCase.testsDefaultRelayName))
+
+ XCTAssertTrue(customListLocation.exists)
}
func createCustomList(named name: String) {
SelectLocationPage(app)
.tapWhereStatusBarShouldBeToScrollToTopMostPosition()
- .tapCustomListEllipsisButton()
.tapAddNewCustomList()
// When creating a new custom list, the "create" button should be disabled until the list has a name at minimum
@@ -123,7 +125,6 @@ class CustomListsTests: LoggedInWithTimeUITestCase {
func startEditingCustomList(named customListName: String) {
SelectLocationPage(app)
.tapWhereStatusBarShouldBeToScrollToTopMostPosition()
- .tapCustomListEllipsisButton()
.editExistingCustomLists()
ListCustomListsPage(app)
@@ -138,7 +139,6 @@ class CustomListsTests: LoggedInWithTimeUITestCase {
func deleteCustomList(named customListName: String) {
SelectLocationPage(app)
.tapWhereStatusBarShouldBeToScrollToTopMostPosition()
- .tapCustomListEllipsisButton()
.editExistingCustomLists()
ListCustomListsPage(app)
diff --git a/ios/MullvadVPNUITests/Pages/SelectLocationPage.swift b/ios/MullvadVPNUITests/Pages/SelectLocationPage.swift
index b7fed438a4..8c12e7bcff 100644
--- a/ios/MullvadVPNUITests/Pages/SelectLocationPage.swift
+++ b/ios/MullvadVPNUITests/Pages/SelectLocationPage.swift
@@ -13,83 +13,20 @@ class SelectLocationPage: Page {
@discardableResult override init(_ app: XCUIApplication) {
super.init(app)
- self.pageElement = app.otherElements[.selectLocationView]
+ self.pageElement = app.scrollViews[.selectLocationView]
waitForPageToBeShown()
}
@discardableResult func tapLocationCell(withName name: String) -> Self {
- app.tables[AccessibilityIdentifier.selectLocationTableView].cells.staticTexts[name].tap()
- return self
- }
-
- @discardableResult func tapCountryLocationCellExpandButton(withName name: String) -> Self {
- let cell = app.cells.containing(.any, identifier: name)
- let expandButton = cell.buttons[AccessibilityIdentifier.expandButton]
- expandButton.tap()
- return self
- }
-
- @discardableResult func tapCountryLocationCellExpandButton(withIndex: Int) -> Self {
- let cell = app.cells.containing(.any, identifier: AccessibilityIdentifier.countryLocationCell.asString)
- .element(boundBy: withIndex)
- let expandButton = cell.buttons[AccessibilityIdentifier.expandButton]
- expandButton.tap()
- return self
- }
-
- @discardableResult func tapCityLocationCellExpandButton(withIndex: Int) -> Self {
- let cell = app.cells.containing(.any, identifier: AccessibilityIdentifier.cityLocationCell.asString)
- .element(boundBy: withIndex)
- let expandButton = cell.buttons[AccessibilityIdentifier.expandButton]
- expandButton.tap()
- return self
- }
-
- @discardableResult func tapRelayLocationCell(withIndex: Int) -> Self {
- let cell = app.cells.containing(.any, identifier: AccessibilityIdentifier.relayLocationCell.asString)
- .element(boundBy: withIndex)
- cell.tap()
+ app.buttons[AccessibilityIdentifier.locationListItem(name)]
+ .tap()
return self
}
@discardableResult func tapLocationCellExpandButton(withName name: String) -> Self {
- let table = app.tables[AccessibilityIdentifier.selectLocationTableView]
- let matchingCells = table.cells.containing(.any, identifier: name)
- let buttons = matchingCells.buttons
- let expandButton = buttons[AccessibilityIdentifier.expandButton]
-
+ let cell = app.buttons[AccessibilityIdentifier.locationListItem(name)]
+ let expandButton = cell.buttons[AccessibilityIdentifier.expandButton]
expandButton.tap()
-
- return self
- }
-
- @discardableResult func tapLocationCellCollapseButton(withName name: String) -> Self {
- let table = app.tables[AccessibilityIdentifier.selectLocationTableView]
- let matchingCells = table.cells.containing(.any, identifier: name)
- let buttons = matchingCells.buttons
- let collapseButton = buttons[AccessibilityIdentifier.collapseButton]
-
- collapseButton.tap()
-
- return self
- }
-
- @discardableResult func tapCustomListEllipsisButton() -> Self {
- // This wait should not be needed, but is due to the issues we are having with the ellipsis button
- _ = app.buttons[.openCustomListsMenuButton].waitForExistence(timeout: BaseUITestCase.shortTimeout)
-
- let customListEllipsisButtons = app.buttons
- .matching(identifier: AccessibilityIdentifier.openCustomListsMenuButton.asString).allElementsBoundByIndex
-
- // This is a workaround for an issue we have with the ellipsis showing up multiple times in the accessibility hieararchy even though in the view hierarchy there is only one
- // Only the actually visual one is hittable, so only the visible button will be tapped
- for ellipsisButton in customListEllipsisButtons where ellipsisButton.isHittable {
- ellipsisButton.tap()
- return self
- }
-
- XCTFail("Found no hittable custom list ellipsis button")
-
return self
}
@@ -105,12 +42,19 @@ class SelectLocationPage: Page {
return self
}
- @discardableResult func cellWithIdentifier(identifier: String) -> XCUIElement {
- app.tables[AccessibilityIdentifier.selectLocationTableView].cells[identifier]
+ @discardableResult func cellWithIdentifier(identifier: AccessibilityIdentifier) -> XCUIElement {
+ app.buttons[identifier]
}
@discardableResult func tapFilterButton() -> Self {
- app.buttons[AccessibilityIdentifier.selectLocationFilterButton].tap()
+ app.buttons[AccessibilityIdentifier.selectLocationFilterButton]
+ .firstMatch
+ .tap()
+ return self
+ }
+
+ @discardableResult func tapMenuButton() -> Self {
+ app.images[AccessibilityIdentifier.selectLocationToolbarMenu].tap()
return self
}
diff --git a/ios/MullvadVPNUITests/Screenshots/ScreenshotTests.swift b/ios/MullvadVPNUITests/Screenshots/ScreenshotTests.swift
index 2b7a51cb9b..7f41859126 100644
--- a/ios/MullvadVPNUITests/Screenshots/ScreenshotTests.swift
+++ b/ios/MullvadVPNUITests/Screenshots/ScreenshotTests.swift
@@ -49,7 +49,6 @@ class ScreenshotTests: LoggedInWithTimeUITestCase {
SelectLocationPage(app)
.tapWhereStatusBarShouldBeToScrollToTopMostPosition()
- .tapCustomListEllipsisButton()
.tapAddNewCustomList()
CustomListPage(app)
@@ -88,6 +87,7 @@ class ScreenshotTests: LoggedInWithTimeUITestCase {
.tapSelectLocationButton()
SelectLocationPage(app)
+ .tapMenuButton()
.tapFilterButton()
snapshot("RelayFilter")
diff --git a/ios/MullvadVPNUITests/SelectLocationTests.swift b/ios/MullvadVPNUITests/SelectLocationTests.swift
index 71fc345f1b..659aaf3eba 100644
--- a/ios/MullvadVPNUITests/SelectLocationTests.swift
+++ b/ios/MullvadVPNUITests/SelectLocationTests.swift
@@ -28,7 +28,7 @@ class SelectLocationTests: LoggedInWithTimeUITestCase {
TunnelControlPage(app)
.tapSelectLocationButton()
- XCTAssertTrue(app.staticTexts[AccessibilityIdentifier.daitaFilterPill.asString].exists)
+ XCTAssertTrue(app.buttons[AccessibilityIdentifier.daitaFilterPill.asString].exists)
}
func testEnableShadowsocksObfuscation() {
@@ -49,6 +49,6 @@ class SelectLocationTests: LoggedInWithTimeUITestCase {
TunnelControlPage(app)
.tapSelectLocationButton()
- XCTAssertTrue(app.staticTexts[AccessibilityIdentifier.obfuscationFilterPill.asString].exists)
+ XCTAssertTrue(app.buttons[AccessibilityIdentifier.obfuscationFilterPill.asString].exists)
}
}