diff options
| author | Steffen <steffen.ernst@mullvad.net> | 2025-10-30 14:08:16 +0100 |
|---|---|---|
| committer | Steffen <steffen.ernst@mullvad.net> | 2025-11-21 14:43:39 +0100 |
| commit | 7921176436500797c2c149619f8e6f63ce0fb212 (patch) | |
| tree | 5e1210318bc367b4e6abb47b8395e0138a98a9ff | |
| parent | 88a493783e214118a08d6b4aea0cf06a7bc62704 (diff) | |
| download | mullvadvpn-7921176436500797c2c149619f8e6f63ce0fb212.tar.xz mullvadvpn-7921176436500797c2c149619f8e6f63ce0fb212.zip | |
Implement new select location view in swiftui
61 files changed, 2463 insertions, 2498 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 e0ef73abb6..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 */; }; @@ -1073,17 +1065,22 @@ F91CCBF82DFABB75007F1925 /* DeviceManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91CCBF72DFABB70007F1925 /* DeviceManagementView.swift */; }; F91CCBFA2DFAC8ED007F1925 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91CCBF92DFAC8ED007F1925 /* DeviceListView.swift */; }; F91CCBFC2DFAF5E6007F1925 /* MullvadProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91CCBFB2DFAF5E1007F1925 /* MullvadProgressViewStyle.swift */; }; - F91E0CF32E6853D60056BD7C /* MullvadRoundedCorner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91E0CF12E6853770056BD7C /* MullvadRoundedCorner.swift */; }; F924C4532D70692E001F4660 /* MullvadApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F924C4522D706929001F4660 /* MullvadApiTests.swift */; }; 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 */; }; @@ -1094,10 +1091,13 @@ 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 */ @@ -1714,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>"; }; @@ -1815,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>"; }; @@ -2009,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>"; }; @@ -2147,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>"; }; @@ -2189,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>"; }; @@ -2360,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>"; }; @@ -2418,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>"; }; @@ -2435,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>"; }; @@ -2480,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>"; }; @@ -2491,17 +2483,22 @@ F91CCBF72DFABB70007F1925 /* DeviceManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementView.swift; sourceTree = "<group>"; }; F91CCBF92DFAC8ED007F1925 /* DeviceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = "<group>"; }; F91CCBFB2DFAF5E1007F1925 /* MullvadProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadProgressViewStyle.swift; sourceTree = "<group>"; }; - F91E0CF12E6853770056BD7C /* MullvadRoundedCorner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadRoundedCorner.swift; sourceTree = "<group>"; }; F924C4522D706929001F4660 /* MullvadApiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadApiTests.swift; sourceTree = "<group>"; }; 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>"; }; @@ -2510,10 +2507,12 @@ 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 */ @@ -2729,6 +2728,7 @@ 440E9EF02BDA93CB00B1FD11 /* MullvadVPN */ = { isa = PBXGroup; children = ( + F9EDB26A2EC4C03A0015DE36 /* Interactors */, F0A7EBB02CEF6C5F005BB671 /* Log */, 440E9EF62BDA957300B1FD11 /* Classes */, 440E9F002BDA997C00B1FD11 /* Extensions */, @@ -3155,22 +3155,12 @@ 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 */, - 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 */, @@ -3284,8 +3274,6 @@ 583FE01F29C197ED006E85F9 /* Views */ = { isa = PBXGroup; children = ( - F90A988B2E1268510020F64F /* MullvadPrimaryTextField.swift */, - F90A988D2E13C5490020F64F /* MullvadSecondaryTextField.swift */, 7A5869962B32EA4500640D27 /* AppButton.swift */, 7A0EAEA12D033D5A00D3EB8B /* BlurView.swift */, 7A9FA1412A2E3306000B728D /* CheckboxView.swift */, @@ -3306,15 +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 */, - F91E0CF12E6853770056BD7C /* MullvadRoundedCorner.swift */, - A9019ED62E6878CD0002ACA9 /* SegmentedControl.swift */, ); path = Views; sourceTree = "<group>"; @@ -4395,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 */, ); @@ -4812,10 +4798,14 @@ 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>"; @@ -4841,11 +4831,26 @@ 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 */ @@ -5919,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 */, @@ -6008,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 */, @@ -6207,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 */, @@ -6219,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 */, @@ -6258,6 +6263,7 @@ 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 */, @@ -6294,6 +6300,7 @@ 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 */, @@ -6302,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 */, @@ -6313,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 */, @@ -6322,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 */, @@ -6366,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 */, @@ -6418,11 +6425,9 @@ 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 */, @@ -6452,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 */, @@ -6467,6 +6472,7 @@ 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 */, @@ -6487,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 */, @@ -6547,7 +6554,6 @@ 58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */, F09D04B32AE919AC003D4F89 /* OutgoingConnectionProxy.swift in Sources */, 7A5869BF2B57D0A100640D27 /* IPOverrideStatus.swift in Sources */, - F91E0CF32E6853D60056BD7C /* MullvadRoundedCorner.swift in Sources */, 44E1F7582D3EA83A003A60FF /* DestinationDescriber.swift in Sources */, 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */, @@ -6564,11 +6570,9 @@ 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 */, @@ -6609,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 */, diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 7c630c50f4..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,7 @@ public enum AccessibilityIdentifier: Equatable { case openPortSelectorMenuButton case cancelPurchaseListButton case acceptLocalNetworkSharingButton + case selectLocationToolbarMenu case locationListItem(String) // Cells 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 9a12cfc03f..920c0f7e9f 100644 --- a/ios/MullvadVPN/Extensions/Image+Assets.swift +++ b/ios/MullvadVPN/Extensions/Image+Assets.swift @@ -14,6 +14,8 @@ extension Image { 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/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/DataSource/LocationNode.swift b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift index 8b6f24d9e4..59db93fa9c 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/DataSource/LocationNode.swift @@ -54,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? { @@ -165,12 +178,6 @@ extension Array where Element == LocationNode { element.children.forEachNode(body) } } - - var flattened: [LocationNode] { - var result: [LocationNode] = self - result += self.flatMap { $0.flattened } - return result - } } /// Proxy class for building and/or searching node trees. 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 index 3f605d2c99..dbcdf4c4e5 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/MultihopContext.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/MultihopContext.swift @@ -6,9 +6,9 @@ enum MultihopContext: CaseIterable, CustomStringConvertible, Hashable { var description: String { switch self { case .entry: - "Entry" + NSLocalizedString("Entry", comment: "") case .exit: - "Exit" + NSLocalizedString("Exit", comment: "") } } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift index d1d89cee8e..c7524e5630 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/SelectLocationFilter.swift @@ -1,3 +1,4 @@ +import MullvadSettings import SwiftUI enum SelectLocationFilter: Hashable { @@ -7,27 +8,27 @@ enum SelectLocationFilter: Hashable { case rented case provider(Int) - var canBeRemoved: Bool { + var isRemovable: Bool { switch self { case .daita, .obfuscation: - return false + false case .provider, .owned, .rented: - return true + true } } var title: LocalizedStringKey { switch self { case .daita: - return "Setting: \("DAITA")" + "Setting: \("DAITA")" case .obfuscation: - return "Setting: \("Obfuscation")" + "Setting: \("Obfuscation")" case .owned: - return "Owned" + "Owned" case .rented: - return "Rented" + "Rented" case .provider(let count): - return "Providers: \(count)" + "Providers: \(count)" } } @@ -37,7 +38,62 @@ enum SelectLocationFilter: Hashable { .daitaFilterPill case .obfuscation: .obfuscationFilterPill - default: nil + 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 index 8e77d4ebc0..a1ea82b6d2 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/Views/ActiveFilterView.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/ActiveFilterView.swift @@ -4,13 +4,12 @@ struct ActiveFilterView: View { let activeFilter: [SelectLocationFilter] let onSelect: (SelectLocationFilter) -> Void let onRemove: (SelectLocationFilter) -> Void - @State private var maxItemHeight: CGFloat = 0 // Show filters that can't be removed to the left private var sortedFilters: [SelectLocationFilter] { activeFilter .sorted { - !$0.canBeRemoved && $1.canBeRemoved + !$0.isRemovable && $1.isRemovable } } var body: some View { @@ -22,22 +21,18 @@ struct ActiveFilterView: View { } label: { HStack { Text(filter.title) - .font(.mullvadMiniSemiBold) - .foregroundStyle(Color.mullvadTextPrimary) - if filter.canBeRemoved { + if filter.isRemovable { Button { onRemove(filter) } label: { - Image.mullvadIconCross + Image(systemName: "xmark") } .accessibilityIdentifier(.relayFilterChipCloseButton) } } + .foregroundStyle(Color.mullvadTextPrimary) + .font(.mullvadMiniSemiBold) .padding(8) - .sizeOfView { size in - maxItemHeight = max(maxItemHeight, size.height) - } - .frame(height: maxItemHeight) .background { RoundedRectangle(cornerRadius: 8) .foregroundStyle(Color.MullvadButton.primary) @@ -46,19 +41,14 @@ struct ActiveFilterView: View { .accessibilityIdentifier(filter.accessibilityIdentifier) } } + .padding(.horizontal) } - .apply { - if #available(iOS 16.0, *) { - $0.scrollIndicators(.never) - } else { - $0 - } - } + .scrollIndicators(.never) } } #Preview { - Text("da") + Text("") .sheet(isPresented: .constant(true)) { NavigationView { ScrollView { 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 index a9ec70ac87..5795d4a03f 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationDisclosureGroup.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationDisclosureGroup.swift @@ -1,28 +1,37 @@ import SwiftUI -struct LocationDisclosureGroup<Label: View, Content: View>: View { +struct LocationDisclosureGroup<Label: View, Content: View, ContextMenu: View>: View { @Binding private var isExpanded: Bool - let position: ItemPosition 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, - position: ItemPosition = .only, + 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.position = position self.level = level + self.isLastInList = isLastInList self.isActive = isActive self._isExpanded = isExpanded self.accessibilityIdentifier = accessibilityIdentifier @@ -30,85 +39,60 @@ struct LocationDisclosureGroup<Label: View, Content: View>: View { self.label = label self.content = content self.onSelect = onSelect + self.contextMenu = contextMenu } var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 2) { - Button { - onSelect?() - } label: { - HStack { - label() - Spacer() - } - .frame(maxHeight: .infinity) - .background { - let corners: UIRectCorner = - if level == 0 { - if isExpanded { - [.topLeft] - } else { - [.topLeft, .bottomLeft] - } - } else { - switch position { - case .only: [.topLeft, .bottomLeft] - case .first: [.topLeft] - case .middle: [] - case .last: isExpanded ? [] : [.bottomLeft] - } - } - MullvadRoundedCorner(cornerRadius: 16, corners: corners) - .foregroundStyle(Color.colorForLevel(level)) - } + HStack(spacing: 2) { + Button { + onSelect?() + } label: { + HStack { + label() + Spacer() } - .disabled(!isActive) - Button { - withAnimation { - isExpanded.toggle() - } - } label: { - Image.mullvadIconChevron - .rotationEffect(.degrees(isExpanded ? -90 : 90)) - .padding(16) - .frame(maxHeight: .infinity) - .background { - let corners: UIRectCorner = - if level == 0 { - if isExpanded { - [.topRight] - } else { - [.topRight, .bottomRight] - } - } else { - switch position { - case .only: [.topRight, .bottomRight] - case .first: [.topRight] - case .middle: [] - case .last: isExpanded ? [] : [.bottomRight] - } - } - MullvadRoundedCorner( - cornerRadius: 16, - corners: corners - ) - .foregroundStyle(Color.colorForLevel(level)) - } + .frame(maxHeight: .infinity) + .background { + Color.colorForLevel(level) } - .accessibilityLabel(isExpanded ? Text("Collapse") : Text("Expand")) - .accessibilityIdentifier(.expandButton) - .contentShape(Rectangle()) } - .accessibilityElement(children: .combine) - .accessibilityIdentifier(accessibilityIdentifier) - - if isExpanded { - VStack(spacing: 1) { - content() + .disabled(!isActive) + Button { + withAnimation(.default.speed(3)) { + isExpanded.toggle() } - .padding(.top, 1) + } 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() } } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift index d78c22d9a6..5991775b32 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationListItem.swift @@ -2,8 +2,8 @@ import SwiftUI struct LocationListItem<ContextMenu>: View where ContextMenu: View { @Binding var location: LocationNode + var isLastInList: Bool = true let multihopContext: MultihopContext - let position: ItemPosition let onSelect: (LocationNode) -> Void let contextMenu: (LocationNode) -> ContextMenu var level = 0 @@ -21,17 +21,22 @@ struct LocationListItem<ContextMenu>: View where ContextMenu: View { RelayItemView( location: location, multihopContext: multihopContext, - position: position, level: level, + isLastInList: isLastInList, onSelect: { onSelect(location) } ) .accessibilityIdentifier(.locationListItem(location.name)) + .contextMenu { + contextMenu(location) + } + .padding(.top, level == 0 ? 4 : 1) } else { LocationDisclosureGroup( level: level, - position: position, + isLastInList: isLastInList, isActive: location.isActive && !location.isExcluded, isExpanded: $location.showsChildren, + contextMenu: { contextMenu(location) }, accessibilityIdentifier: .locationListItem(location.name) ) { ForEach( @@ -41,13 +46,8 @@ struct LocationListItem<ContextMenu>: View where ContextMenu: View { let location = $location.children[indexInChildrenList] LocationListItem( location: location, + isLastInList: isLastInList && index == (filteredChildrenIndices.count - 1), multihopContext: multihopContext, - position: level > 0 && position != .last - ? .middle - : ItemPosition( - index: index + 1, - count: filteredChildrenIndices.count + 1 - ), onSelect: onSelect, contextMenu: { location in contextMenu(location) }, level: level + 1, @@ -80,38 +80,13 @@ struct LocationListItem<ContextMenu>: View where ContextMenu: View { } } } + .zIndex(level == 0 ? 2 : 1 / Double(level)) // prevent wrong overlapping during animations .id(location.code) // to be able to scroll to this item programmatically - .transformEffect(.identity) - .contextMenu { - contextMenu(location) - } - } -} - -enum ItemPosition: String { - case first - case middle - case last - case only - - init(index: Int, count: Int) { - if index == 0 { - if count == 1 { - self = .only - } else { - self = .first - } - } else if index == count - 1 { - self = .last - } else { - self = .middle - } } } -@available(iOS 17, *) #Preview { - @Previewable @State var disabled: Bool = false + let viewModel = MockSelectLocationViewModel() Text("") .sheet(isPresented: .constant(true)) { ScrollView { @@ -121,16 +96,21 @@ enum ItemPosition: String { .init(name: "test", code: "test") ), multihopContext: .exit, - position: .only, onSelect: { _ in }, contextMenu: { _ in EmptyView() }, level: 0 ) - .disabled(disabled) + LocationListItem( + location: + .constant( + viewModel.exitContext.locations.first! + ), + multihopContext: .exit, + onSelect: { _ in + }, + contextMenu: { _ in EmptyView() }, + level: 0 + ) } - .gesture( - DragGesture().onChanged({ _ in disabled.toggle() }), - including: .none - ) } } diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift index 2e0074eea5..2a67fb6d0d 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/LocationsListView.swift @@ -14,165 +14,37 @@ struct LocationsListView<ContextMenu>: View where ContextMenu: View { } var body: some View { - VStack(spacing: 4) { - ForEach( - Array(filteredLocationIndices.enumerated()), - id: \.element - ) { index, indexInLocationList in - let location = $locations[indexInLocationList] - LocationListItem( - location: location, - multihopContext: multihopContext, - position: ItemPosition( - index: index, - count: filteredLocationIndices.count - ), - onSelect: onSelectLocation, - contextMenu: { location in contextMenu(location) } - ) - } + 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) } + ) } } } -@available(iOS 17, *) #Preview { - @Previewable @State var locations: [LocationNode] = [ - LocationNode( - name: "Sweden", code: "se", - children: [ - LocationNode( - name: "Stockholm", - code: "sth", - isActive: false, - children: [ - LocationNode(name: "se-sto-001", code: "se-sto-001"), - LocationNode(name: "se-sto-002", code: "se-sto-002"), - LocationNode(name: "se-sto-003", code: "se-sto-003"), - ] - ), - LocationNode( - name: "Gothenburg", code: "gto", - children: [ - LocationNode(name: "se-got-001", code: "se-got-001"), - LocationNode(name: "se-got-002", code: "se-got-002"), - LocationNode(name: "se-got-003", code: "se-got-003"), - ]), - ]), - LocationNode(name: "blo-la-003", code: "blo-la-003"), - LocationNode(name: "blo-la-005", code: "blo-la-005", isActive: false), - LocationNode( - name: "Germany", code: "de", - children: [ - LocationNode( - name: "Berlin", code: "ber", - children: [ - LocationNode(name: "de-ber-001", code: "de-ber-001"), - LocationNode(name: "de-ber-002", code: "de-ber-002"), - LocationNode(name: "de-ber-003", code: "de-ber-003"), - ]), - LocationNode( - name: "Frankfurt", code: "fra", - children: [ - LocationNode(name: "de-fra-001", code: "de-fra-001"), - LocationNode(name: "de-fra-002", code: "de-fra-002"), - LocationNode(name: "de-fra-003", code: "de-fra-003"), - ]), - ]), - LocationNode( - name: "France", code: "fr", - children: [ - LocationNode( - name: "Paris", code: "par", - children: [ - LocationNode(name: "fr-par-001", code: "fr-par-001"), - LocationNode(name: "fr-par-002", code: "fr-par-002"), - LocationNode(name: "fr-par-003", code: "fr-par-003"), - ]), - LocationNode( - name: "Lyon", code: "lyo", isActive: false, - children: [ - LocationNode(name: "fr-lyo-001", code: "fr-lyo-001"), - LocationNode(name: "fr-lyo-002", code: "fr-lyo-002"), - LocationNode(name: "fr-lyo-003", code: "fr-lyo-003"), - ]), - ]), - LocationNode(name: "Lalala", code: "test"), - LocationNode( - name: "Custom list", code: "blda", - children: [ - LocationNode(name: "de-ber-003", code: "de-ber-003"), - - LocationNode( - name: "France", code: "fr", - children: [ - LocationNode( - name: "Paris", code: "par", - children: [ - LocationNode(name: "fr-par-001", code: "fr-par-001"), - LocationNode(name: "fr-par-002", code: "fr-par-002"), - LocationNode(name: "fr-par-003", code: "fr-par-003"), - ]), - LocationNode( - name: "Lyon", code: "lyo", - children: [ - LocationNode(name: "fr-lyo-001", code: "fr-lyo-001"), - LocationNode(name: "fr-lyo-002", code: "fr-lyo-002"), - LocationNode(name: "fr-lyo-003", code: "fr-lyo-003"), - ]), - ]), - LocationNode(name: "testserver", code: "1234"), - ]), - ] + @Previewable @StateObject var viewModel = MockSelectLocationViewModel() ScrollView { - LocationsListView( - locations: $locations, - multihopContext: .exit, - onSelectLocation: { location in - print("Selected: \(location.name)") - }, - contextMenu: { location in Text("Add \(location.name) to list") } - ) - .padding() + 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) } - -@available(iOS 17, *) -#Preview { - @Previewable @State var location = LocationNode( - name: "Custom list", code: "blda", - children: [ - LocationNode(name: "de-ber-003", code: "de-ber-003"), - - LocationNode( - name: "France", code: "fr", - children: [ - LocationNode( - name: "Paris", code: "par", - children: [ - LocationNode(name: "fr-par-001", code: "fr-par-001"), - LocationNode(name: "fr-par-002", code: "fr-par-002"), - LocationNode(name: "fr-par-003", code: "fr-par-003"), - ], showsChildren: true), - LocationNode( - name: "Lyon", code: "lyo", - children: [ - LocationNode(name: "fr-lyo-001", code: "fr-lyo-001"), - LocationNode(name: "fr-lyo-002", code: "fr-lyo-002"), - LocationNode(name: "fr-lyo-003", code: "fr-lyo-003"), - ]), - ], showsChildren: true), - LocationNode(name: "testserver", code: "1234"), - ], showsChildren: true) - ScrollView { - LocationListItem( - location: $location, - multihopContext: .exit, - position: .only, - onSelect: { _ in }, - contextMenu: { location in Text("Add \(location.name) to list") }, - level: 0 - ) - } -} diff --git a/ios/MullvadVPN/View controllers/SelectLocation/Views/RelayItemView.swift b/ios/MullvadVPN/View controllers/SelectLocation/Views/RelayItemView.swift index 64878026ce..2fe9f0b22d 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/Views/RelayItemView.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/Views/RelayItemView.swift @@ -3,8 +3,8 @@ import SwiftUI struct RelayItemView: View { let location: LocationNode let multihopContext: MultihopContext - let position: ItemPosition let level: Int + var isLastInList = true let onSelect: () -> Void var disabled: Bool { @@ -23,13 +23,13 @@ struct RelayItemView: View { switch multihopContext { case .entry: return """ - \(location.name) (\(String(localized: + \(location.name) (\(String(localized: String .LocalizationValue(MultihopContext.exit.description)))) """ case .exit: return """ - \(location.name) (\(String(localized: + \(location.name) (\(String(localized: String .LocalizationValue(MultihopContext.entry.description)))) """ @@ -43,12 +43,7 @@ struct RelayItemView: View { onSelect() } label: { HStack { - if !location.isActive { - Image.mullvadRedDot - } else if location.isSelected { - Image.mullvadIconTick - .foregroundStyle(Color.mullvadSuccessColor) - } + locationStatusIndicator() VStack(alignment: .leading) { Text(title) .font(.mullvadSmallSemiBold) @@ -70,26 +65,31 @@ struct RelayItemView: View { .padding(.vertical, subtitle != nil ? 8 : 16) .padding(.horizontal, CGFloat(16 * (level + 1))) .background { - let backgroundColor = Color.colorForLevel(level) - let corners: UIRectCorner = - if level == 0 { - .allCorners - } else { - switch position { - case .only: .allCorners - case .first: [] - case .middle: [] - case .last: [.bottomLeft, .bottomRight] - } - } - MullvadRoundedCorner( - cornerRadius: 16, - corners: corners - ) - .foregroundStyle(backgroundColor) + 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) + } } } @@ -100,7 +100,6 @@ struct RelayItemView: View { code: "a-great-location" ), multihopContext: .exit, - position: .only, 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/MullvadRoundedCorner.swift b/ios/MullvadVPN/Views/MullvadRoundedCorner.swift deleted file mode 100644 index 249b6fb7d5..0000000000 --- a/ios/MullvadVPN/Views/MullvadRoundedCorner.swift +++ /dev/null @@ -1,152 +0,0 @@ -import SwiftUI - -/* - This is an animatable version of RoundedRectangle. It can also round only a subset of corners. - */ -struct MullvadRoundedCorner: Shape { - var cornerRadius: CGFloat = .infinity - var corners: UIRectCorner = .allCorners - var insetBy: CGFloat = 0 - - private var radii: CornerRadii { - CornerRadii( - topLeft: corners.contains(.topLeft) ? cornerRadius : 0, - topRight: corners.contains(.topRight) ? cornerRadius : 0, - bottomLeft: corners.contains(.bottomLeft) ? cornerRadius : 0, - bottomRight: corners.contains(.bottomRight) ? cornerRadius : 0 - ) - } - - var animatableData: CornerRadii { - get { radiiState } - set { radiiState = newValue } - } - - private var radiiState: CornerRadii - - init( - cornerRadius: CGFloat = .infinity, - corners: UIRectCorner = .allCorners, - insetBy: CGFloat = 0 - ) { - self.cornerRadius = cornerRadius - self.corners = corners - self.insetBy = insetBy - self.radiiState = CornerRadii( - topLeft: corners.contains(.topLeft) ? cornerRadius : 0, - topRight: corners.contains(.topRight) ? cornerRadius : 0, - bottomLeft: corners.contains(.bottomLeft) ? cornerRadius : 0, - bottomRight: corners.contains(.bottomRight) ? cornerRadius : 0 - ) - } - - func path(in rect: CGRect) -> Path { - let insetRect = rect.insetBy(dx: insetBy, dy: insetBy) - var path = Path() - - let tl = min(radiiState.topLeft, min(insetRect.width, insetRect.height) / 2) - let tr = min(radiiState.topRight, min(insetRect.width, insetRect.height) / 2) - let bl = min(radiiState.bottomLeft, min(insetRect.width, insetRect.height) / 2) - let br = min(radiiState.bottomRight, min(insetRect.width, insetRect.height) / 2) - - path.move(to: CGPoint(x: insetRect.minX + tl, y: insetRect.minY)) - - // Top edge - path.addLine(to: CGPoint(x: insetRect.maxX - tr, y: insetRect.minY)) - path.addArc( - center: CGPoint(x: insetRect.maxX - tr, y: insetRect.minY + tr), - radius: tr, - startAngle: .degrees(-90), - endAngle: .degrees(0), - clockwise: false - ) - - // Right edge - path.addLine(to: CGPoint(x: insetRect.maxX, y: insetRect.maxY - br)) - path.addArc( - center: CGPoint(x: insetRect.maxX - br, y: insetRect.maxY - br), - radius: br, - startAngle: .degrees(0), - endAngle: .degrees(90), - clockwise: false - ) - - // Bottom edge - path.addLine(to: CGPoint(x: insetRect.minX + bl, y: insetRect.maxY)) - path.addArc( - center: CGPoint(x: insetRect.minX + bl, y: insetRect.maxY - bl), - radius: bl, - startAngle: .degrees(90), - endAngle: .degrees(180), - clockwise: false - ) - - // Left edge - path.addLine(to: CGPoint(x: insetRect.minX, y: insetRect.minY + tl)) - path.addArc( - center: CGPoint(x: insetRect.minX + tl, y: insetRect.minY + tl), - radius: tl, - startAngle: .degrees(180), - endAngle: .degrees(270), - clockwise: false - ) - - return path - } - - struct CornerRadii: VectorArithmetic { - var topLeft: CGFloat - var topRight: CGFloat - var bottomLeft: CGFloat - var bottomRight: CGFloat - - static let zero = CornerRadii( - topLeft: 0, - topRight: 0, - bottomLeft: 0, - bottomRight: 0 - ) - - static func + (lhs: CornerRadii, rhs: CornerRadii) -> CornerRadii { - .init( - topLeft: lhs.topLeft + rhs.topLeft, - topRight: lhs.topRight + rhs.topRight, - bottomLeft: lhs.bottomLeft + rhs.bottomLeft, - bottomRight: lhs.bottomRight + rhs.bottomRight - ) - } - - static func - (lhs: CornerRadii, rhs: CornerRadii) -> CornerRadii { - .init( - topLeft: lhs.topLeft - rhs.topLeft, - topRight: lhs.topRight - rhs.topRight, - bottomLeft: lhs.bottomLeft - rhs.bottomLeft, - bottomRight: lhs.bottomRight - rhs.bottomRight - ) - } - - mutating func scale(by rhs: Double) { - topLeft *= rhs - topRight *= rhs - bottomLeft *= rhs - bottomRight *= rhs - } - - var magnitudeSquared: Double { - Double( - topLeft * topLeft + topRight * topRight + bottomLeft * bottomLeft + bottomRight - * bottomRight - ) - } - - static func == (lhs: CornerRadii, rhs: CornerRadii) -> Bool { - lhs.topLeft == rhs.topLeft && lhs.topRight == rhs.topRight && lhs.bottomLeft == rhs.bottomLeft - && lhs.bottomRight == rhs.bottomRight - } - - // swiftlint:disable:next shorthand_operator - mutating func add(_ other: CornerRadii) { self = self + other } - // swiftlint:disable:next shorthand_operator - mutating func subtract(_ other: CornerRadii) { self = self - other } - } -} 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) } } |
