diff --git a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs index 86ec5cce..bfacad73 100644 --- a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs +++ b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs @@ -81,6 +81,11 @@ public interface ISynchronizationManager /// IWinoSynchronizerBase CreateSynchronizerForAccount(MailAccount account); + /// + /// Cancels ongoing synchronizations for the given account. + /// + Task CancelSynchronizationsAsync(Guid accountId); + /// /// Destroys the synchronizer for the given account. /// diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index cadaadda..108a08ea 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -26,6 +26,7 @@ public class SynchronizationManager : ISynchronizationManager public static SynchronizationManager Instance => _instance.Value; private readonly ConcurrentDictionary _synchronizerCache = new(); + private readonly ConcurrentDictionary _accountSynchronizationCancellationSources = new(); private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly ILogger _logger = Log.ForContext(); @@ -141,9 +142,14 @@ public class SynchronizationManager : ISynchronizationManager _logger.Information("Starting mail synchronization for account {AccountId} with type {SyncType}", options.AccountId, options.Type); + var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource()); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + accountCancellationSource.Token); + try { - var result = await synchronizer.SynchronizeMailsAsync(options, cancellationToken); + var result = await synchronizer.SynchronizeMailsAsync(options, linkedCancellationTokenSource.Token); _logger.Information("Mail synchronization completed for account {AccountId} with state {State}", options.AccountId, result.CompletedState); @@ -156,6 +162,11 @@ public class SynchronizationManager : ISynchronizationManager return result; } + catch (OperationCanceledException) + { + _logger.Information("Mail synchronization canceled for account {AccountId}", options.AccountId); + return MailSynchronizationResult.Canceled; + } catch (AuthenticationAttentionException authEx) { _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); @@ -350,9 +361,14 @@ public class SynchronizationManager : ISynchronizationManager _logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}", options.AccountId, options.Type); + var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource()); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + accountCancellationSource.Token); + try { - var result = await synchronizer.SynchronizeCalendarEventsAsync(options, cancellationToken); + var result = await synchronizer.SynchronizeCalendarEventsAsync(options, linkedCancellationTokenSource.Token); _logger.Information("Calendar synchronization completed for account {AccountId} with state {State}", options.AccountId, result.CompletedState); @@ -363,6 +379,11 @@ public class SynchronizationManager : ISynchronizationManager return result; } + catch (OperationCanceledException) + { + _logger.Information("Calendar synchronization canceled for account {AccountId}", options.AccountId); + return CalendarSynchronizationResult.Canceled; + } catch (AuthenticationAttentionException authEx) { _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); @@ -491,6 +512,38 @@ public class SynchronizationManager : ISynchronizationManager } } + /// + /// Cancels all in-flight synchronizations for the given account. + /// + /// Account ID to cancel synchronizations for + public Task CancelSynchronizationsAsync(Guid accountId) + { + EnsureInitialized(); + + if (_accountSynchronizationCancellationSources.TryRemove(accountId, out var cancellationSource)) + { + try + { + if (!cancellationSource.IsCancellationRequested) + { + cancellationSource.Cancel(); + } + } + catch (ObjectDisposedException) + { + // no-op + } + finally + { + cancellationSource.Dispose(); + } + + _logger.Information("Canceled ongoing synchronizations for account {AccountId}", accountId); + } + + return Task.CompletedTask; + } + /// /// Destroys the synchronizer for the given account. /// @@ -498,6 +551,7 @@ public class SynchronizationManager : ISynchronizationManager public async Task DestroySynchronizerAsync(Guid accountId) { EnsureInitialized(); + await CancelSynchronizationsAsync(accountId); if (_synchronizerCache.TryRemove(accountId, out var synchronizer)) { @@ -506,6 +560,10 @@ public class SynchronizationManager : ISynchronizationManager await synchronizer.KillSynchronizerAsync(); _logger.Information("Destroyed synchronizer for account {AccountId}", accountId); } + catch (OperationCanceledException) + { + _logger.Information("Synchronizer destruction canceled for account {AccountId}", accountId); + } catch (Exception ex) { _logger.Error(ex, "Failed to destroy synchronizer for account {AccountId}", accountId); diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 35965c35..a711a7e4 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.DependencyInjection; @@ -32,7 +34,13 @@ public partial class App : WinoApplication, IRecipient, IRecipient { + private const int InboxSyncsPerFullSync = 20; private ISynchronizationManager? _synchronizationManager; + private IPreferencesService? _preferencesService; + private IAccountService? _accountService; + private CancellationTokenSource? _autoSynchronizationLoopCts; + private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1); + private readonly Dictionary _inboxSyncCounters = []; public App() { @@ -136,6 +144,12 @@ public partial class App : WinoApplication, await InitializeServicesAsync(); _synchronizationManager = Services.GetRequiredService(); + _preferencesService = Services.GetRequiredService(); + _accountService = Services.GetRequiredService(); + + _preferencesService.PreferenceChanged -= PreferencesServiceChanged; + _preferencesService.PreferenceChanged += PreferencesServiceChanged; + RestartAutoSynchronizationLoop(); // Check if launched from toast notification. if (IsNotificationActivation(out AppNotificationActivatedEventArgs toastArgs)) @@ -463,6 +477,132 @@ public partial class App : WinoApplication, }; } + private void PreferencesServiceChanged(object? sender, string propertyName) + { + if (propertyName != nameof(IPreferencesService.EmailSyncIntervalMinutes)) + return; + + RestartAutoSynchronizationLoop(); + } + + private void RestartAutoSynchronizationLoop() + { + if (_preferencesService == null) + return; + + StopAutoSynchronizationLoop(); + + int intervalMinutes = Math.Max(1, _preferencesService.EmailSyncIntervalMinutes); + _autoSynchronizationLoopCts = new CancellationTokenSource(); + + _ = RunAutoSynchronizationLoopAsync(TimeSpan.FromMinutes(intervalMinutes), _autoSynchronizationLoopCts.Token); + LogActivation($"Automatic sync loop started. Interval: {intervalMinutes} minute(s)."); + } + + private void StopAutoSynchronizationLoop() + { + if (_autoSynchronizationLoopCts == null) + return; + + _autoSynchronizationLoopCts.Cancel(); + _autoSynchronizationLoopCts.Dispose(); + _autoSynchronizationLoopCts = null; + } + + private async Task RunAutoSynchronizationLoopAsync(TimeSpan interval, CancellationToken cancellationToken) + { + try + { + await ExecuteAutoSynchronizationAsync(cancellationToken).ConfigureAwait(false); + + using var timer = new PeriodicTimer(interval); + + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + await ExecuteAutoSynchronizationAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // no-op + } + catch (Exception ex) + { + LogActivation($"Automatic sync loop failed: {ex.Message}"); + } + } + + private async Task ExecuteAutoSynchronizationAsync(CancellationToken cancellationToken) + { + if (_synchronizationManager == null || _accountService == null) + return; + + bool lockTaken = false; + + try + { + lockTaken = await _autoSynchronizationSemaphore.WaitAsync(0, cancellationToken).ConfigureAwait(false); + if (!lockTaken) + return; + + var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + var currentAccountIds = accounts.Select(a => a.Id).ToHashSet(); + _inboxSyncCounters.Keys.Where(a => !currentAccountIds.Contains(a)).ToList().ForEach(a => _inboxSyncCounters.Remove(a)); + + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_synchronizationManager.IsAccountSynchronizing(account.Id)) + continue; + + var inboxSyncOptions = new MailSynchronizationOptions() + { + AccountId = account.Id, + Type = MailSynchronizationType.InboxOnly + }; + + var inboxSyncResult = await _synchronizationManager.SynchronizeMailAsync(inboxSyncOptions, cancellationToken).ConfigureAwait(false); + + if (inboxSyncResult.CompletedState is SynchronizationCompletedState.Success or SynchronizationCompletedState.PartiallyCompleted) + { + _inboxSyncCounters.TryAdd(account.Id, 0); + _inboxSyncCounters[account.Id]++; + + if (_inboxSyncCounters[account.Id] >= InboxSyncsPerFullSync) + { + var fullSyncOptions = new MailSynchronizationOptions() + { + AccountId = account.Id, + Type = MailSynchronizationType.FullFolders + }; + + await _synchronizationManager.SynchronizeMailAsync(fullSyncOptions, cancellationToken).ConfigureAwait(false); + _inboxSyncCounters[account.Id] = 0; + } + } + + if (!account.IsCalendarAccessGranted) + continue; + + var calendarOptions = new CalendarSynchronizationOptions() + { + AccountId = account.Id, + Type = CalendarSynchronizationType.CalendarMetadata + }; + + await _synchronizationManager.SynchronizeCalendarAsync(calendarOptions, cancellationToken).ConfigureAwait(false); + } + } + finally + { + if (lockTaken) + { + _autoSynchronizationSemaphore.Release(); + } + } + } + /// /// Handles activation redirected from another instance (single-instancing). /// This is called when a second instance tries to launch and redirects to this existing instance. diff --git a/Wino.Mail.WinUI/Services/PreferencesService.cs b/Wino.Mail.WinUI/Services/PreferencesService.cs index b6171b64..8b930750 100644 --- a/Wino.Mail.WinUI/Services/PreferencesService.cs +++ b/Wino.Mail.WinUI/Services/PreferencesService.cs @@ -50,12 +50,6 @@ public class PreferencesService(IConfigurationService configurationService) : Ob set => SetPropertyAndSave(nameof(MailItemDisplayMode), value); } - public bool IsSemanticZoomEnabled - { - get => _configurationService.Get(nameof(IsSemanticZoomEnabled), true); - set => SetPropertyAndSave(nameof(IsSemanticZoomEnabled), value); - } - public bool IsHardDeleteProtectionEnabled { get => _configurationService.Get(nameof(IsHardDeleteProtectionEnabled), true);