/*
* 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 System.Reflection;
using System.Runtime.CompilerServices;
using ProtonVPN.Api.Contracts;
using ProtonVPN.Api.Contracts.Features;
using ProtonVPN.Client.Common.Observers;
using ProtonVPN.Client.EventMessaging.Contracts;
using ProtonVPN.Client.Settings.Attributes;
using ProtonVPN.Client.Settings.Contracts;
using ProtonVPN.Client.Settings.Contracts.Messages;
using ProtonVPN.Client.Settings.Contracts.Models;
using ProtonVPN.Client.Settings.Contracts.Observers;
using ProtonVPN.Common.Core.Extensions;
using ProtonVPN.Configurations.Contracts;
using ProtonVPN.IssueReporting.Contracts;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.SettingsLogs;
namespace ProtonVPN.Client.Settings.Observers;
public class FeatureFlagsObserver : PollingObserverBase, IFeatureFlagsObserver
{
private readonly ISettings _settings;
private readonly IApiClient _apiClient;
private readonly IConfiguration _config;
private readonly IEventMessageSender _eventMessageSender;
[FeatureFlag("U2FGatewayPortalUrl")]
public string U2FGatewayPortalUrl => GetPayload();
[FeatureFlag("ProTunV1")]
public bool IsProTunEnabled => IsEnabled();
[FeatureFlag("IsConnectionFeedbackEnabled")]
public bool IsConnectionFeedbackEnabled => IsEnabled();
protected override TimeSpan PollingInterval => _config.FeatureFlagsUpdateInterval;
private static PropertyInfo[] Properties { get; } = typeof(FeatureFlagsObserver).GetProperties();
private static bool HasFeatureFlags { get; } = Properties.Any(prop => prop.IsDefined(typeof(FeatureFlagAttribute)));
public FeatureFlagsObserver(
ILogger logger,
IIssueReporter issueReporter,
ISettings settings,
IApiClient apiClient,
IConfiguration config,
IEventMessageSender eventMessageSender)
: base(logger, issueReporter)
{
_settings = settings;
_apiClient = apiClient;
_config = config;
_eventMessageSender = eventMessageSender;
if (HasFeatureFlags)
{
TriggerAndStartTimer();
}
}
public Task UpdateAsync(CancellationToken cancellationToken)
{
return UpdateFeatureFlagsAsync(cancellationToken);
}
private FeatureFlag GetFeatureFlag([CallerMemberName] string propertyName = "")
{
PropertyInfo? property = GetType().GetProperty(propertyName)
?? throw new InvalidOperationException($"Property '{propertyName}' not found on {GetType().Name}");
FeatureFlagAttribute? featureFlagAttribute = property.GetCustomAttribute()
?? throw new InvalidOperationException($"Property '{propertyName}' is missing [FeatureFlag]");
return _settings.FeatureFlags
.FirstOrDefault(f => f.Name.EqualsIgnoringCase(featureFlagAttribute.Name), FeatureFlag.Default);
}
private bool IsEnabled([CallerMemberName] string propertyName = "")
{
return GetFeatureFlag(propertyName).IsEnabled;
}
private string GetPayload([CallerMemberName] string propertyName = "")
{
FeatureFlag featureFlag = GetFeatureFlag(propertyName);
return featureFlag.IsEnabled
? featureFlag.Payload
: string.Empty;
}
protected override Task OnTriggerAsync()
{
return UpdateFeatureFlagsAsync(CancellationToken.None);
}
private async Task UpdateFeatureFlagsAsync(CancellationToken cancellationToken)
{
try
{
Logger.Info("Fetching feature flags");
ApiResponseResult response = await _apiClient.GetFeatureFlagsAsync(cancellationToken);
if (response.Success)
{
List updatedFeatureFlags = Map(response.Value).ToList();
List changes = GetChanges(updatedFeatureFlags);
_settings.FeatureFlags = updatedFeatureFlags;
if (changes.Count > 0)
{
_eventMessageSender.Send(new FeatureFlagsChangedMessage()
{
Changes = changes,
});
}
}
}
catch (Exception e)
{
Logger.Error("Failed to retrieve feature flags", e);
}
}
private List GetChanges(List updatedFeatureFlags)
{
List changes = [];
List featureFlags = Properties.Where(prop => prop.IsDefined(typeof(FeatureFlagAttribute))).ToList();
foreach (PropertyInfo featureFlagPropertyInfo in featureFlags)
{
string? featureFlagName = featureFlagPropertyInfo.GetCustomAttribute()?.Name;
if (string.IsNullOrEmpty(featureFlagName))
{
continue;
}
bool? oldValue = GetFeatureFlag(_settings.FeatureFlags, featureFlagName)?.IsEnabled;
bool? newValue = GetFeatureFlag(updatedFeatureFlags, featureFlagName)?.IsEnabled;
if (oldValue != newValue)
{
changes.Add(new()
{
// Use property name instead of attribute name so that later we can compare
// using nameof(IFeatureFlagsObserver.FeatureFlag)
Name = featureFlagPropertyInfo.Name,
OldValue = oldValue,
NewValue = newValue,
});
}
}
return changes;
}
private static FeatureFlag? GetFeatureFlag(IReadOnlyList featureFlags, string name)
{
return featureFlags.FirstOrNull(f => f.Name == name);
}
private static List Map(FeatureFlagsResponse featureFlagsResponse)
{
return featureFlagsResponse?.FeatureFlags?.Select(f =>
new FeatureFlag
{
Name = f.Name,
IsEnabled = f.IsEnabled,
Payload = f.Variant?.Payload?.Value ?? string.Empty
}).ToList() ?? [];
}
}