/* * Copyright (c) 2025 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.Api.Contracts; using ProtonVPN.Api.Contracts.Announcements; using ProtonVPN.Client.EventMessaging.Contracts; using ProtonVPN.Client.Logic.Announcements.Contracts; using ProtonVPN.Client.Logic.Announcements.Contracts.Entities; using ProtonVPN.Client.Logic.Announcements.Contracts.Messages; using ProtonVPN.Client.Logic.Announcements.Files; using ProtonVPN.Client.Logic.Announcements.Images; using ProtonVPN.Client.Logic.Auth.Contracts; using ProtonVPN.Client.Logic.Auth.Contracts.Messages; using ProtonVPN.Common.Core.Extensions; using ProtonVPN.EntityMapping.Contracts; using ProtonVPN.Logging.Contracts; using ProtonVPN.Logging.Contracts.Events.AppLogs; namespace ProtonVPN.Client.Logic.Announcements; public class AnnouncementsProvider : IAnnouncementsProvider, IAnnouncementsUpdater, IEventMessageReceiver { private readonly IApiClient _apiClient; private readonly IEntityMapper _entityMapper; private readonly IAnnouncementsFileReaderWriter _announcementsFileReaderWriter; private readonly IEventMessageSender _eventMessageSender; private readonly ILogger _logger; private readonly IUserAuthenticator _userAuthenticator; private readonly IAnnouncementImagesDeleter _announcementImagesDeleter; private readonly ReaderWriterLockSlim _readWriteLock = new(); private readonly SemaphoreSlim _updateLock = new(1, 1); private List _announcements = []; public AnnouncementsProvider(IApiClient apiClient, IEntityMapper entityMapper, IAnnouncementsFileReaderWriter announcementsFileReaderWriter, IEventMessageSender eventMessageSender, ILogger logger, IUserAuthenticator userAuthenticator, IAnnouncementImagesDeleter announcementImagesDeleter) { _apiClient = apiClient; _entityMapper = entityMapper; _announcementsFileReaderWriter = announcementsFileReaderWriter; _eventMessageSender = eventMessageSender; _logger = logger; _userAuthenticator = userAuthenticator; _announcementImagesDeleter = announcementImagesDeleter; } public async Task UpdateAsync() { await _updateLock.WaitAsync(); try { if (_userAuthenticator.IsLoggedIn) { LoadFromFileIfEmpty(); AnnouncementsRequest request = CreateAnnouncementsRequest(); ApiResponseResult response = await _apiClient.GetAnnouncementsAsync(request); if (response.Success) { List announcements = _entityMapper.Map(response.Value.Announcements); ProcessAnnouncements(announcements); SetWithWriteLock(() => SaveToFile(announcements)); } } else { _logger.Info("Ignoring announcements update because there is no user logged in"); } _announcementImagesDeleter.DeleteUnused(); } catch (Exception ex) { _logger.Error("Get announcements failed", ex); } finally { _updateLock.Release(); } } private void LoadFromFileIfEmpty() { if (!HasAnyAnnouncements()) { _logger.Info("Loading announcements from file as the user has none."); List announcements = _announcementsFileReaderWriter.Read(); ProcessAnnouncements(announcements); } } public bool HasAnyAnnouncements() { return GetWithReadLock(() => _announcements is not null && _announcements.Count > 0); } private void ProcessAnnouncements(List newAnnouncements) { SetWithWriteLock(() => { for (int i = 0; i < newAnnouncements.Count; i++) { Announcement newAnnouncement = newAnnouncements[i]; newAnnouncement.Seen = _announcements.FirstOrDefault(a => a.Id == newAnnouncement.Id)?.Seen ?? newAnnouncement.Seen; } _announcements = newAnnouncements; }); } private void SaveToFile(List announcements) { _announcementsFileReaderWriter.Save(announcements); } private AnnouncementsRequest CreateAnnouncementsRequest() { return new AnnouncementsRequest() { FullScreenImageWidth = 1024, FullScreenImageHeight = 768, FullScreenImageSupport = AnnouncementConstants.FULL_SCREEN_IMAGE_FORMAT, }; } public IReadOnlyList GetAllActive() { return GetWithReadLock(() => _announcements.Where(a => a.IsActive())).ToList(); } private T GetWithReadLock(Func func) { _readWriteLock.EnterReadLock(); try { return func(); } finally { _readWriteLock.ExitReadLock(); } } public Announcement? GetActiveById(string id) { return GetWithReadLock(() => _announcements.FirstOrDefault(a => a.Id == id && a.IsActive())); } public Announcement? GetActiveAndUnseenByType(AnnouncementType type) { return GetWithReadLock(() => _announcements .OrderBy(a => a.EndDateTimeUtc) .FirstOrDefault(a => a.Type == type && a.IsActiveAndUnseen()) ); } public void MarkAsSeen(string id) { SetWithWriteLock(() => { _announcements.Where(a => a.Id == id).ForEach(a => a.Seen = true); SaveToFile(_announcements); }); } private void SetWithWriteLock(Action action) { _readWriteLock.EnterWriteLock(); try { action(); } finally { _readWriteLock.ExitWriteLock(); } _eventMessageSender.Send(new AnnouncementListChangedMessage()); } public void Delete(string id) { SetWithWriteLock(() => { Announcement? announcement = _announcements.FirstOrDefault(a => a.Id == id); if (announcement is not null) { try { _announcements.Remove(announcement); SaveToFile(_announcements); } catch (Exception ex) { _logger.Error($"The announcement with ID '{id}' failed to be removed.", ex); } } else { _logger.Info($"The announcement with ID '{id}' doesn't exist."); } }); } public void Receive(LoggedOutMessage message) { SetWithWriteLock(() => { _announcements = []; }); } }