/*
* Copyright (c) 2026 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 ProtonVPN.Client.EventMessaging.Contracts;
using ProtonVPN.Client.Logic.Auth.Contracts.Messages;
using ProtonVPN.Client.Logic.Connection.Contracts;
using ProtonVPN.Client.Logic.Connection.Contracts.Enums;
using ProtonVPN.Client.Logic.Connection.Contracts.GuestHole;
using ProtonVPN.Client.Logic.Connection.Contracts.Messages;
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.Countries;
using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.FreeServers;
using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.Gateways;
using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Locations.Servers;
using ProtonVPN.Client.Logic.Profiles.Contracts;
using ProtonVPN.Client.Logic.Profiles.Contracts.Messages;
using ProtonVPN.Client.Logic.Profiles.Contracts.Models;
using ProtonVPN.Client.Logic.Recents.Contracts;
using ProtonVPN.Client.Logic.Recents.Contracts.Messages;
using ProtonVPN.Client.Logic.Recents.Files;
using ProtonVPN.Client.Logic.Servers.Contracts;
using ProtonVPN.Client.Logic.Servers.Contracts.Messages;
using ProtonVPN.Client.Logic.Servers.Contracts.Models;
using ProtonVPN.Client.Settings.Contracts;
using ProtonVPN.Client.Settings.Contracts.Enums;
using ProtonVPN.Client.Settings.Contracts.Models;
using ProtonVPN.Common.Core.Geographical;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.AppLogs;
namespace ProtonVPN.Client.Logic.Recents;
public class RecentConnectionsManager : IRecentConnectionsManager,
IEventMessageReceiver,
IEventMessageReceiver,
IEventMessageReceiver,
IEventMessageReceiver,
IEventMessageReceiver
{
private const int MAXIMUM_RECENT_CONNECTIONS = 6;
private readonly ILogger _logger;
private readonly ISettings _settings;
private readonly IServersLoader _serversLoader;
private readonly IProfilesManager _profilesManager;
private readonly IConnectionManager _connectionManager;
private readonly IEventMessageSender _eventMessageSender;
private readonly IGuestHoleManager _guestHoleManager;
private readonly IFavoriteServersStorage _favoriteServersStorage;
private readonly IRecentsFileReaderWriter _recentsFileReaderWriter;
private readonly object _lock = new();
private List _recentConnections = [];
private bool _areRecentsLoaded;
public RecentConnectionsManager(
ILogger logger,
ISettings settings,
IServersLoader serversLoader,
IProfilesManager profilesManager,
IConnectionManager connectionManager,
IEventMessageSender eventMessageSender,
IGuestHoleManager guestHoleManager,
IFavoriteServersStorage favoriteServersStorage,
IRecentsFileReaderWriter recentsFileReaderWriter)
{
_logger = logger;
_settings = settings;
_serversLoader = serversLoader;
_profilesManager = profilesManager;
_connectionManager = connectionManager;
_eventMessageSender = eventMessageSender;
_guestHoleManager = guestHoleManager;
_favoriteServersStorage = favoriteServersStorage;
_recentsFileReaderWriter = recentsFileReaderWriter;
}
public bool HasAnyRecentConnections()
{
return _recentConnections.Any();
}
public IOrderedEnumerable GetRecentConnections()
{
return _recentConnections.OrderByDescending(c => c.IsPinned)
.ThenBy(c => c.PinTime)
.ThenByDescending(c => c.LastConnectionTimeUtc);
}
public IRecentConnection? GetMostRecentConnection()
{
List servers = _serversLoader.GetServers().ToList();
DeviceLocation? deviceLocation = _settings.DeviceLocation;
IRecentConnection? mostRecentConnection = _recentConnections
.OrderByDescending(c => c.LastConnectionTimeUtc)
.FirstOrDefault(c => !c.ConnectionIntent.AreAllServersUnderMaintenance(servers, deviceLocation));
return mostRecentConnection;
}
public IConnectionIntent GetDefaultConnection()
{
if (!_settings.VpnPlan.IsPaid)
{
return ConnectionIntent.FreeDefault;
}
DefaultConnection defaultConnection = _settings.DefaultConnection;
IConnectionIntent? mostRecentConnectionIntent = GetMostRecentConnection()?.ConnectionIntent;
Gateway? gateway = _serversLoader.GetGateways().FirstOrDefault();
if ((defaultConnection == DefaultConnection.Fastest || (defaultConnection == DefaultConnection.Last && mostRecentConnectionIntent is null)) &&
gateway is not null && !_serversLoader.HasAnyCountries())
{
return new ConnectionIntent(SingleGatewayLocationIntent.From(gateway.Name), new B2BFeatureIntent());
}
return defaultConnection.Type switch
{
DefaultConnectionType.Recent => GetById(defaultConnection.RecentId)?.ConnectionIntent ?? ConnectionIntent.Default,
DefaultConnectionType.Last => mostRecentConnectionIntent ?? ConnectionIntent.Default,
DefaultConnectionType.Random => new ConnectionIntent(MultiCountryLocationIntent.Random),
_ => ConnectionIntent.Default
};
}
public IRecentConnection? GetById(Guid id)
{
return _recentConnections.FirstOrDefault(c => c.Id == id);
}
public void OverrideRecentConnections(List connectionIntents, IConnectionIntent? quickConnectionIntent = null)
{
lock (_lock)
{
_recentConnections.Clear();
foreach (IConnectionIntent connectionIntent in connectionIntents)
{
TryInsertRecentConnection(connectionIntent);
}
if (quickConnectionIntent is not null)
{
TryInsertRecentConnection(quickConnectionIntent);
SetAsDefaultConnection(GetMostRecentConnection()?.Id);
}
SaveRecentConnections();
}
}
public void Pin(IRecentConnection recentConnection)
{
lock (_lock)
{
if (TryPinRecentConnection(recentConnection))
{
SaveAndBroadcastRecentConnectionsChanges();
}
}
}
public void Unpin(IRecentConnection recentConnection)
{
lock (_lock)
{
if (TryUnpinRecentConnection(recentConnection))
{
TrimRecentConnections();
SaveAndBroadcastRecentConnectionsChanges();
}
}
}
public void Remove(IRecentConnection recentConnection)
{
lock (_lock)
{
if (TryRemoveRecentConnection(recentConnection))
{
SaveAndBroadcastRecentConnectionsChanges();
}
}
}
public void SetAsDefaultConnection(Guid? recentConnectionId)
{
_settings.DefaultConnection = recentConnectionId != null
? new DefaultConnection(recentConnectionId.Value)
: DefaultSettings.DefaultConnection;
}
public void Receive(LoggedInMessage message)
{
BroadcastRecentConnectionsChanges();
}
public void Receive(LoggedOutMessage message)
{
_areRecentsLoaded = false;
}
public void Receive(ConnectionStatusChangedMessage message)
{
if (_areRecentsLoaded && !_guestHoleManager.IsActive && (message?.ConnectionStatus) == ConnectionStatus.Connecting)
{
lock (_lock)
{
IConnectionIntent? connectionIntent = _connectionManager.CurrentConnectionIntent;
if (TryInsertRecentConnection(connectionIntent, DateTime.UtcNow))
{
TrimRecentConnections();
SaveAndBroadcastRecentConnectionsChanges();
}
}
}
}
public void Receive(ServerListChangedMessage message)
{
if (_areRecentsLoaded)
{
lock (_lock)
{
if (TryInvalidateRetiredServers())
{
SaveAndBroadcastRecentConnectionsChanges();
}
}
}
}
public void Receive(ProfilesChangedMessage message)
{
if (_areRecentsLoaded)
{
lock (_lock)
{
bool hasProfilesChanged = TryInvalidateRecentProfiles();
bool hasRecentsChanged = TryInvalidateRetiredServers();
if (hasProfilesChanged || hasRecentsChanged)
{
SaveAndBroadcastRecentConnectionsChanges();
}
}
}
}
private void UpdateCurrentConnectionIntent()
{
// If the client is launched after a crash and there was previously an active VPN connection,
// we initialize the connection manager with the last connection intent from the recent active connection.
// This ensures that when the connection manager receives the connected state, it knows the last user intent
// and can therefore display the correct connection info in the connection details panel.
IRecentConnection? recentConnection = GetMostRecentConnection();
_connectionManager.InitializeAsync(recentConnection?.ConnectionIntent);
}
private bool TryInsertRecentConnection(IConnectionIntent? recentIntent, DateTime? connectionTime = null)
{
if (recentIntent == null || recentIntent.Location is FreeServerLocationIntent)
{
return false;
}
List duplicates = _recentConnections.Where(c => c.ConnectionIntent.IsSameAs(recentIntent)).ToList();
foreach (IRecentConnection duplicate in duplicates)
{
_recentConnections.Remove(duplicate);
}
IRecentConnection recentConnection = duplicates.FirstOrDefault() ?? new RecentConnection(Guid.NewGuid(), recentIntent);
recentConnection.LastConnectionTimeUtc = connectionTime;
_recentConnections.Add(recentConnection);
return true;
}
private bool TryRemoveRecentConnection(IRecentConnection recentConnection)
{
if (recentConnection == null)
{
return false;
}
if (recentConnection.Id == _settings.DefaultConnection.RecentId)
{
_settings.DefaultConnection = DefaultSettings.DefaultConnection;
_logger.Info("Removing recent which is set as a current default connection.");
}
_recentConnections.Remove(recentConnection);
return true;
}
private bool TryPinRecentConnection(IRecentConnection recentConnection)
{
if (recentConnection == null || recentConnection.IsPinned)
{
return false;
}
recentConnection.IsPinned = true;
recentConnection.PinTime = DateTime.UtcNow;
return true;
}
private bool TryUnpinRecentConnection(IRecentConnection recentConnection)
{
if (recentConnection == null || !recentConnection.IsPinned)
{
return false;
}
recentConnection.IsPinned = false;
recentConnection.PinTime = null;
return true;
}
private bool TryInvalidateRecentProfiles()
{
bool hasRecentListBeenModified = false;
List profiles = _profilesManager.GetAll().ToList();
List recentConnections = _recentConnections.ToList();
foreach (IRecentConnection connection in recentConnections.Where(c => c.ConnectionIntent is IConnectionProfile))
{
IConnectionProfile? profile = profiles.FirstOrDefault(p => p.IsSameAs(connection.ConnectionIntent));
if (profile != null)
{
// Profile may have changed, update the recent connection item
connection.ConnectionIntent = profile;
}
else
{
// Profile no longer exists, remove it from recents
_logger.Info($"Recent connection {connection.ConnectionIntent} has been removed because the profile has been deleted");
_recentConnections.Remove(connection);
}
hasRecentListBeenModified = true;
}
return hasRecentListBeenModified;
}
private bool TryInvalidateRetiredServers()
{
bool hasRecentListBeenModified = false;
bool hasGatewaysOnly = _serversLoader.HasAnyGateways() && !_serversLoader.HasAnyCountries();
List servers = _serversLoader.GetServers().ToList();
List recentConnections = _recentConnections.ToList();
foreach (IRecentConnection connection in recentConnections)
{
bool hasNoServers = connection.ConnectionIntent is not IConnectionProfile &&
connection.ConnectionIntent.HasNoServers(servers, _settings.DeviceLocation);
bool isMultiCountryIntent = connection.ConnectionIntent.Location is MultiCountryLocationIntent;
if (hasNoServers || (hasGatewaysOnly && isMultiCountryIntent))
{
_logger.Info($"Recent connection {connection.ConnectionIntent} has been removed. All servers for this intent have been retired.");
_recentConnections.Remove(connection);
hasRecentListBeenModified = true;
}
}
return hasRecentListBeenModified;
}
private void SaveAndBroadcastRecentConnectionsChanges()
{
SaveRecentConnections();
SetFavoriteServers();
BroadcastRecentConnectionsChanges();
}
private void SetFavoriteServers()
{
List serverIds = _recentConnections
.Where(rc => rc.ConnectionIntent.Location is SingleServerLocationIntent sli)
.OrderByDescending(rc => rc.IsPinned)
.ThenByDescending(rc => rc.LastConnectionTimeUtc)
.Select(rc => ((SingleServerLocationIntent)rc.ConnectionIntent.Location).Server.Id)
.ToList();
_favoriteServersStorage.SetRecentConnectionServerIds(serverIds);
}
public void LoadRecentConnections()
{
lock (_lock)
{
_recentConnections = _recentsFileReaderWriter.Read();
UpdateCurrentConnectionIntent();
}
_areRecentsLoaded = true;
SetFavoriteServers();
}
private void SaveRecentConnections()
{
_recentsFileReaderWriter.Save(_recentConnections.ToList());
}
private void BroadcastRecentConnectionsChanges()
{
_eventMessageSender.Send(new RecentConnectionsChangedMessage());
}
private void TrimRecentConnections()
{
List recentConnections = _recentConnections
.Where(rc => !IsRecentInUseOrPinned(rc))
.OrderByDescending(rc => rc.LastConnectionTimeUtc)
.ToList();
for (int i = MAXIMUM_RECENT_CONNECTIONS; i < recentConnections.Count; i++)
{
_recentConnections.Remove(recentConnections[i]);
}
}
private bool IsRecentInUseOrPinned(IRecentConnection recent)
{
return recent.IsPinned || recent.Id == _settings.DefaultConnection.RecentId;
}
}