/* * Copyright (c) 2024 Proton AG * * This file is part of ProtonVPN. * * ProtonVPN is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * ProtonVPN is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with ProtonVPN. If not, see . */ using CommunityToolkit.Mvvm.ComponentModel; using ProtonVPN.Client.Common.Collections; using ProtonVPN.Client.Core.Bases; using ProtonVPN.Client.Core.Bases.ViewModels; using ProtonVPN.Client.Core.Enums; using ProtonVPN.Client.EventMessaging.Contracts; using ProtonVPN.Client.Factories; using ProtonVPN.Client.Logic.Connection.Contracts.Enums; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Features; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.Cities; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.Countries; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.Gateways; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.GatewayServers; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.Servers; using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.States; using ProtonVPN.Client.Logic.Servers.Contracts; using ProtonVPN.Client.Logic.Servers.Contracts.Enums; using ProtonVPN.Client.Logic.Servers.Contracts.Messages; using ProtonVPN.Client.Models; using ProtonVPN.Client.Models.Connections; using ProtonVPN.Client.Models.Connections.Countries; using ProtonVPN.Client.Models.Connections.Gateways; using ProtonVPN.Client.UI.Main.Profiles.Contracts; using ProtonVPN.Common.Core.Extensions; namespace ProtonVPN.Client.UI.Main.Profiles.Components; public partial class ConnectionIntentSelectorViewModel : ViewModelBase, IConnectionIntentSelector, IEventMessageReceiver { private readonly IServersLoader _serversLoader; private readonly ILocationItemFactory _locationItemFactory; private readonly ICommonItemFactory _commonItemFactory; private IConnectionIntent _originalConnectionIntent = ConnectionIntent.Default; private ILocationIntent? _currentLocationIntent; private IFeatureIntent? _currentFeatureIntent; [ObservableProperty] [NotifyPropertyChangedFor(nameof(FirstLevelHeader))] private FeatureItem? _selectedFeature; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasSecondLevel))] [NotifyPropertyChangedFor(nameof(HasThirdLevel))] private ConnectionItemBase? _selectedFirstLevelLocationItem; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasThirdLevel))] private ConnectionItemBase? _selectedSecondLevelLocationItem; [ObservableProperty] private ConnectionItemBase? _selectedThirdLevelLocationItem; private IReadOnlyList _gateways = []; private IReadOnlyList _countries = []; private IReadOnlyList _p2pCountries = []; private IReadOnlyList _secureCoreCountries = []; private bool _isUpdatingFeatureSelection; private bool _isUpdatingLocationSelection; public SmartObservableCollection FirstLevelLocationItems { get; } = []; public SmartObservableCollection SecondLevelLocationItems { get; } = []; public SmartObservableCollection ThirdLevelLocationItems { get; } = []; public SmartObservableCollection Features { get; } = []; public bool AreOnlyGatewaysAvailable => _serversLoader.HasAnyGateways() && !_serversLoader.HasAnyCountries(); public string FirstLevelHeader => SelectedFeature?.Feature switch { Feature.B2B => Localizer.Get("Connections_Gateway"), _ => Localizer.Get("Connections_Country"), }; public string SecondLevelHeader => SecondLevelLocationItems.FirstOrDefault(i => i is not GenericFastestLocationItem) switch { SecureCoreCountryPairLocationItem => Localizer.Get("Connections_Country_Middle"), GatewayServerLocationItem => Localizer.Get("Connections_Gateways_Server"), StateLocationItemBase => Localizer.Get("Connections_State"), _ => Localizer.Get("Connections_City"), }; public string ThirdLevelHeader => Localizer.Get("Server"); public bool HasSecondLevel => SelectedFirstLevelLocationItem is IHostLocationItem; public bool HasThirdLevel => HasSecondLevel && SelectedSecondLevelLocationItem is IHostLocationItem; public ConnectionIntentSelectorViewModel( IServersLoader serversLoader, ILocationItemFactory locationItemFactory, ICommonItemFactory commonItemFactory, IViewModelHelper viewModelHelper) : base(viewModelHelper) { _serversLoader = serversLoader; _locationItemFactory = locationItemFactory; _commonItemFactory = commonItemFactory; } public IConnectionIntent GetConnectionIntent() { InvalidateCurrentFeatureIntent(); InvalidateCurrentLocationIntent(); return _currentLocationIntent != null ? new ConnectionIntent(_currentLocationIntent, _currentFeatureIntent) : ConnectionIntent.Default; } public void SetConnectionIntent(IConnectionIntent connectionIntent) { _originalConnectionIntent = connectionIntent ?? ConnectionIntent.Default; _currentFeatureIntent = _originalConnectionIntent.Feature?.Copy(); InvalidateFeatureSelection(); _currentLocationIntent = _originalConnectionIntent.Location.Copy(); InvalidateLocationSelection(); } public void Receive(ServerListChangedMessage message) { ExecuteOnUIThread(() => { OnPropertyChanged(nameof(AreOnlyGatewaysAvailable)); InvalidateServers(); }); } public bool HasChanged() { bool isSameIntent = _originalConnectionIntent.Location.IsSameAs(_currentLocationIntent) && ((_originalConnectionIntent.Feature == null && _currentFeatureIntent == null) || _originalConnectionIntent.Feature?.IsSameAs(_currentFeatureIntent) == true); return !isSameIntent; } public bool IsReconnectionRequired() { return HasChanged(); } private void InvalidateServers() { _gateways = GetGateways(); _countries = GetCountries(); _p2pCountries = GetP2PCountries(); _secureCoreCountries = GetSecureCoreCountries(); InvalidateFeatures(); InvalidateFeatureSelection(); InvalidateLocationSelection(); } private IReadOnlyList GetGateways() { IEnumerable gateways = _serversLoader.GetGateways() .Select(_locationItemFactory.GetGateway); return gateways.ToList(); } private IReadOnlyList GetCountries() { IEnumerable genericCountries = [ _locationItemFactory.GetGenericCountry(CountriesConnectionType.All, SelectionStrategy.Fastest, false), _locationItemFactory.GetGenericCountry(CountriesConnectionType.All, SelectionStrategy.Fastest, true), _locationItemFactory.GetGenericCountry(CountriesConnectionType.All, SelectionStrategy.Random, false), ]; IEnumerable countries = _serversLoader.GetCountries() .Select(c => _locationItemFactory.GetCountry(c)); return genericCountries .Concat(countries) .ToList(); } private IReadOnlyList GetP2PCountries() { IEnumerable genericCountries = [ _locationItemFactory.GetGenericCountry(CountriesConnectionType.P2P, SelectionStrategy.Fastest, false), _locationItemFactory.GetGenericCountry(CountriesConnectionType.P2P, SelectionStrategy.Fastest, true), _locationItemFactory.GetGenericCountry(CountriesConnectionType.P2P, SelectionStrategy.Random, false), ]; IEnumerable countries = _serversLoader.GetCountriesByFeatures(ServerFeatures.P2P) .Select(c => _locationItemFactory.GetP2PCountry(c)); return genericCountries .Concat(countries) .ToList(); } private IReadOnlyList GetSecureCoreCountries() { IEnumerable genericCountries = [ _locationItemFactory.GetGenericCountry(CountriesConnectionType.SecureCore, SelectionStrategy.Fastest, false), _locationItemFactory.GetGenericCountry(CountriesConnectionType.SecureCore, SelectionStrategy.Fastest, true), _locationItemFactory.GetGenericCountry(CountriesConnectionType.SecureCore, SelectionStrategy.Random, false), ]; IEnumerable countries = _serversLoader.GetCountriesByFeatures(ServerFeatures.SecureCore) .Select(c => _locationItemFactory.GetSecureCoreCountry(c)); return genericCountries .Concat(countries) .ToList(); } private void InvalidateFeatures() { if (AreOnlyGatewaysAvailable) { Features.Reset([_commonItemFactory.GetFeature(Feature.B2B)]); return; } List features = [Feature.None]; if (_gateways.Any()) { features.Add(Feature.B2B); } if (_secureCoreCountries.Any()) { features.Add(Feature.SecureCore); } if (_p2pCountries.Any()) { features.Add(Feature.P2P); } Features.Reset( features.Select(_commonItemFactory.GetFeature)); } private void InvalidateFeatureSelection() { try { _isUpdatingFeatureSelection = true; SelectedFeature = _currentFeatureIntent switch { B2BFeatureIntent when Features.Any(f => f.Feature == Feature.B2B) => Features.FirstOrDefault(f => f.Feature == Feature.B2B), P2PFeatureIntent when Features.Any(f => f.Feature == Feature.P2P) => Features.FirstOrDefault(f => f.Feature == Feature.P2P), SecureCoreFeatureIntent when Features.Any(f => f.Feature == Feature.SecureCore) => Features.FirstOrDefault(f => f.Feature == Feature.SecureCore), _ when AreOnlyGatewaysAvailable => Features.FirstOrDefault(f => f.Feature == Feature.B2B), _ => Features.FirstOrDefault(f => f.Feature == Feature.None) }; } finally { _isUpdatingFeatureSelection = false; } } private void InvalidateLocationSelection() { try { _isUpdatingLocationSelection = true; InvalidateFirstLevelLocations(); SelectedFirstLevelLocationItem = _currentLocationIntent switch { MultiCountryLocationIntent intent when intent.IsToExcludeMyCountry => intent.Strategy switch { SelectionStrategy.Fastest => FirstLevelLocationItems.OfType().FirstOrDefault(c => c.Strategy == SelectionStrategy.Fastest && c.ExcludeMyCountry), SelectionStrategy.Random => FirstLevelLocationItems.OfType().FirstOrDefault(c => c.Strategy == SelectionStrategy.Random && c.ExcludeMyCountry), _ => null, }, MultiCountryLocationIntent intent => intent.Strategy switch { SelectionStrategy.Fastest => FirstLevelLocationItems.OfType().FirstOrDefault(c => c.Strategy == SelectionStrategy.Fastest && !c.ExcludeMyCountry), SelectionStrategy.Random => FirstLevelLocationItems.OfType().FirstOrDefault(c => c.Strategy == SelectionStrategy.Random && !c.ExcludeMyCountry), _ => null, }, SingleCountryLocationIntent intent => FirstLevelLocationItems.OfType().FirstOrDefault(c => intent.CountryCode.EqualsIgnoringCase(c.ExitCountryCode)), StateLocationIntentBase intent => FirstLevelLocationItems.OfType().FirstOrDefault(c => intent.Country.CountryCode.EqualsIgnoringCase(c.ExitCountryCode)), CityLocationIntentBase intent => FirstLevelLocationItems.OfType().FirstOrDefault(c => intent.Country.CountryCode.EqualsIgnoringCase(c.ExitCountryCode)), ServerLocationIntentBase intent => FirstLevelLocationItems.OfType().FirstOrDefault(c => intent.Country.CountryCode.EqualsIgnoringCase(c.ExitCountryCode)), MultiGatewayLocationIntent intent => intent.Strategy switch { SelectionStrategy.Fastest => FirstLevelLocationItems.OfType().FirstOrDefault(g => g.Strategy == SelectionStrategy.Fastest), SelectionStrategy.Random => FirstLevelLocationItems.OfType().FirstOrDefault(g => g.Strategy == SelectionStrategy.Random), _ => null, }, GatewayServerLocationIntentBase intent => FirstLevelLocationItems.OfType().FirstOrDefault(g => intent.Gateway.GatewayName.EqualsIgnoringCase(g.Gateway.Name)), _ => null }; SelectedFirstLevelLocationItem ??= FirstLevelLocationItems.FirstOrDefault(); SelectedSecondLevelLocationItem = _currentLocationIntent switch { _ when _currentFeatureIntent is SecureCoreFeatureIntent intent => SecondLevelLocationItems.OfType().FirstOrDefault(cp => cp.CountryPair.EntryCountry == intent.EntryCountryCode), MultiStateLocationIntent intent => intent.Strategy switch { // TODO: Pick the right generic intent based on the strategy (fastest or random) SelectionStrategy.Fastest => SecondLevelLocationItems.OfType().FirstOrDefault(), SelectionStrategy.Random => SecondLevelLocationItems.OfType().FirstOrDefault(), _ => null }, SingleStateLocationIntent intent => SecondLevelLocationItems.OfType().FirstOrDefault(s => intent.StateName.EqualsIgnoringCase(s.State.Name)), MultiCityLocationIntent intent => intent.Strategy switch { // TODO: Pick the right generic intent based on the strategy (fastest or random) SelectionStrategy.Fastest => SecondLevelLocationItems.OfType().FirstOrDefault(), SelectionStrategy.Random => SecondLevelLocationItems.OfType().FirstOrDefault(), _ => null }, SingleCityLocationIntent intent => SecondLevelLocationItems.OfType().FirstOrDefault(c => intent.CityName.EqualsIgnoringCase(c.City.Name)), ServerLocationIntentBase intent when intent.State != null => SecondLevelLocationItems.OfType().FirstOrDefault(s => intent.State.StateName.EqualsIgnoringCase(s.State.Name)), ServerLocationIntentBase intent when intent.City != null => SecondLevelLocationItems.OfType().FirstOrDefault(c => intent.City.CityName.EqualsIgnoringCase(c.City.Name)), SingleServerLocationIntent intent => SecondLevelLocationItems.OfType().FirstOrDefault(s => intent.Server.Id == s.Server.Id), SingleGatewayServerLocationIntent intent => SecondLevelLocationItems.OfType().FirstOrDefault(s => intent.Server.Id == s.Server.Id), _ => null }; SelectedSecondLevelLocationItem ??= SecondLevelLocationItems.FirstOrDefault(); SelectedThirdLevelLocationItem = _currentLocationIntent switch { SingleServerLocationIntent intent => ThirdLevelLocationItems.OfType().FirstOrDefault(s => intent.Server.Id == s.Server.Id), _ => null }; SelectedThirdLevelLocationItem ??= ThirdLevelLocationItems.FirstOrDefault(); } finally { _isUpdatingLocationSelection = false; } } private void InvalidateFirstLevelLocations() { IEnumerable items = SelectedFeature?.Feature switch { Feature.B2B => _gateways, Feature.P2P => _p2pCountries, Feature.SecureCore => _secureCoreCountries, _ => _countries, }; FirstLevelLocationItems.Reset( items.OrderBy(g => g.FirstSortProperty) .ThenBy(g => g.SecondSortProperty)); } private void InvalidateSecondLevelLocations() { List locations = []; if (SelectedFirstLevelLocationItem is IHostLocationItem hostLocationItem) { hostLocationItem.FetchSubItems(); ConnectionItemBase fastestLocation = _locationItemFactory.GetGenericFastestLocation(hostLocationItem.GroupType, hostLocationItem.LocationIntent); locations.Add(fastestLocation); locations.AddRange( hostLocationItem.SubItems .OrderBy(l => l.Header) .ToList()); } SecondLevelLocationItems.Reset(locations); OnPropertyChanged(nameof(SecondLevelHeader)); } private void InvalidateThirdLevelLocations() { List locations = []; if (SelectedSecondLevelLocationItem is IHostLocationItem hostLocationItem) { hostLocationItem.FetchSubItems(); ConnectionItemBase fastestLocation = _locationItemFactory.GetGenericFastestLocation(hostLocationItem.GroupType, hostLocationItem.LocationIntent); locations.Add(fastestLocation); locations.AddRange( hostLocationItem.SubItems .OfType() .OrderBy(s => s.ServerTag) .ThenBy(s => s.ServerNumber) .ToList()); } ThirdLevelLocationItems.Reset(locations); } private void InvalidateCurrentFeatureIntent() { _currentFeatureIntent = SelectedFeature?.Feature switch { Feature.SecureCore when SelectedSecondLevelLocationItem is SecureCoreCountryPairLocationItem countryPair => new SecureCoreFeatureIntent(countryPair.CountryPair.EntryCountry), Feature.SecureCore => new SecureCoreFeatureIntent(), Feature.Tor => new TorFeatureIntent(), Feature.P2P => new P2PFeatureIntent(), Feature.B2B => new B2BFeatureIntent(), _ => null, }; } private void InvalidateCurrentLocationIntent() { _currentLocationIntent = HasThirdLevel && SelectedThirdLevelLocationItem is LocationItemBase thirdLevelLocationItem ? thirdLevelLocationItem.LocationIntent : HasSecondLevel && SelectedSecondLevelLocationItem is LocationItemBase secondLevelLocationItem ? secondLevelLocationItem.LocationIntent : SelectedFirstLevelLocationItem is LocationItemBase firstLevelLocationItem ? firstLevelLocationItem.LocationIntent : MultiCountryLocationIntent.Default; } partial void OnSelectedFeatureChanged(FeatureItem? value) { if (!_isUpdatingFeatureSelection) { InvalidateCurrentFeatureIntent(); InvalidateCurrentLocationIntent(); InvalidateLocationSelection(); } } partial void OnSelectedFirstLevelLocationItemChanged(ConnectionItemBase? value) { InvalidateSecondLevelLocations(); if (!_isUpdatingLocationSelection) { SelectedSecondLevelLocationItem = SecondLevelLocationItems.FirstOrDefault(); InvalidateCurrentLocationIntent(); } } partial void OnSelectedSecondLevelLocationItemChanged(ConnectionItemBase? value) { InvalidateThirdLevelLocations(); if (!_isUpdatingLocationSelection) { SelectedThirdLevelLocationItem = ThirdLevelLocationItems.FirstOrDefault(); InvalidateCurrentLocationIntent(); if (_currentFeatureIntent is SecureCoreFeatureIntent) { InvalidateCurrentFeatureIntent(); } } } partial void OnSelectedThirdLevelLocationItemChanged(ConnectionItemBase? value) { if (!_isUpdatingLocationSelection) { InvalidateCurrentLocationIntent(); } } }