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