From 1d0fcfb5b054e2516b93e39615ac6a03b0453ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 4 Apr 2026 20:23:20 +0200 Subject: [PATCH] Import functionality for wino accounts, calendar sync UI, bunch of shell improvements --- Directory.Packages.props | 19 +- .../CalendarAppShellViewModel.cs | 37 +- .../Data/GroupedAccountCalendarViewModel.cs | 33 ++ .../IAccountCalendarStateService.cs | 1 + .../Enums/CalendarSynchronizationType.cs | 1 + .../Interfaces/IDialogServiceBase.cs | 7 - .../Interfaces/IMailDialogService.cs | 3 + Wino.Core.Domain/Interfaces/IShellClient.cs | 2 + .../Interfaces/IWinoAccountApiClient.cs | 5 +- .../Interfaces/IWinoAccountDataSyncService.cs | 11 + .../Interfaces/IWinoAccountProfileService.cs | 5 + .../Interfaces/IWinoNavigationService.cs | 1 + Wino.Core.Domain/MenuItems/AccountMenuItem.cs | 1 + Wino.Core.Domain/Misc/CalendarColorPalette.cs | 16 +- .../Accounts/WinoAccountSyncExportResult.cs | 8 + .../Accounts/WinoAccountSyncImportResult.cs | 15 + .../Accounts/WinoAccountSyncSelection.cs | 5 + .../Navigation/ShellModeActivationContext.cs | 5 +- .../Translations/en_US/resources.json | 46 +- .../WinoAccountDataSyncServiceTests.cs | 268 +++++++++ .../WinoAccountProfileServiceTests.cs | 5 +- .../SettingOptionsPageViewModel.cs | 5 + .../WinoAccountManagementPageViewModel.cs | 125 +++- Wino.Core/CoreContainerSetup.cs | 4 + .../Extensions/GoogleIntegratorExtensions.cs | 6 +- .../Extensions/OutlookIntegratorExtensions.cs | 6 +- Wino.Core/Misc/ColorHelpers.cs | 58 +- .../GmailSynchronizerErrorHandlingFactory.cs | 2 + ...OutlookSynchronizerErrorHandlingFactory.cs | 4 +- Wino.Core/Services/SynchronizationManager.cs | 163 ++++- .../Gmail/GmailAuthenticationFailedHandler.cs | 68 +++ .../Imap/ImapAuthenticationFailedHandler.cs | 30 +- .../OutlookAuthenticationFailedHandler.cs | 83 +++ Wino.Core/Synchronizers/GmailSynchronizer.cs | 24 +- Wino.Core/Synchronizers/ImapSynchronizer.cs | 18 +- .../Synchronizers/OutlookSynchronizer.cs | 22 +- .../AccountDetailsPageViewModel.cs | 7 + .../ImapCalDavSettingsPageViewModel.cs | 23 +- Wino.Mail.ViewModels/MailAppShellViewModel.cs | 88 ++- .../WelcomePageV2ViewModel.cs | 88 ++- Wino.Mail.WinUI/AnimatedVisuals/Sync.cs | 556 ++++++++++++++++++ Wino.Mail.WinUI/App.xaml.cs | 198 +++++-- .../Controls/CalendarTitleBarContent.xaml | 5 +- .../Controls/SyncAnimationControl.xaml | 14 + .../Controls/SyncAnimationControl.xaml.cs | 54 ++ Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml | 43 -- .../Dialogs/WhatIsNewDialog.xaml.cs | 54 -- .../Dialogs/WinoAccountSyncExportDialog.xaml | 76 +++ .../WinoAccountSyncExportDialog.xaml.cs | 73 +++ .../Services/AccountCalendarStateService.cs | 69 ++- Wino.Mail.WinUI/Services/DialogService.cs | 23 +- Wino.Mail.WinUI/Services/DialogServiceBase.cs | 16 +- Wino.Mail.WinUI/Services/NavigationService.cs | 13 +- .../Views/Account/AccountDetailsPage.xaml | 176 +----- .../Views/Account/ImapCalDavSettingsPage.xaml | 4 +- Wino.Mail.WinUI/Views/SettingOptionsPage.xaml | 5 + .../Views/SettingOptionsPage.xaml.cs | 5 + Wino.Mail.WinUI/Views/WelcomePageV2.xaml | 28 + Wino.Mail.WinUI/Views/WinoAppShell.xaml | 75 ++- Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs | 36 ++ Wino.Mail.WinUI/Wino.Mail.WinUI.csproj | 1 + ...ountCalendarSynchronizationStateChanged.cs | 13 + .../UI/WelcomeImportCompletedMessage.cs | 3 + Wino.Services/AccountService.cs | 16 +- Wino.Services/ServicesContainerSetup.cs | 1 + Wino.Services/WinoAccountApiClient.cs | 114 +++- Wino.Services/WinoAccountDataSyncService.cs | 289 +++++++++ Wino.Services/WinoAccountProfileService.cs | 33 ++ 68 files changed, 2792 insertions(+), 519 deletions(-) create mode 100644 Wino.Core.Domain/Interfaces/IWinoAccountDataSyncService.cs create mode 100644 Wino.Core.Domain/Models/Accounts/WinoAccountSyncExportResult.cs create mode 100644 Wino.Core.Domain/Models/Accounts/WinoAccountSyncImportResult.cs create mode 100644 Wino.Core.Domain/Models/Accounts/WinoAccountSyncSelection.cs create mode 100644 Wino.Core.Tests/Services/WinoAccountDataSyncServiceTests.cs create mode 100644 Wino.Core/Synchronizers/Errors/Gmail/GmailAuthenticationFailedHandler.cs create mode 100644 Wino.Core/Synchronizers/Errors/Outlook/OutlookAuthenticationFailedHandler.cs create mode 100644 Wino.Mail.WinUI/AnimatedVisuals/Sync.cs create mode 100644 Wino.Mail.WinUI/Controls/SyncAnimationControl.xaml create mode 100644 Wino.Mail.WinUI/Controls/SyncAnimationControl.xaml.cs delete mode 100644 Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml delete mode 100644 Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml.cs create mode 100644 Wino.Mail.WinUI/Dialogs/WinoAccountSyncExportDialog.xaml create mode 100644 Wino.Mail.WinUI/Dialogs/WinoAccountSyncExportDialog.xaml.cs create mode 100644 Wino.Messages/UI/AccountCalendarSynchronizationStateChanged.cs create mode 100644 Wino.Messages/UI/WelcomeImportCompletedMessage.cs create mode 100644 Wino.Services/WinoAccountDataSyncService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 3c780068..030a09ac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,9 +4,9 @@ - - - + + + @@ -15,6 +15,7 @@ + @@ -24,11 +25,11 @@ - + - - + + @@ -39,7 +40,7 @@ - + @@ -48,7 +49,7 @@ - + @@ -66,7 +67,7 @@ - + diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index 480bd580..43cf07b7 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -52,6 +52,9 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, System.Windows.Input.ICommand ICalendarShellClient.DateClickedCommand => DateClickedCommand; System.Windows.Input.ICommand ICalendarShellClient.PreviousDateRangeCommand => PreviousDateRangeCommand; System.Windows.Input.ICommand ICalendarShellClient.NextDateRangeCommand => NextDateRangeCommand; + System.Windows.Input.ICommand ICalendarShellClient.SyncCommand => SyncCommand; + + public bool CanSynchronizeCalendars => !AccountCalendarStateService.IsAnySynchronizationInProgress; public MenuItemCollection MenuItems { get; private set; } public MenuItemCollection FooterItems { get; private set; } @@ -75,7 +78,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, private readonly SemaphoreSlim _accountCalendarUpdateSemaphoreSlim = new(1); private readonly CalendarPageViewModel _calendarPageViewModel; private readonly IMailDialogService _dialogService; - private readonly IUpdateManager _updateManager; private readonly IStoreUpdateService _storeUpdateService; private readonly IAccountService _accountService; private readonly ICalendarService _calendarService; @@ -93,7 +95,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, INavigationService navigationService, CalendarPageViewModel calendarPageViewModel, IMailDialogService dialogService, - IUpdateManager updateManager, IStoreUpdateService storeUpdateService, IDateContextProvider dateContextProvider) { @@ -105,11 +106,11 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, _calendarService = calendarService; _calendarPageViewModel = calendarPageViewModel; _dialogService = dialogService; - _updateManager = updateManager; _storeUpdateService = storeUpdateService; _dateContextProvider = dateContextProvider; _calendarPageViewModel.PropertyChanged += CalendarPageViewModelPropertyChanged; + AccountCalendarStateService.PropertyChanged += AccountCalendarStateServicePropertyChanged; } protected override void OnDispatcherAssigned() @@ -177,11 +178,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, await InitializeAccountCalendarsAsync(); ValidateConfiguredNewEventCalendar(); - if (shouldRunStartupFlows) - { - await ShowWhatIsNewIfNeededAsync(); - } - TodayClicked(); } @@ -218,6 +214,15 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, _calendarPageViewModel.CleanupForShellDeactivation(); } + private void AccountCalendarStateServicePropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(IAccountCalendarStateService.IsAnySynchronizationInProgress)) + return; + + OnPropertyChanged(nameof(CanSynchronizeCalendars)); + SyncCommand.NotifyCanExecuteChanged(); + } + private void AttachRuntimeSubscriptions() { if (_runtimeSubscriptionsAttached) @@ -240,18 +245,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, _runtimeSubscriptionsAttached = false; } - private async Task ShowWhatIsNewIfNeededAsync() - { - if (!_updateManager.ShouldShowUpdateNotes()) - return; - - var notes = await _updateManager.GetLatestUpdateNotesAsync(); - if (notes.Sections.Count == 0) - return; - - await _dialogService.ShowWhatIsNewDialogAsync(notes); - } - private async Task RefreshFooterItemsAsync(bool showNotification) { await ExecuteUIThread(() => @@ -326,7 +319,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, _navigationDate = null; } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanSynchronizeCalendars))] private async Task Sync() { var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); @@ -335,7 +328,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions { AccountId = account.Id, - Type = CalendarSynchronizationType.CalendarEvents + Type = CalendarSynchronizationType.Strict })); } } diff --git a/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs b/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs index cf5df346..0994b8c0 100644 --- a/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/GroupedAccountCalendarViewModel.cs @@ -20,6 +20,7 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject { Account = account; AccountCalendars = new ObservableCollection(calendarViewModels); + AccountColorHex = account.AccountColorHex; ManageIsCheckedState(); @@ -74,6 +75,18 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject [ObservableProperty] public partial bool? IsCheckedState { get; set; } = true; + [ObservableProperty] + public partial string AccountColorHex { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool IsSynchronizationInProgress { get; set; } + + [ObservableProperty] + public partial string SynchronizationStatus { get; set; } = string.Empty; + + public bool CanSynchronize => !IsSynchronizationInProgress; + public bool IsSynchronizationProgressVisible => IsSynchronizationInProgress; + private bool _isExternalPropChangeBlocked = false; private void ManageIsCheckedState() @@ -142,4 +155,24 @@ public partial class GroupedAccountCalendarViewModel : ObservableObject CalendarSelectionStateChanged?.Invoke(this, accountCalendarViewModel); } + + partial void OnIsSynchronizationInProgressChanged(bool value) + { + OnPropertyChanged(nameof(CanSynchronize)); + OnPropertyChanged(nameof(IsSynchronizationProgressVisible)); + } + + public void UpdateAccount(MailAccount updatedAccount) + { + if (updatedAccount == null || updatedAccount.Id != Account.Id) + return; + + Account.Name = updatedAccount.Name; + Account.Address = updatedAccount.Address; + Account.AccountColorHex = updatedAccount.AccountColorHex; + Account.AttentionReason = updatedAccount.AttentionReason; + Account.MergedInboxId = updatedAccount.MergedInboxId; + AccountColorHex = updatedAccount.AccountColorHex; + OnPropertyChanged(nameof(Account)); + } } diff --git a/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs b/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs index e43f29b7..d639040d 100644 --- a/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs +++ b/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs @@ -29,5 +29,6 @@ public interface IAccountCalendarStateService : INotifyPropertyChanged /// IEnumerable ActiveCalendars { get; } IEnumerable AllCalendars { get; } + bool IsAnySynchronizationInProgress { get; } ReadOnlyObservableGroupedCollection GroupedCalendars { get; set; } } diff --git a/Wino.Core.Domain/Enums/CalendarSynchronizationType.cs b/Wino.Core.Domain/Enums/CalendarSynchronizationType.cs index d9f01a19..96351243 100644 --- a/Wino.Core.Domain/Enums/CalendarSynchronizationType.cs +++ b/Wino.Core.Domain/Enums/CalendarSynchronizationType.cs @@ -5,6 +5,7 @@ public enum CalendarSynchronizationType ExecuteRequests, // Execute all requests in the queue. CalendarMetadata, // Sync calendar metadata. CalendarEvents, // Sync all events for all calendars. + Strict, // Run metadata and event synchronization in sequence. SingleCalendar, // Sync events for only specified calendars. UpdateProfile // Update profile information only. } diff --git a/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs b/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs index 18f59bab..c915d5b9 100644 --- a/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs +++ b/Wino.Core.Domain/Interfaces/IDialogServiceBase.cs @@ -5,7 +5,6 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Common; using Wino.Core.Domain.Models.Printing; -using Wino.Core.Domain.Models.Updates; namespace Wino.Core.Domain.Interfaces; @@ -32,10 +31,4 @@ public interface IDialogServiceBase Task> PickFilesMetadataAsync(params object[] typeFilters); Task PickFilePathAsync(string saveFileName); Task ShowPrintDialogAsync(WebView2PrintSettingsModel initialSettings = null); - - /// - /// Presents the "What's New" dialog for the current version. - /// This dialog is undismissable and runs any pending migrations when the user clicks "Get Started". - /// - Task ShowWhatIsNewDialogAsync(UpdateNotes notes); } diff --git a/Wino.Core.Domain/Interfaces/IMailDialogService.cs b/Wino.Core.Domain/Interfaces/IMailDialogService.cs index 007c9572..dc60c422 100644 --- a/Wino.Core.Domain/Interfaces/IMailDialogService.cs +++ b/Wino.Core.Domain/Interfaces/IMailDialogService.cs @@ -8,6 +8,7 @@ using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Folders; @@ -74,4 +75,6 @@ public interface IMailDialogService : IDialogServiceBase Task ShowWinoAccountRegistrationDialogAsync(); Task ShowWinoAccountLoginDialogAsync(); + + Task ShowWinoAccountExportDialogAsync(); } diff --git a/Wino.Core.Domain/Interfaces/IShellClient.cs b/Wino.Core.Domain/Interfaces/IShellClient.cs index db5f6483..ab9ad0c9 100644 --- a/Wino.Core.Domain/Interfaces/IShellClient.cs +++ b/Wino.Core.Domain/Interfaces/IShellClient.cs @@ -51,6 +51,8 @@ public interface ICalendarShellClient : IShellClient int SelectedDateNavigationHeaderIndex { get; } VisibleDateRange? CurrentVisibleRange { get; } string VisibleDateRangeText { get; } + bool CanSynchronizeCalendars { get; } + ICommand SyncCommand { get; } ICommand TodayClickedCommand { get; } ICommand DateClickedCommand { get; } ICommand PreviousDateRangeCommand { get; } diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs index a76b1764..79d21e29 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs @@ -6,6 +6,7 @@ using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Common; +using Wino.Mail.Api.Contracts.Users; namespace Wino.Core.Domain.Interfaces; @@ -26,5 +27,7 @@ public interface IWinoAccountApiClient Task> CreatePurchaseIdTicketAsync(CancellationToken cancellationToken = default); Task> SyncStoreEntitlementsAsync(string? storeIdKey, string? purchaseIdKey, CancellationToken cancellationToken = default); Task GetSettingsAsync(CancellationToken cancellationToken = default); - Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default); + Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default); + Task GetMailboxesAsync(CancellationToken cancellationToken = default); + Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountDataSyncService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountDataSyncService.cs new file mode 100644 index 00000000..da08a933 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IWinoAccountDataSyncService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using Wino.Core.Domain.Models.Accounts; + +namespace Wino.Core.Domain.Interfaces; + +public interface IWinoAccountDataSyncService +{ + Task ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default); + Task ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default); +} diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs index 7857c8cf..d91ffa0e 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -8,6 +8,7 @@ using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Common; +using Wino.Mail.Api.Contracts.Users; namespace Wino.Core.Domain.Interfaces; @@ -28,6 +29,10 @@ public interface IWinoAccountProfileService Task> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default); Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default); Task> SyncStoreEntitlementsAsync(CancellationToken cancellationToken = default); + Task GetSettingsAsync(CancellationToken cancellationToken = default); + Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default); + Task GetMailboxesAsync(CancellationToken cancellationToken = default); + Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default); Task ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default); Task SignOutAsync(CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs b/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs index 328f08ac..458f820c 100644 --- a/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs @@ -13,6 +13,7 @@ public interface INavigationService Type GetPageType(WinoPage winoPage); bool ChangeApplicationMode(WinoApplicationMode mode); + bool ChangeApplicationMode(WinoApplicationMode mode, ShellModeActivationContext activationContext); bool CanGoBack(); void GoBack(NavigationTransitionEffect slideEffect = NavigationTransitionEffect.FromRight); } diff --git a/Wino.Core.Domain/MenuItems/AccountMenuItem.cs b/Wino.Core.Domain/MenuItems/AccountMenuItem.cs index 46a480f1..fc836dbe 100644 --- a/Wino.Core.Domain/MenuItems/AccountMenuItem.cs +++ b/Wino.Core.Domain/MenuItems/AccountMenuItem.cs @@ -111,6 +111,7 @@ public partial class AccountMenuItem : MenuItemBase GetColors() => FlatUiColorPalette; diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountSyncExportResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountSyncExportResult.cs new file mode 100644 index 00000000..998bfcb1 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountSyncExportResult.cs @@ -0,0 +1,8 @@ +namespace Wino.Core.Domain.Models.Accounts; + +public sealed class WinoAccountSyncExportResult +{ + public bool IncludedPreferences { get; init; } + public bool IncludedAccounts { get; init; } + public int ExportedMailboxCount { get; init; } +} diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountSyncImportResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountSyncImportResult.cs new file mode 100644 index 00000000..df2ebcf5 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountSyncImportResult.cs @@ -0,0 +1,15 @@ +namespace Wino.Core.Domain.Models.Accounts; + +public sealed class WinoAccountSyncImportResult +{ + public bool IncludedPreferences { get; init; } + public bool IncludedAccounts { get; init; } + public bool HadRemotePreferences { get; init; } + public int AppliedPreferenceCount { get; init; } + public int FailedPreferenceCount { get; init; } + public int ImportedMailboxCount { get; init; } + public int SkippedDuplicateMailboxCount { get; init; } + public int RemoteMailboxCount { get; init; } + + public bool HasAnyRemoteData => HadRemotePreferences || RemoteMailboxCount > 0; +} diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountSyncSelection.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountSyncSelection.cs new file mode 100644 index 00000000..9116c197 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountSyncSelection.cs @@ -0,0 +1,5 @@ +namespace Wino.Core.Domain.Models.Accounts; + +public sealed record WinoAccountSyncSelection( + bool IncludePreferences = true, + bool IncludeAccounts = true); diff --git a/Wino.Core.Domain/Models/Navigation/ShellModeActivationContext.cs b/Wino.Core.Domain/Models/Navigation/ShellModeActivationContext.cs index ccdfaac0..eb3ea9ca 100644 --- a/Wino.Core.Domain/Models/Navigation/ShellModeActivationContext.cs +++ b/Wino.Core.Domain/Models/Navigation/ShellModeActivationContext.cs @@ -1,7 +1,10 @@ +#nullable enable + namespace Wino.Core.Domain.Models.Navigation; public sealed class ShellModeActivationContext { public bool IsInitialActivation { get; init; } - public object Parameter { get; init; } + public bool SuppressStartupFlows { get; init; } + public object? Parameter { get; init; } } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 51c0b3b1..670c618f 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -104,6 +104,9 @@ "SyncAction_SettingFlag": "Flagging {0} mail(s)", "SyncAction_SynchronizingAccount": "Synchronizing {0}", "SyncAction_SynchronizingAccounts": "Synchronizing {0} account(s)", + "SyncAction_SynchronizingCalendarData": "Synchronizing calendar data", + "SyncAction_SynchronizingCalendarEvents": "Synchronizing calendar events", + "SyncAction_SynchronizingCalendarMetadata": "Synchronizing calendar metadata", "SyncAction_Unarchiving": "Unarchiving {0} mail(s)", "CalendarAllDayEventSummary": "all-day events", "CalendarDisplayOptions_Color": "Color", @@ -313,6 +316,8 @@ "Exception_CustomThemeMissingName": "You must provide a name.", "Exception_CustomThemeMissingWallpaper": "You must provide a custom background image.", "Exception_FailedToSynchronizeAliases": "Failed to synchronize aliases", + "Exception_FailedToSynchronizeCalendarData": "Failed to synchronize calendar data", + "Exception_FailedToSynchronizeCalendarEvents": "Failed to synchronize calendar events", "Exception_FailedToSynchronizeCalendarMetadata": "Failed to synchronize calendar details", "Exception_FailedToSynchronizeFolders": "Failed to synchronize folders", "Exception_FailedToSynchronizeProfileInformation": "Failed to synchronize profile information", @@ -505,6 +510,11 @@ "Info_AccountDeletedMessage": "{0} is successfuly deleted.", "Info_AccountDeletedTitle": "Account Deleted", "Info_AccountIssueFixFailedTitle": "Failed", + "Info_AccountIssueFixImapMessage": "Open the IMAP and calendar settings page to enter your server credentials again.", + "Info_AccountAttentionRequiredMessage": "This account needs your attention.", + "Info_AccountAttentionRequiredClickableMessage": "Click to fix this account and resynchronize it.", + "Info_AccountAttentionRequiredAction": "Fix", + "Info_AccountAttentionRequiredActionHint": "Click Fix to resolve this account issue.", "Info_AccountIssueFixSuccessMessage": "Fixed all account issues.", "Info_AccountIssueFixSuccessTitle": "Success", "Info_AttachmentOpenFailedMessage": "Can't open this attachment.", @@ -678,6 +688,9 @@ "SettingConfigureSpecialFolders_Button": "Configure", "SettingsEditAccountDetails_IMAPConfiguration_Title": "IMAP/SMTP Configuration", "SettingsEditAccountDetails_IMAPConfiguration_Description": "Change your incoming/outgoing server settings.", + "SettingsEditAccountDetails_ImapCalDavSettings_Title": "IMAP and calendar settings", + "SettingsEditAccountDetails_ImapCalDavSettings_Description": "Open the dedicated IMAP, SMTP, and CalDAV settings page for this account.", + "SettingsEditAccountDetails_ImapCalDavSettings_Action": "Open settings", "SettingsAbout_Description": "Learn more about Wino.", "SettingsAbout_Title": "About", "SettingsAboutGithub_Description": "Go to issue tracker GitHub repository.", @@ -1222,6 +1235,10 @@ "WelcomeWindow_FeaturesTab": "Features", "WelcomeWindow_GetStartedButton": "Get started by adding an account", "WelcomeWindow_GetStartedDescription": "Add your Outlook, Gmail, or IMAP account to get started with Wino Mail.", + "WelcomeWindow_ImportFromWinoAccount": "Import from your Wino Account", + "WelcomeWindow_ImportInProgress": "Importing your synchronized preferences and accounts...", + "WelcomeWindow_ImportNoAccountsFound": "No synced accounts were found in your Wino Account. If preferences were available, they were restored. Use Get started to add an account manually.", + "WelcomeWindow_ImportDuplicateAccountsSkipped": "{0} synced accounts are already available on this device. Use Get started to add another account manually if needed.", "WelcomeWindow_SetupTitle": "Set up your account", "WelcomeWindow_SetupSubtitle": "Choose your email provider to get started", "WelcomeWindow_AddAccountButton": "Add account", @@ -1233,7 +1250,7 @@ "WinoAccount_Titlebar_SyncBenefitDescription": "Keep your Wino preferences in sync across devices.", "WinoAccount_Titlebar_AddonsBenefitTitle": "Unlock add-ons", "WinoAccount_Titlebar_AddonsBenefitDescription": "Access premium features like Wino AI Pack.", - "WinoAccount_Management_Description": "Manage your Wino Account, AI Pack access, and synchronized settings.", + "WinoAccount_Management_Description": "Manage your Wino Account, AI Pack access, and synchronized preferences and account details.", "WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail", "WinoAccount_Management_SignedOutDescription": "Sign in or create an account to sync your email, access AI features, and manage your settings across devices.", "WinoAccount_Management_ProfileSectionHeader": "Profile", @@ -1273,18 +1290,31 @@ "WinoAccount_Management_AiPackFeatureRewrite": "Rewrite", "WinoAccount_Management_AiPackFeatureSummarize": "Summarize", "WinoAccount_Management_AddOnLoadFailed": "We had issues loading this add-on.", - "WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences", - "WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.", + "WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences and Accounts", + "WinoAccount_Management_SyncPreferencesDescription": "Import or export your Wino preferences and mailbox details across devices. Passwords, tokens, and other sensitive information are never synced.", "WinoAccount_Management_SignOutTitle": "Sign out", "WinoAccount_Management_SignOutDescription": "Sign out of your account on this device", "WinoAccount_Management_StatusLabel": "Status: {0}", - "WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.", - "WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.", - "WinoAccount_Management_ImportSucceeded": "Imported {0} settings from your Wino Account.", - "WinoAccount_Management_ImportPartial": "Imported {0} settings. {1} settings could not be restored.", + "WinoAccount_Management_NoRemoteSettings": "There is no synchronized data stored for this account yet.", + "WinoAccount_Management_ExportSucceeded": "Your selected Wino data was exported successfully.", + "WinoAccount_Management_ExportPreferencesSucceeded": "Your preferences were exported to your Wino Account.", + "WinoAccount_Management_ExportAccountsSucceeded": "Exported {0} account details to your Wino Account.", + "WinoAccount_Management_ImportSucceeded": "Imported synchronized data from your Wino Account.", + "WinoAccount_Management_ImportPreferencesSucceeded": "Applied {0} synchronized preferences.", + "WinoAccount_Management_ImportAccountsSucceeded": "Imported {0} accounts.", + "WinoAccount_Management_ImportDuplicateAccountsSkipped": "Skipped {0} accounts that already exist on this device.", + "WinoAccount_Management_ImportPartial": "Applied {0} synchronized preferences. {1} preferences could not be restored.", + "WinoAccount_Management_ImportReloginReminder": "Passwords, tokens, and other sensitive information were not imported. Sign in again for each account on this device before using it.", "WinoAccount_Management_SerializeFailed": "Wino could not serialize your current preferences.", "WinoAccount_Management_EmptyExport": "There are no preference values to export.", - "WinoAccount_Management_ImportEmpty": "The synchronized settings payload does not contain any values to restore.", + "WinoAccount_Management_ImportEmpty": "The synchronized data payload does not contain anything new to restore.", + "WinoAccount_Management_ExportDialog_Title": "Export to your Wino Account", + "WinoAccount_Management_ExportDialog_Description": "Choose what you want to sync to your Wino Account.", + "WinoAccount_Management_ExportDialog_IncludePreferences": "Preferences", + "WinoAccount_Management_ExportDialog_IncludeAccounts": "Accounts", + "WinoAccount_Management_ExportDialog_AccountsDisclaimer": "Passwords, tokens, and other sensitive information are not synced.", + "WinoAccount_Management_ExportDialog_AccountsRelogin": "Imported accounts on another PC will still need you to sign in again before they can be used.", + "WinoAccount_Management_ExportDialog_InProgress": "Exporting your selected Wino data...", "WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.", "WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.", "WinoAccount_SettingsSection_Title": "Wino Account", diff --git a/Wino.Core.Tests/Services/WinoAccountDataSyncServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountDataSyncServiceTests.cs new file mode 100644 index 00000000..770171e3 --- /dev/null +++ b/Wino.Core.Tests/Services/WinoAccountDataSyncServiceTests.cs @@ -0,0 +1,268 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; +using Wino.Core.Tests.Helpers; +using Wino.Mail.Api.Contracts.Users; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public sealed class WinoAccountDataSyncServiceTests : IAsyncLifetime +{ + private InMemoryDatabaseService _databaseService = null!; + private Mock _profileService = null!; + private Mock _preferencesService = null!; + private AccountService _accountService = null!; + private WinoAccountDataSyncService _service = null!; + + public async Task InitializeAsync() + { + _databaseService = new InMemoryDatabaseService(); + await _databaseService.InitializeAsync(); + + _profileService = new Mock(MockBehavior.Strict); + _preferencesService = new Mock(); + _preferencesService.SetupProperty(a => a.StartupEntityId); + + _accountService = CreateAccountService(_databaseService, _preferencesService.Object); + _service = new WinoAccountDataSyncService(_profileService.Object, _preferencesService.Object, _accountService); + } + + public async Task DisposeAsync() + { + await _databaseService.DisposeAsync(); + } + + [Fact] + public async Task ExportAsync_ImapMailbox_MapsSanitizedPayload() + { + var accountId = Guid.NewGuid(); + + await _accountService.CreateAccountAsync( + new MailAccount + { + Id = accountId, + Name = "Custom IMAP", + SenderName = "Custom IMAP Sender", + Address = "imap@example.com", + ProviderType = MailProviderType.IMAP4, + SpecialImapProvider = SpecialImapProvider.iCloud, + AccountColorHex = "#123456", + IsCalendarAccessGranted = true, + SynchronizationDeltaIdentifier = "delta-token", + CalendarSynchronizationDeltaIdentifier = "calendar-delta", + Base64ProfilePictureData = "profile" + }, + new CustomServerInformation + { + Id = Guid.NewGuid(), + AccountId = accountId, + Address = "imap@example.com", + IncomingServer = "imap.example.com", + IncomingServerPort = "993", + IncomingServerUsername = "imap-user", + IncomingServerPassword = "secret-incoming", + IncomingServerSocketOption = ImapConnectionSecurity.Auto, + IncomingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword, + OutgoingServer = "smtp.example.com", + OutgoingServerPort = "465", + OutgoingServerUsername = "smtp-user", + OutgoingServerPassword = "secret-outgoing", + OutgoingServerSocketOption = ImapConnectionSecurity.Auto, + OutgoingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword, + CalendarSupportMode = ImapCalendarSupportMode.CalDav, + CalDavServiceUrl = "https://dav.example.com", + CalDavUsername = "dav-user", + CalDavPassword = "secret-caldav", + ProxyServer = "proxy.example.com", + ProxyServerPort = "8080", + MaxConcurrentClients = 7 + }); + + ReplaceUserMailboxesRequestDto? capturedRequest = null; + _profileService + .Setup(a => a.ReplaceMailboxesAsync(It.IsAny(), It.IsAny())) + .Callback((request, _) => capturedRequest = request) + .Returns(Task.CompletedTask); + + var result = await _service.ExportAsync(new WinoAccountSyncSelection(IncludePreferences: false, IncludeAccounts: true)); + + result.ExportedMailboxCount.Should().Be(1); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Mailboxes.Should().ContainSingle(); + + var exportedMailbox = capturedRequest.Mailboxes[0]; + exportedMailbox.Address.Should().Be("imap@example.com"); + exportedMailbox.ProviderType.Should().Be((int)MailProviderType.IMAP4); + exportedMailbox.SpecialImapProvider.Should().Be((int)SpecialImapProvider.iCloud); + exportedMailbox.AccountName.Should().Be("Custom IMAP"); + exportedMailbox.SenderName.Should().Be("Custom IMAP Sender"); + exportedMailbox.AccountColorHex.Should().Be("#123456"); + exportedMailbox.IsCalendarAccessGranted.Should().BeTrue(); + exportedMailbox.IncomingServer.Should().Be("imap.example.com"); + exportedMailbox.IncomingServerUsername.Should().Be("imap-user"); + exportedMailbox.OutgoingServer.Should().Be("smtp.example.com"); + exportedMailbox.OutgoingServerUsername.Should().Be("smtp-user"); + exportedMailbox.CalDavServiceUrl.Should().Be("https://dav.example.com"); + exportedMailbox.CalDavUsername.Should().Be("dav-user"); + exportedMailbox.ProxyServer.Should().Be("proxy.example.com"); + exportedMailbox.ProxyServerPort.Should().Be("8080"); + exportedMailbox.MaxConcurrentClients.Should().Be(7); + + _profileService.Verify(a => a.SaveSettingsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ExportAsync_GmailMailbox_DoesNotIncludeCustomServerSettings() + { + await _accountService.CreateAccountAsync( + new MailAccount + { + Id = Guid.NewGuid(), + Name = "Gmail", + SenderName = "Gmail Sender", + Address = "gmail@example.com", + ProviderType = MailProviderType.Gmail + }, + null!); + + ReplaceUserMailboxesRequestDto? capturedRequest = null; + _profileService + .Setup(a => a.ReplaceMailboxesAsync(It.IsAny(), It.IsAny())) + .Callback((request, _) => capturedRequest = request) + .Returns(Task.CompletedTask); + + await _service.ExportAsync(new WinoAccountSyncSelection(IncludePreferences: false, IncludeAccounts: true)); + + var exportedMailbox = capturedRequest!.Mailboxes.Single(); + exportedMailbox.IncomingServer.Should().BeNull(); + exportedMailbox.OutgoingServer.Should().BeNull(); + exportedMailbox.CalDavServiceUrl.Should().BeNull(); + exportedMailbox.MaxConcurrentClients.Should().BeNull(); + } + + [Fact] + public async Task ImportAsync_SkipsDuplicateMailbox_ByAddressAndProviderCaseInsensitive() + { + await _accountService.CreateAccountAsync( + new MailAccount + { + Id = Guid.NewGuid(), + Name = "Existing Gmail", + SenderName = "Existing Gmail", + Address = "User@Example.com", + ProviderType = MailProviderType.Gmail + }, + null!); + + _profileService + .Setup(a => a.GetMailboxesAsync(It.IsAny())) + .ReturnsAsync(new UserMailboxSyncListDto( + [ + new UserMailboxSyncItemDto + { + Address = "user@example.com", + ProviderType = (int)MailProviderType.Gmail, + AccountName = "Duplicate Gmail" + }, + new UserMailboxSyncItemDto + { + Address = "second@example.com", + ProviderType = (int)MailProviderType.Outlook, + AccountName = "New Outlook" + } + ])); + + var result = await _service.ImportAsync(new WinoAccountSyncSelection(IncludePreferences: false, IncludeAccounts: true)); + + result.ImportedMailboxCount.Should().Be(1); + result.SkippedDuplicateMailboxCount.Should().Be(1); + + var accounts = await _accountService.GetAccountsAsync(); + accounts.Should().HaveCount(2); + accounts.Should().Contain(a => a.Address == "second@example.com" && a.ProviderType == MailProviderType.Outlook); + } + + [Fact] + public async Task ImportAsync_ImapMailbox_CreatesRootAliasAndInvalidCredentialsAttentionWithoutPasswords() + { + _profileService + .Setup(a => a.GetMailboxesAsync(It.IsAny())) + .ReturnsAsync(new UserMailboxSyncListDto( + [ + new UserMailboxSyncItemDto + { + Address = "imap@example.com", + ProviderType = (int)MailProviderType.IMAP4, + SpecialImapProvider = (int)SpecialImapProvider.Yahoo, + AccountName = "Imported IMAP", + SenderName = "Imported Sender", + CalendarSupportMode = (int)ImapCalendarSupportMode.CalDav, + IncomingServer = "imap.example.com", + IncomingServerPort = "993", + IncomingServerUsername = "imap-user", + IncomingServerSocketOption = (int)ImapConnectionSecurity.Auto, + IncomingAuthenticationMethod = (int)ImapAuthenticationMethod.NormalPassword, + OutgoingServer = "smtp.example.com", + OutgoingServerPort = "465", + OutgoingServerUsername = "smtp-user", + OutgoingServerSocketOption = (int)ImapConnectionSecurity.Auto, + OutgoingAuthenticationMethod = (int)ImapAuthenticationMethod.NormalPassword, + CalDavServiceUrl = "https://dav.example.com", + CalDavUsername = "dav-user", + MaxConcurrentClients = 9 + } + ])); + + var result = await _service.ImportAsync(new WinoAccountSyncSelection(IncludePreferences: false, IncludeAccounts: true)); + + result.ImportedMailboxCount.Should().Be(1); + + var importedAccount = (await _accountService.GetAccountsAsync()).Single(); + importedAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials); + importedAccount.SynchronizationDeltaIdentifier.Should().BeEmpty(); + importedAccount.CalendarSynchronizationDeltaIdentifier.Should().BeEmpty(); + + var importedAliases = await _accountService.GetAccountAliasesAsync(importedAccount.Id); + importedAliases.Should().ContainSingle(a => a.IsRootAlias && a.IsPrimary && a.AliasAddress == "imap@example.com"); + + var serverInformation = await _accountService.GetAccountCustomServerInformationAsync(importedAccount.Id); + serverInformation.Should().NotBeNull(); + serverInformation.IncomingServerPassword.Should().BeEmpty(); + serverInformation.OutgoingServerPassword.Should().BeEmpty(); + serverInformation.CalDavPassword.Should().BeEmpty(); + serverInformation.MaxConcurrentClients.Should().Be(9); + serverInformation.CalDavServiceUrl.Should().Be("https://dav.example.com"); + } + + private static AccountService CreateAccountService(InMemoryDatabaseService databaseService, IPreferencesService preferencesService) + { + var signatureService = new Mock(); + signatureService + .Setup(a => a.CreateDefaultSignatureAsync(It.IsAny())) + .ReturnsAsync((Guid accountId) => new AccountSignature + { + Id = Guid.NewGuid(), + MailAccountId = accountId, + Name = "Default", + HtmlBody = string.Empty + }); + + return new AccountService( + databaseService, + signatureService.Object, + Mock.Of(), + Mock.Of(), + preferencesService, + Mock.Of()); + } +} diff --git a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs index 3bc97b3d..d4531cfe 100644 --- a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs +++ b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs @@ -232,11 +232,10 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime .ReturnsAsync(WinoAccountApiResult.Success(authResult)); _apiClient - .Setup(x => x.SummarizeAsync("

Hello

", default)) + .Setup(x => x.SummarizeAsync("

Hello

", "en", default)) .ReturnsAsync(ApiEnvelope.Success( new AiTextResultDto("

Summary

"), new QuotaInfoDto( - true, "Active", DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(29), @@ -247,7 +246,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime await _service.LoginAsync("first@example.com", "pw"); - var response = await _service.SummarizeAsync("

Hello

"); + var response = await _service.SummarizeAsync("

Hello

", "en"); response.IsSuccess.Should().BeTrue(); response.Result?.Html.Should().Be("

Summary

"); diff --git a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs index e49f98fe..087fb08e 100644 --- a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs +++ b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs @@ -163,6 +163,11 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel Messenger.Send(new BreadcrumbNavigationRequested(Translator.WelcomeWizard_Step2Title, WinoPage.ProviderSelectionPage)); } + public void NavigateToManageAccounts() + { + Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAccountSettings_Title, WinoPage.ManageAccountsPage)); + } + private async Task LoadDashboardAsync() { var accounts = (await _accountService.GetAccountsAsync().ConfigureAwait(false) ?? []).ToList(); diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs index 3f771eed..80afdd15 100644 --- a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs +++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs @@ -1,14 +1,15 @@ #nullable enable using System; using System.Collections.ObjectModel; -using Wino.Core.Domain.Entities.Shared; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Navigation; using Wino.Core.ViewModels.Data; using Wino.Mail.Api.Contracts.Common; @@ -22,6 +23,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, IRecipient { private readonly IWinoAccountProfileService _profileService; + private readonly IWinoAccountDataSyncService _syncService; private readonly IMailDialogService _dialogService; private readonly IStoreManagementService _storeManagementService; private readonly WinoAddOnItemViewModel _aiPackAddOn; @@ -49,10 +51,12 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, public bool IsSignedOut => !IsSignedIn; public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService, + IWinoAccountDataSyncService syncService, IMailDialogService dialogService, IStoreManagementService storeManagementService) { _profileService = profileService; + _syncService = syncService; _dialogService = dialogService; _storeManagementService = storeManagementService; @@ -220,10 +224,69 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, => addOn != null && !addOn.IsPurchased && !addOn.IsLoading && !IsCheckoutInProgress; [RelayCommand] - private Task ExportSettingsAsync() => Task.CompletedTask; + private async Task ExportSettingsAsync() + { + try + { + var result = await _dialogService.ShowWinoAccountExportDialogAsync().ConfigureAwait(false); + if (result == null) + { + return; + } + + _dialogService.InfoBarMessage( + Translator.GeneralTitle_Info, + BuildExportSuccessMessage(result), + InfoBarMessageType.Success); + } + catch (Exception ex) + { + _dialogService.InfoBarMessage( + Translator.GeneralTitle_Error, + ex.Message, + InfoBarMessageType.Error); + } + } [RelayCommand] - private Task ImportSettingsAsync() => Task.CompletedTask; + private async Task ImportSettingsAsync() + { + await ExecuteUIThread(() => IsBusy = true); + + try + { + var result = await _syncService.ImportAsync(new WinoAccountSyncSelection()); + + if (!result.HasAnyRemoteData) + { + _dialogService.InfoBarMessage( + Translator.GeneralTitle_Info, + Translator.WinoAccount_Management_NoRemoteSettings, + InfoBarMessageType.Information); + return; + } + + var messageType = result.FailedPreferenceCount > 0 + ? InfoBarMessageType.Warning + : InfoBarMessageType.Success; + + _dialogService.InfoBarMessage( + result.FailedPreferenceCount > 0 ? Translator.GeneralTitle_Warning : Translator.GeneralTitle_Info, + BuildImportMessage(result), + messageType); + } + catch (Exception ex) + { + _dialogService.InfoBarMessage( + Translator.GeneralTitle_Error, + ex.Message, + InfoBarMessageType.Error); + } + finally + { + await ExecuteUIThread(() => IsBusy = false); + } + } protected override void RegisterRecipients() { @@ -392,6 +455,62 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, _ => Translator.WinoAccount_Management_StoreSyncFailed }; + private static string BuildExportSuccessMessage(WinoAccountSyncExportResult result) + { + var parts = new Collection(); + + if (result.IncludedPreferences) + { + parts.Add(Translator.WinoAccount_Management_ExportPreferencesSucceeded); + } + + if (result.IncludedAccounts) + { + parts.Add(string.Format(Translator.WinoAccount_Management_ExportAccountsSucceeded, result.ExportedMailboxCount)); + } + + if (parts.Count == 0) + { + parts.Add(Translator.WinoAccount_Management_ExportSucceeded); + } + + return string.Join(" ", parts); + } + + private static string BuildImportMessage(WinoAccountSyncImportResult result) + { + var parts = new Collection(); + + if (result.HadRemotePreferences) + { + parts.Add(result.FailedPreferenceCount > 0 + ? string.Format(Translator.WinoAccount_Management_ImportPartial, result.AppliedPreferenceCount, result.FailedPreferenceCount) + : string.Format(Translator.WinoAccount_Management_ImportPreferencesSucceeded, result.AppliedPreferenceCount)); + } + + if (result.ImportedMailboxCount > 0) + { + parts.Add(string.Format(Translator.WinoAccount_Management_ImportAccountsSucceeded, result.ImportedMailboxCount)); + } + + if (result.SkippedDuplicateMailboxCount > 0) + { + parts.Add(string.Format(Translator.WinoAccount_Management_ImportDuplicateAccountsSkipped, result.SkippedDuplicateMailboxCount)); + } + + if (parts.Count == 0) + { + parts.Add(Translator.WinoAccount_Management_ImportEmpty); + } + + if (result.ImportedMailboxCount > 0) + { + parts.Add(Translator.WinoAccount_Management_ImportReloginReminder); + } + + return string.Join(" ", parts); + } + private static bool IsAccessTokenExpired(WinoAccount account) => string.IsNullOrWhiteSpace(account.AccessToken) || account.AccessTokenExpiresAtUtc <= DateTime.UtcNow; diff --git a/Wino.Core/CoreContainerSetup.cs b/Wino.Core/CoreContainerSetup.cs index 02fcc7d1..6f07640f 100644 --- a/Wino.Core/CoreContainerSetup.cs +++ b/Wino.Core/CoreContainerSetup.cs @@ -44,6 +44,7 @@ public static class CoreContainerSetup services.AddTransient(); // Register Gmail error handlers + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -56,6 +57,9 @@ public static class CoreContainerSetup services.AddTransient(); services.AddTransient(); + // Register Outlook auth handlers + services.AddTransient(); + // Register error handler factories services.AddTransient(); services.AddTransient(); diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index 75d5c99e..292fb187 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -147,10 +147,8 @@ public static class GoogleIntegratorExtensions // Bg color must present. Generate one if doesnt exists. // Text color is optional. It'll be overriden by UI for readibility. - calendar.BackgroundColorHex = string.IsNullOrEmpty(calendarListEntry.BackgroundColor) - ? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex() - : calendarListEntry.BackgroundColor; - calendar.TextColorHex = string.IsNullOrEmpty(calendarListEntry.ForegroundColor) ? "#000000" : calendarListEntry.ForegroundColor; + calendar.BackgroundColorHex = fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex(); + calendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(calendar.BackgroundColorHex); return calendar; } diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 5b315e5c..4bfdf542 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -191,10 +191,8 @@ public static class OutlookIntegratorExtensions // Bg must be present. Generate flat one if doesn't exists. // Text doesnt exists for Outlook. - calendar.BackgroundColorHex = string.IsNullOrEmpty(outlookCalendar.HexColor) - ? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex() - : outlookCalendar.HexColor; - calendar.TextColorHex = "#000000"; + calendar.BackgroundColorHex = fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex(); + calendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(calendar.BackgroundColorHex); return calendar; } diff --git a/Wino.Core/Misc/ColorHelpers.cs b/Wino.Core/Misc/ColorHelpers.cs index d917cab3..8b012431 100644 --- a/Wino.Core/Misc/ColorHelpers.cs +++ b/Wino.Core/Misc/ColorHelpers.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.Globalization; using System.Linq; using Wino.Core.Domain.Misc; @@ -12,9 +13,21 @@ public static class ColorHelpers public static string GenerateFlatColorHex() => GetDistinctFlatColorHex(Array.Empty()); - public static string GetDistinctFlatColorHex(IEnumerable usedColors) + public static string GetDistinctFlatColorHex(IEnumerable usedColors, string preferredColor = null) { var palette = CalendarColorPalette.GetColors(); + var normalizedUsedColors = usedColors? + .Select(NormalizeHexColor) + .Where(color => !string.IsNullOrWhiteSpace(color)) + .ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + + if (TryNormalizeHexColor(preferredColor, out var normalizedPreferred) && + palette.Contains(normalizedPreferred, StringComparer.OrdinalIgnoreCase) && + !normalizedUsedColors.Contains(normalizedPreferred)) + { + return normalizedPreferred; + } + var distinctColor = CalendarColorPalette.GetDistinctColor(usedColors); if (palette.Contains(distinctColor)) { @@ -26,6 +39,18 @@ public static class ColorHelpers return candidate; } + public static string GetReadableTextColorHex(string backgroundColor) + { + if (!TryNormalizeHexColor(backgroundColor, out var normalizedColor)) + { + return "#FFFFFF"; + } + + var color = ColorTranslator.FromHtml(normalizedColor); + var luminance = ((0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B)) / 255d; + return luminance > 0.6 ? "#111111" : "#FFFFFF"; + } + public static string ToHexString(this Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}"; public static string ToRgbString(this Color c) => $"RGB({c.R}, {c.G}, {c.B})"; @@ -41,4 +66,35 @@ public static class ColorHelpers return adjusted.ToHexString(); } + + private static bool TryNormalizeHexColor(string value, out string normalized) + { + normalized = string.Empty; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var color = value.Trim(); + if (color.StartsWith('#')) + { + color = color[1..]; + } + + if (color.Length != 6) + { + return false; + } + + if (!int.TryParse(color, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _)) + { + return false; + } + + normalized = $"#{color.ToUpperInvariant()}"; + return true; + } + + private static string NormalizeHexColor(string value) + => TryNormalizeHexColor(value, out var normalized) ? normalized : string.Empty; } diff --git a/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs b/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs index 565cdeb8..0f64aaf1 100644 --- a/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs +++ b/Wino.Core/Services/GmailSynchronizerErrorHandlingFactory.cs @@ -11,12 +11,14 @@ namespace Wino.Core.Services; public class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IGmailSynchronizerErrorHandlerFactory { public GmailSynchronizerErrorHandlingFactory( + GmailAuthenticationFailedHandler authenticationFailedHandler, GmailQuotaExceededHandler quotaExceededHandler, GmailRateLimitHandler rateLimitHandler, GmailHistoryExpiredHandler historyExpiredHandler, EntityNotFoundHandler entityNotFoundHandler) { // Order matters - more specific handlers should be registered first + RegisterHandler(authenticationFailedHandler); RegisterHandler(quotaExceededHandler); RegisterHandler(historyExpiredHandler); RegisterHandler(entityNotFoundHandler); diff --git a/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs b/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs index 5b9a0c08..91f411d3 100644 --- a/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs +++ b/Wino.Core/Services/OutlookSynchronizerErrorHandlingFactory.cs @@ -6,11 +6,13 @@ namespace Wino.Core.Services; public class OutlookSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IOutlookSynchronizerErrorHandlerFactory { - public OutlookSynchronizerErrorHandlingFactory(ObjectCannotBeDeletedHandler objectCannotBeDeleted, + public OutlookSynchronizerErrorHandlingFactory(OutlookAuthenticationFailedHandler authenticationFailedHandler, + ObjectCannotBeDeletedHandler objectCannotBeDeleted, EntityNotFoundHandler entityNotFoundHandler, DeltaTokenExpiredHandler deltaTokenExpiredHandler, OutlookRateLimitHandler outlookRateLimitHandler) { + RegisterHandler(authenticationFailedHandler); RegisterHandler(outlookRateLimitHandler); RegisterHandler(objectCannotBeDeleted); RegisterHandler(entityNotFoundHandler); diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index 108a08ea..b181b123 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -4,7 +4,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; using Serilog; +using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; @@ -13,6 +15,7 @@ using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Authentication; using Wino.Core.Domain.Models.Connectivity; using Wino.Core.Domain.Models.Synchronization; +using Wino.Messaging.UI; namespace Wino.Core.Services; @@ -27,6 +30,7 @@ public class SynchronizationManager : ISynchronizationManager private readonly ConcurrentDictionary _synchronizerCache = new(); private readonly ConcurrentDictionary _accountSynchronizationCancellationSources = new(); + private readonly ConcurrentDictionary _calendarSynchronizationLocks = new(); private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly ILogger _logger = Log.ForContext(); @@ -131,6 +135,12 @@ public class SynchronizationManager : ISynchronizationManager { EnsureInitialized(); + if (await IsSynchronizationBlockedByAttentionAsync(options.AccountId).ConfigureAwait(false)) + { + _logger.Information("Skipping mail synchronization for account {AccountId} because it requires credential attention.", options.AccountId); + return MailSynchronizationResult.Canceled; + } + var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId); if (synchronizer == null) { @@ -170,7 +180,8 @@ public class SynchronizationManager : ISynchronizationManager catch (AuthenticationAttentionException authEx) { _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); - + await SetInvalidCredentialAttentionAsync(authEx.Account).ConfigureAwait(false); + // Create app notification for authentication attention _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); @@ -348,9 +359,75 @@ public class SynchronizationManager : ISynchronizationManager /// Synchronization result public async Task SynchronizeCalendarAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) + => options.Type == CalendarSynchronizationType.Strict + ? await SynchronizeCalendarStrictAsync(options, cancellationToken).ConfigureAwait(false) + : await RunCalendarSynchronizationWithLockAsync( + options.AccountId, + cancellationToken, + () => SynchronizeCalendarCoreAsync(options, cancellationToken, reportState: true)).ConfigureAwait(false); + + private async Task SynchronizeCalendarStrictAsync( + CalendarSynchronizationOptions options, + CancellationToken cancellationToken) + { + var metadataOptions = new CalendarSynchronizationOptions + { + AccountId = options.AccountId, + Type = CalendarSynchronizationType.CalendarMetadata, + SynchronizationCalendarIds = options.SynchronizationCalendarIds + }; + + var eventOptions = new CalendarSynchronizationOptions + { + AccountId = options.AccountId, + Type = CalendarSynchronizationType.CalendarEvents, + SynchronizationCalendarIds = options.SynchronizationCalendarIds + }; + + return await RunCalendarSynchronizationWithLockAsync(options.AccountId, cancellationToken, async () => + { + try + { + PublishCalendarSynchronizationState( + options.AccountId, + CalendarSynchronizationType.Strict, + isSynchronizationInProgress: true, + Translator.SyncAction_SynchronizingCalendarMetadata); + + var metadataResult = await SynchronizeCalendarCoreAsync(metadataOptions, cancellationToken, reportState: false).ConfigureAwait(false); + if (metadataResult.CompletedState is SynchronizationCompletedState.Failed or SynchronizationCompletedState.Canceled) + { + return metadataResult; + } + + PublishCalendarSynchronizationState( + options.AccountId, + CalendarSynchronizationType.Strict, + isSynchronizationInProgress: true, + Translator.SyncAction_SynchronizingCalendarEvents); + + return await SynchronizeCalendarCoreAsync(eventOptions, cancellationToken, reportState: false).ConfigureAwait(false); + } + finally + { + PublishCalendarSynchronizationState(options.AccountId, CalendarSynchronizationType.Strict, isSynchronizationInProgress: false); + } + }).ConfigureAwait(false); + } + + private async Task SynchronizeCalendarCoreAsync( + CalendarSynchronizationOptions options, + CancellationToken cancellationToken, + bool reportState) { EnsureInitialized(); + if (await IsSynchronizationBlockedByAttentionAsync(options.AccountId).ConfigureAwait(false)) + { + _logger.Information("Skipping calendar synchronization for account {AccountId} because it requires credential attention.", options.AccountId); + return CalendarSynchronizationResult.Canceled; + } + var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId); if (synchronizer == null) { @@ -361,6 +438,15 @@ public class SynchronizationManager : ISynchronizationManager _logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}", options.AccountId, options.Type); + if (reportState) + { + PublishCalendarSynchronizationState( + options.AccountId, + options.Type, + isSynchronizationInProgress: true, + GetCalendarSynchronizationStatus(options.Type)); + } + var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource()); using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, @@ -387,7 +473,8 @@ public class SynchronizationManager : ISynchronizationManager catch (AuthenticationAttentionException authEx) { _logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId); - + await SetInvalidCredentialAttentionAsync(authEx.Account).ConfigureAwait(false); + // Create app notification for authentication attention _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); @@ -398,6 +485,13 @@ public class SynchronizationManager : ISynchronizationManager _logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId); return CalendarSynchronizationResult.Failed; } + finally + { + if (reportState) + { + PublishCalendarSynchronizationState(options.AccountId, options.Type, isSynchronizationInProgress: false); + } + } } /// @@ -667,4 +761,69 @@ public class SynchronizationManager : ISynchronizationManager throw new InvalidOperationException("SynchronizationManager must be initialized before use. Call InitializeAsync first."); } } + + private async Task SetInvalidCredentialAttentionAsync(MailAccount account) + { + if (account == null || _accountService == null) + return; + + var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false); + + if (persistedAccount == null) + return; + + if (persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials) + return; + + persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials; + await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false); + } + + private async Task IsSynchronizationBlockedByAttentionAsync(Guid accountId) + { + if (_accountService == null) + return false; + + var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); + return account?.AttentionReason == AccountAttentionReason.InvalidCredentials; + } + + private void PublishCalendarSynchronizationState( + Guid accountId, + CalendarSynchronizationType synchronizationType, + bool isSynchronizationInProgress, + string synchronizationStatus = "") + { + WeakReferenceMessenger.Default.Send(new AccountCalendarSynchronizationStateChanged( + accountId, + synchronizationType, + isSynchronizationInProgress, + synchronizationStatus)); + } + + private static string GetCalendarSynchronizationStatus(CalendarSynchronizationType synchronizationType) + => synchronizationType switch + { + CalendarSynchronizationType.CalendarMetadata => Translator.SyncAction_SynchronizingCalendarMetadata, + CalendarSynchronizationType.Strict => Translator.SyncAction_SynchronizingCalendarData, + _ => Translator.SyncAction_SynchronizingCalendarEvents + }; + + private async Task RunCalendarSynchronizationWithLockAsync( + Guid accountId, + CancellationToken cancellationToken, + Func> synchronizationFactory) + { + var calendarSemaphore = _calendarSynchronizationLocks.GetOrAdd(accountId, _ => new SemaphoreSlim(1, 1)); + await calendarSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + return await synchronizationFactory().ConfigureAwait(false); + } + finally + { + calendarSemaphore.Release(); + } + } } diff --git a/Wino.Core/Synchronizers/Errors/Gmail/GmailAuthenticationFailedHandler.cs b/Wino.Core/Synchronizers/Errors/Gmail/GmailAuthenticationFailedHandler.cs new file mode 100644 index 00000000..c82ec459 --- /dev/null +++ b/Wino.Core/Synchronizers/Errors/Gmail/GmailAuthenticationFailedHandler.cs @@ -0,0 +1,68 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Google; +using Serilog; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Synchronization; + +namespace Wino.Core.Synchronizers.Errors.Gmail; + +public class GmailAuthenticationFailedHandler : ISynchronizerErrorHandler +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly IAccountService _accountService; + + public GmailAuthenticationFailedHandler(IAccountService accountService) + { + _accountService = accountService; + } + + public bool CanHandle(SynchronizerErrorContext error) + { + if (error.Exception is not GoogleApiException googleEx) + return false; + + var reason = googleEx.Error?.Errors?.FirstOrDefault()?.Reason?.ToLowerInvariant() ?? string.Empty; + var message = googleEx.Message?.ToLowerInvariant() ?? string.Empty; + + return googleEx.HttpStatusCode == HttpStatusCode.Unauthorized || + (googleEx.HttpStatusCode == HttpStatusCode.Forbidden && + (reason.Contains("auth") || + reason.Contains("credential") || + message.Contains("invalid credentials") || + message.Contains("insufficient authentication") || + message.Contains("login required"))); + } + + public async Task HandleAsync(SynchronizerErrorContext error) + { + _logger.Warning(error.Exception, + "Gmail authentication failed for account {AccountName} ({AccountId}). User intervention is required.", + error.Account?.Name, error.Account?.Id); + + if (error.Account != null) + { + await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false); + } + + error.Severity = SynchronizerErrorSeverity.AuthRequired; + error.Category = SynchronizerErrorCategory.Authentication; + error.RetryDelay = null; + + return true; + } + + private async Task PersistInvalidCredentialAttentionAsync(MailAccount account) + { + var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false); + + if (persistedAccount == null || persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials) + return; + + persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials; + await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false); + } +} diff --git a/Wino.Core/Synchronizers/Errors/Imap/ImapAuthenticationFailedHandler.cs b/Wino.Core/Synchronizers/Errors/Imap/ImapAuthenticationFailedHandler.cs index d86a218e..144217b8 100644 --- a/Wino.Core/Synchronizers/Errors/Imap/ImapAuthenticationFailedHandler.cs +++ b/Wino.Core/Synchronizers/Errors/Imap/ImapAuthenticationFailedHandler.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using MailKit.Security; using Serilog; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Synchronization; @@ -14,6 +15,12 @@ namespace Wino.Core.Synchronizers.Errors.Imap; public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler { private readonly ILogger _logger = Log.ForContext(); + private readonly IAccountService _accountService; + + public ImapAuthenticationFailedHandler(IAccountService accountService) + { + _accountService = accountService; + } public bool CanHandle(SynchronizerErrorContext error) { @@ -22,12 +29,17 @@ public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler (error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false); } - public Task HandleAsync(SynchronizerErrorContext error) + public async Task HandleAsync(SynchronizerErrorContext error) { _logger.Warning(error.Exception, "IMAP authentication failed for account {AccountName} ({AccountId}). User needs to re-authenticate.", error.Account?.Name, error.Account?.Id); + if (error.Account != null) + { + await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false); + } + // Mark as requiring authentication - this will stop sync and notify user error.Severity = SynchronizerErrorSeverity.AuthRequired; error.Category = SynchronizerErrorCategory.Authentication; @@ -35,6 +47,20 @@ public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler // No point in retrying auth failures - credentials need to be updated error.RetryDelay = null; - return Task.FromResult(true); + return true; + } + + private async Task PersistInvalidCredentialAttentionAsync(MailAccount account) + { + var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false); + + if (persistedAccount == null) + return; + + if (persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials) + return; + + persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials; + await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false); } } diff --git a/Wino.Core/Synchronizers/Errors/Outlook/OutlookAuthenticationFailedHandler.cs b/Wino.Core/Synchronizers/Errors/Outlook/OutlookAuthenticationFailedHandler.cs new file mode 100644 index 00000000..9183504c --- /dev/null +++ b/Wino.Core/Synchronizers/Errors/Outlook/OutlookAuthenticationFailedHandler.cs @@ -0,0 +1,83 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Graph.Models.ODataErrors; +using Microsoft.Kiota.Abstractions; +using Serilog; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Synchronization; + +namespace Wino.Core.Synchronizers.Errors.Outlook; + +public class OutlookAuthenticationFailedHandler : ISynchronizerErrorHandler +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly IAccountService _accountService; + + public OutlookAuthenticationFailedHandler(IAccountService accountService) + { + _accountService = accountService; + } + + public bool CanHandle(SynchronizerErrorContext error) + { + if (error.Exception is ApiException apiException) + { + if (apiException.ResponseStatusCode == 401) + return true; + + if (apiException.ResponseStatusCode == 403) + { + var message = apiException.Message?.ToLowerInvariant() ?? string.Empty; + return message.Contains("access denied") || message.Contains("authentication"); + } + } + + if (error.Exception is ODataError oDataError) + { + if (oDataError.ResponseStatusCode == 401) + return true; + + var code = oDataError.Error?.Code?.ToLowerInvariant() ?? string.Empty; + var message = oDataError.Error?.Message?.ToLowerInvariant() ?? string.Empty; + + return code.Contains("invalidauthenticationtoken") || + code.Contains("invalidgrant") || + code.Contains("token") || + message.Contains("access token") || + message.Contains("authentication"); + } + + return false; + } + + public async Task HandleAsync(SynchronizerErrorContext error) + { + _logger.Warning(error.Exception, + "Outlook authentication failed for account {AccountName} ({AccountId}). User intervention is required.", + error.Account?.Name, error.Account?.Id); + + if (error.Account != null) + { + await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false); + } + + error.Severity = SynchronizerErrorSeverity.AuthRequired; + error.Category = SynchronizerErrorCategory.Authentication; + error.RetryDelay = null; + + return true; + } + + private async Task PersistInvalidCredentialAttentionAsync(MailAccount account) + { + var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false); + + if (persistedAccount == null || persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials) + return; + + persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials; + await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false); + } +} diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index b65bfb23..5445e3aa 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -603,11 +603,7 @@ public class GmailSynchronizer : WinoSynchronizer( - localCalendars - .Select(a => a.BackgroundColorHex) - .Where(a => !string.IsNullOrWhiteSpace(a)), - StringComparer.OrdinalIgnoreCase); + var usedCalendarColors = new HashSet(StringComparer.OrdinalIgnoreCase); List insertedCalendars = new(); List updatedCalendars = new(); @@ -637,25 +633,25 @@ public class GmailSynchronizer : WinoSynchronizer c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); - var usedCalendarColors = new HashSet( - localCalendars - .Select(a => a.BackgroundColorHex) - .Where(a => !string.IsNullOrWhiteSpace(a)), - StringComparer.OrdinalIgnoreCase); + var usedCalendarColors = new HashSet(StringComparer.OrdinalIgnoreCase); var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId; @@ -1493,25 +1489,33 @@ public class ImapSynchronizer : WinoSynchronizer( - localCalendars - .Select(a => a.BackgroundColorHex) - .Where(a => !string.IsNullOrWhiteSpace(a)), - StringComparer.OrdinalIgnoreCase); + var usedCalendarColors = new HashSet(StringComparer.OrdinalIgnoreCase); List insertedCalendars = new(); List updatedCalendars = new(); @@ -2367,23 +2363,25 @@ public class OutlookSynchronizer : WinoSynchronizer Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id)); + [RelayCommand] + private void EditImapCalDavSettings() + => Messenger.Send(new BreadcrumbNavigationRequested( + Translator.ImapCalDavSettingsPage_TitleEdit, + WinoPage.ImapCalDavSettingsPage, + ImapCalDavSettingsNavigationContext.CreateForEditMode(Account.Id))); + [RelayCommand] private async Task SaveChangesAsync() { diff --git a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs index f1409956..109dbbd9 100644 --- a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs +++ b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs @@ -13,9 +13,12 @@ using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.AutoDiscovery; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Services; using Wino.Mail.ViewModels.Data; +using Wino.Messaging.Client.Calendar; using Wino.Messaging.Client.Navigation; +using Wino.Messaging.Server; namespace Wino.Mail.ViewModels; @@ -289,7 +292,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel else { PageTitle = Translator.ImapCalDavSettingsPage_TitleEdit; - await InitializeEditModeAsync(context.AccountId).ConfigureAwait(false); + await InitializeEditModeAsync(context.AccountId); } } @@ -498,7 +501,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel private async Task InitializeEditModeAsync(Guid accountId) { - var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); + var account = await _accountService.GetAccountAsync(accountId); if (account == null) throw new InvalidOperationException(Translator.Exception_NullAssignedAccount); @@ -768,10 +771,26 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel serverInformation.AccountId = account.Id; account.ServerInformation = serverInformation; + account.AttentionReason = AccountAttentionReason.None; await _accountService.UpdateAccountCustomServerInformationAsync(serverInformation).ConfigureAwait(false); await _accountService.UpdateAccountAsync(account).ConfigureAwait(false); + Messenger.Send(new NewMailSynchronizationRequested(new MailSynchronizationOptions + { + AccountId = account.Id, + Type = MailSynchronizationType.FullFolders + })); + + if (account.IsCalendarAccessGranted) + { + Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions + { + AccountId = account.Id, + Type = CalendarSynchronizationType.CalendarEvents + })); + } + _mailDialogService.InfoBarMessage( Translator.IMAPSetupDialog_ValidationSuccess_Title, Translator.ImapCalDavSettingsPage_SaveSuccessMessage, diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs index 7f18fa48..b4168541 100644 --- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs +++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs @@ -20,6 +20,8 @@ using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Services; +using Wino.Mail.ViewModels.Data; using Wino.Messaging.Client.Accounts; using Wino.Messaging.Client.Navigation; using Wino.Messaging.Client.Shell; @@ -81,7 +83,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel, private readonly IMailDialogService _dialogService; private readonly IMimeFileService _mimeFileService; private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService; - private readonly IUpdateManager _updateManager; private readonly IStoreUpdateService _storeUpdateService; private readonly INativeAppService _nativeAppService; @@ -108,7 +109,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel, IConfigurationService configurationService, IStartupBehaviorService startupBehaviorService, IWebView2RuntimeValidatorService webView2RuntimeValidatorService, - IUpdateManager updateManager, IStoreUpdateService storeUpdateService) { StatePersistenceService = statePersistanceService; @@ -130,7 +130,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel, _notificationBuilder = notificationBuilder; _winoRequestDelegator = winoRequestDelegator; _webView2RuntimeValidatorService = webView2RuntimeValidatorService; - _updateManager = updateManager; _storeUpdateService = storeUpdateService; } @@ -235,7 +234,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } var activationContext = parameters as ShellModeActivationContext; - var shouldRunStartupFlows = activationContext?.IsInitialActivation ?? true; + var shouldRunStartupFlows = (activationContext?.IsInitialActivation ?? true) && + activationContext?.SuppressStartupFlows != true; var hasExistingAccountMenuItems = MenuItems?.OfType().Any() == true; PreferencesService.PreferenceChanged -= PreferencesServiceChanged; @@ -258,7 +258,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel, if (shouldRunStartupFlows) { - await ShowWhatIsNewIfNeededAsync(); await MakeSureEnableStartupLaunchAsync(); } } @@ -296,19 +295,6 @@ public partial class MailAppShellViewModel : MailBaseViewModel, FooterItems?.Clear(); } - private async Task ShowWhatIsNewIfNeededAsync() - { - if (!_updateManager.ShouldShowUpdateNotes()) - return; - - var notes = await _updateManager.GetLatestUpdateNotesAsync(); - - if (notes.Sections.Count == 0) - return; - - await _dialogService.ShowWhatIsNewDialogAsync(notes); - } - private async Task MakeSureEnableStartupLaunchAsync() { if (!_configurationService.Get(IsActivateStartupLaunchAskedKey, false)) @@ -605,21 +591,75 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } } + public Task HandleAccountAttentionAsync(MailAccount account) + => FixAccountIssuesAsync(account); + + private void TriggerFullSynchronization(MailAccount account) + { + Messenger.Send(new NewMailSynchronizationRequested(new MailSynchronizationOptions + { + AccountId = account.Id, + Type = MailSynchronizationType.FullFolders + })); + + if (account.IsCalendarAccessGranted) + { + Messenger.Send(new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions + { + AccountId = account.Id, + Type = CalendarSynchronizationType.CalendarEvents + })); + } + } + private async Task FixAccountIssuesAsync(MailAccount account) { - // TODO: This area is very unclear. Needs to be rewritten with care. - // Fix account issues are expected to not work, but may work for some cases. - try { if (account.AttentionReason == AccountAttentionReason.InvalidCredentials) - await _accountService.FixTokenIssuesAsync(account.Id); + { + if (account.ProviderType is MailProviderType.Gmail or MailProviderType.Outlook) + { + await SynchronizationManager.Instance.HandleAuthorizationAsync( + account.ProviderType, + account, + account.ProviderType == MailProviderType.Gmail); + + await _accountService.ClearAccountAttentionAsync(account.Id); + + _dialogService.InfoBarMessage( + Translator.Info_AccountIssueFixSuccessTitle, + Translator.Info_AccountIssueFixSuccessMessage, + InfoBarMessageType.Success); + + TriggerFullSynchronization(account); + return; + } + + NavigationService.Navigate(WinoPage.SettingsPage, WinoPage.ManageAccountsPage); + Messenger.Send(new BreadcrumbNavigationRequested( + Translator.ImapCalDavSettingsPage_TitleEdit, + WinoPage.ImapCalDavSettingsPage, + ImapCalDavSettingsNavigationContext.CreateForEditMode(account.Id))); + + _dialogService.InfoBarMessage( + Translator.Info_AccountIssueFixSuccessTitle, + Translator.Info_AccountIssueFixImapMessage, + InfoBarMessageType.Information); + return; + } else if (account.AttentionReason == AccountAttentionReason.MissingSystemFolderConfiguration) + { await _dialogService.HandleSystemFolderConfigurationDialogAsync(account.Id, _folderService); + await _accountService.ClearAccountAttentionAsync(account.Id); - await _accountService.ClearAccountAttentionAsync(account.Id); + _dialogService.InfoBarMessage( + Translator.Info_AccountIssueFixSuccessTitle, + Translator.Info_AccountIssueFixSuccessMessage, + InfoBarMessageType.Success); - _dialogService.InfoBarMessage(Translator.Info_AccountIssueFixFailedTitle, Translator.Info_AccountIssueFixSuccessMessage, InfoBarMessageType.Success); + TriggerFullSynchronization(account); + } } catch (Exception ex) { diff --git a/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs b/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs index cb190e8b..9626af2a 100644 --- a/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs +++ b/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs @@ -1,27 +1,47 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Updates; using Wino.Messaging.Client.Navigation; +using Wino.Messaging.UI; namespace Wino.Mail.ViewModels; public partial class WelcomePageV2ViewModel : MailBaseViewModel { private readonly IUpdateManager _updateManager; + private readonly IMailDialogService _dialogService; + private readonly IWinoAccountDataSyncService _syncService; [ObservableProperty] public partial List UpdateSections { get; set; } = []; - public WelcomePageV2ViewModel(IUpdateManager updateManager) + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(GetStartedCommand))] + [NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))] + public partial bool IsImportInProgress { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasImportStatus))] + public partial string ImportStatusMessage { get; set; } = string.Empty; + + public bool HasImportStatus => !string.IsNullOrWhiteSpace(ImportStatusMessage); + + public WelcomePageV2ViewModel(IUpdateManager updateManager, + IMailDialogService dialogService, + IWinoAccountDataSyncService syncService) { _updateManager = updateManager; + _dialogService = dialogService; + _syncService = syncService; } public override async void OnNavigatedTo(NavigationMode mode, object parameters) @@ -39,11 +59,75 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel } } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))] private void GetStarted() { Messenger.Send(new BreadcrumbNavigationRequested( Translator.WelcomeWizard_Step2Title, WinoPage.ProviderSelectionPage)); } + + [RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))] + private async Task ImportFromWinoAccountAsync() + { + await ExecuteUIThread(() => ImportStatusMessage = string.Empty); + + try + { + var account = await _dialogService.ShowWinoAccountLoginDialogAsync().ConfigureAwait(false); + if (account == null) + { + return; + } + + await ExecuteUIThread(() => IsImportInProgress = true); + + var result = await _syncService.ImportAsync(new WinoAccountSyncSelection()).ConfigureAwait(false); + if (result.ImportedMailboxCount > 0) + { + ReportUIChange(new WelcomeImportCompletedMessage(result.ImportedMailboxCount)); + return; + } + + await ExecuteUIThread(() => ImportStatusMessage = BuildInlineImportMessage(result)); + } + catch (Exception ex) + { + await _dialogService.ShowMessageAsync(ex.Message, Translator.GeneralTitle_Error, WinoCustomMessageDialogIcon.Error); + } + finally + { + await ExecuteUIThread(() => IsImportInProgress = false); + } + } + + private bool CanOpenWelcomeActions() => !IsImportInProgress; + + private static string BuildInlineImportMessage(WinoAccountSyncImportResult result) + { + var preferencesMessage = result.FailedPreferenceCount > 0 + ? string.Format(Translator.WinoAccount_Management_ImportPartial, result.AppliedPreferenceCount, result.FailedPreferenceCount) + : result.HadRemotePreferences + ? string.Format(Translator.WinoAccount_Management_ImportPreferencesSucceeded, result.AppliedPreferenceCount) + : string.Empty; + + if (result.RemoteMailboxCount == 0) + { + return string.IsNullOrWhiteSpace(preferencesMessage) + ? Translator.WelcomeWindow_ImportNoAccountsFound + : $"{preferencesMessage} {Translator.WelcomeWindow_ImportNoAccountsFound}"; + } + + if (result.SkippedDuplicateMailboxCount > 0 && result.ImportedMailboxCount == 0) + { + var duplicateMessage = string.Format(Translator.WelcomeWindow_ImportDuplicateAccountsSkipped, result.SkippedDuplicateMailboxCount); + return string.IsNullOrWhiteSpace(preferencesMessage) + ? duplicateMessage + : $"{preferencesMessage} {duplicateMessage}"; + } + + return string.IsNullOrWhiteSpace(preferencesMessage) + ? Translator.WinoAccount_Management_ImportEmpty + : preferencesMessage; + } } diff --git a/Wino.Mail.WinUI/AnimatedVisuals/Sync.cs b/Wino.Mail.WinUI/AnimatedVisuals/Sync.cs new file mode 100644 index 00000000..d7bdb00a --- /dev/null +++ b/Wino.Mail.WinUI/AnimatedVisuals/Sync.cs @@ -0,0 +1,556 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// LottieGen version: +// 8.2.250604.1+b02a3ee244 +// +// Command: +// LottieGen -Language CSharp -Public -WinUIVersion 2.4 -InputFile sync.json +// +// Input file: +// sync.json (2404 bytes created 20:18+02:00 Apr 4 2026) +// +// LottieGen source: +// http://aka.ms/Lottie +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +// ___________________________________________________________ +// | Object stats | UAP v15 count | UAP v7 count | +// |__________________________|_______________|______________| +// | All CompositionObjects | 26 | 26 | +// |--------------------------+---------------+--------------| +// | Expression animators | 1 | 1 | +// | KeyFrame animators | 1 | 1 | +// | Reference parameters | 1 | 1 | +// | Expression operations | 0 | 0 | +// |--------------------------+---------------+--------------| +// | Animated brushes | - | - | +// | Animated gradient stops | - | - | +// | ExpressionAnimations | 1 | 1 | +// | PathKeyFrameAnimations | - | - | +// |--------------------------+---------------+--------------| +// | ContainerVisuals | 1 | 1 | +// | ShapeVisuals | 1 | 1 | +// |--------------------------+---------------+--------------| +// | ContainerShapes | 1 | 1 | +// | CompositionSpriteShapes | 2 | 2 | +// |--------------------------+---------------+--------------| +// | Brushes | 1 | 1 | +// | Gradient stops | - | - | +// | CompositionVisualSurface | - | - | +// ----------------------------------------------------------- +using Microsoft.Graphics.Canvas.Geometry; +using System; +using System.Collections.Generic; +using System.Numerics; +using Windows.Graphics; +using Windows.UI; +using Windows.UI.Composition; + +namespace AnimatedVisuals +{ + // Name: main_libary_shelf_icon_sync + // Frame rate: 60 fps + // Frame count: 61 + // Duration: 1016.7 mS + sealed class Sync + : Microsoft.UI.Xaml.Controls.IAnimatedVisualSource + { + // Animation duration: 1.017 seconds. + internal const long c_durationTicks = 10166666; + + public Microsoft.UI.Xaml.Controls.IAnimatedVisual TryCreateAnimatedVisual(Compositor compositor) + { + object ignored = null; + return TryCreateAnimatedVisual(compositor, out ignored); + } + + public Microsoft.UI.Xaml.Controls.IAnimatedVisual TryCreateAnimatedVisual(Compositor compositor, out object diagnostics) + { + diagnostics = null; + + if (Sync_AnimatedVisual_UAPv15.IsRuntimeCompatible()) + { + var res = + new Sync_AnimatedVisual_UAPv15( + compositor + ); + return res; + } + + if (Sync_AnimatedVisual_UAPv7.IsRuntimeCompatible()) + { + var res = + new Sync_AnimatedVisual_UAPv7( + compositor + ); + return res; + } + + return null; + } + + /// + /// Gets the number of frames in the animation. + /// + public double FrameCount => 61d; + + /// + /// Gets the frame rate of the animation. + /// + public double Framerate => 60d; + + /// + /// Gets the duration of the animation. + /// + public TimeSpan Duration => TimeSpan.FromTicks(10166666); + + /// + /// Converts a zero-based frame number to the corresponding progress value denoting the + /// start of the frame. + /// + public double FrameToProgress(double frameNumber) + { + return frameNumber / 61d; + } + + /// + /// Returns a map from marker names to corresponding progress values. + /// + public IReadOnlyDictionary Markers => + new Dictionary + { + }; + + /// + /// Sets the color property with the given name, or does nothing if no such property + /// exists. + /// + public void SetColorProperty(string propertyName, Color value) + { + } + + /// + /// Sets the scalar property with the given name, or does nothing if no such property + /// exists. + /// + public void SetScalarProperty(string propertyName, double value) + { + } + + sealed class Sync_AnimatedVisual_UAPv15 + : Microsoft.UI.Xaml.Controls.IAnimatedVisual + { + const long c_durationTicks = 10166666; + readonly Compositor _c; + readonly ExpressionAnimation _reusableExpressionAnimation; + AnimationController _animationController_0; + CompositionColorBrush _colorBrush_AlmostDarkSlateGray_FF2D3846; + ContainerVisual _root; + + void BindProperty( + CompositionObject target, + string animatedPropertyName, + string expression, + string referenceParameterName, + CompositionObject referencedObject) + { + _reusableExpressionAnimation.ClearAllParameters(); + _reusableExpressionAnimation.Expression = expression; + _reusableExpressionAnimation.SetReferenceParameter(referenceParameterName, referencedObject); + target.StartAnimation(animatedPropertyName, _reusableExpressionAnimation); + } + + ScalarKeyFrameAnimation CreateScalarKeyFrameAnimation(float initialProgress, float initialValue, CompositionEasingFunction initialEasingFunction) + { + var result = _c.CreateScalarKeyFrameAnimation(); + result.Duration = TimeSpan.FromTicks(c_durationTicks); + result.InsertKeyFrame(initialProgress, initialValue, initialEasingFunction); + return result; + } + + CompositionSpriteShape CreateSpriteShape(CompositionGeometry geometry, Matrix3x2 transformMatrix, CompositionBrush fillBrush) + { + var result = _c.CreateSpriteShape(geometry); + result.TransformMatrix = transformMatrix; + result.FillBrush = fillBrush; + return result; + } + + // - Shape tree root for layer: main_library_shelf_icon_sync Outlines + AnimationController AnimationController_0() + { + var result = _animationController_0 = _c.CreateAnimationController(); + result.Pause(); + BindProperty(result, "Progress", "_.Progress", "_", _root); + return result; + } + + // - - - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // - - ShapeGroup: Group 2 Offset:<28.255, 18.903> + CanvasGeometry Geometry_0() + { + CanvasGeometry result; + using (var builder = new CanvasPathBuilder(null)) + { + builder.SetFilledRegionDetermination(CanvasFilledRegionDetermination.Winding); + builder.BeginFigure(new Vector2(11.7449999F, 5.09700012F)); + builder.AddCubicBezier(new Vector2(11.7449999F, -3.66000009F), new Vector2(4.56699991F, -10.9189997F), new Vector2(-4.25500011F, -10.9189997F)); + builder.AddCubicBezier(new Vector2(-8.6239996F, -10.9189997F), new Vector2(-12.7040005F, -9.20300007F), new Vector2(-15.7449999F, -6.08900023F)); + builder.AddLine(new Vector2(-12.8739996F, -3.32599998F)); + builder.AddCubicBezier(new Vector2(-10.5930004F, -5.66200018F), new Vector2(-7.53200006F, -6.94799995F), new Vector2(-4.25500011F, -6.94799995F)); + builder.AddCubicBezier(new Vector2(2.36199999F, -6.94799995F), new Vector2(7.74499989F, -1.47099996F), new Vector2(7.74499989F, 5.09700012F)); + builder.AddLine(new Vector2(3.74499989F, 5.09700012F)); + builder.AddLine(new Vector2(9.74499989F, 10.9189997F)); + builder.AddLine(new Vector2(15.7449999F, 5.09700012F)); + builder.AddLine(new Vector2(11.7449999F, 5.09700012F)); + builder.EndFigure(CanvasFigureLoop.Closed); + result = CanvasGeometry.CreatePath(builder); + } + return result; + } + + // - - - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // - - ShapeGroup: Group 1 Offset:<19.745, 29.096> + CanvasGeometry Geometry_1() + { + CanvasGeometry result; + using (var builder = new CanvasPathBuilder(null)) + { + builder.SetFilledRegionDetermination(CanvasFilledRegionDetermination.Winding); + builder.BeginFigure(new Vector2(4.25500011F, 6.94799995F)); + builder.AddCubicBezier(new Vector2(-2.36199999F, 6.94799995F), new Vector2(-7.74499989F, 1.472F), new Vector2(-7.74499989F, -5.09499979F)); + builder.AddLine(new Vector2(-3.74499989F, -5.09499979F)); + builder.AddLine(new Vector2(-9.74499989F, -10.9189997F)); + builder.AddLine(new Vector2(-15.7449999F, -5.09499979F)); + builder.AddLine(new Vector2(-11.7449999F, -5.09499979F)); + builder.AddCubicBezier(new Vector2(-11.7449999F, 3.66199994F), new Vector2(-4.56699991F, 10.9189997F), new Vector2(4.25500011F, 10.9189997F)); + builder.AddCubicBezier(new Vector2(8.6260004F, 10.9189997F), new Vector2(12.7060003F, 9.20300007F), new Vector2(15.7449999F, 6.08900023F)); + builder.AddLine(new Vector2(12.8739996F, 3.32500005F)); + builder.AddCubicBezier(new Vector2(10.5930004F, 5.66099977F), new Vector2(7.53200006F, 6.94799995F), new Vector2(4.25500011F, 6.94799995F)); + builder.EndFigure(CanvasFigureLoop.Closed); + result = CanvasGeometry.CreatePath(builder); + } + return result; + } + + CompositionColorBrush ColorBrush_AlmostDarkSlateGray_FF2D3846() + { + return _colorBrush_AlmostDarkSlateGray_FF2D3846 = _c.CreateColorBrush(Color.FromArgb(0xFF, 0x2D, 0x38, 0x46)); + } + + // Shape tree root for layer: main_library_shelf_icon_sync Outlines + CompositionContainerShape ContainerShape() + { + var result = _c.CreateContainerShape(); + result.CenterPoint = new Vector2(24F, 24F); + var shapes = result.Shapes; + // ShapeGroup: Group 2 Offset:<28.255, 18.903> + shapes.Add(SpriteShape_0()); + // ShapeGroup: Group 1 Offset:<19.745, 29.096> + shapes.Add(SpriteShape_1()); + result.StartAnimation("RotationAngleInDegrees", RotationAngleInDegreesScalarAnimation_0_to_360(), AnimationController_0()); + return result; + } + + // - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // ShapeGroup: Group 2 Offset:<28.255, 18.903> + CompositionPathGeometry PathGeometry_0() + { + return _c.CreatePathGeometry(new CompositionPath(Geometry_0())); + } + + // - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // ShapeGroup: Group 1 Offset:<19.745, 29.096> + CompositionPathGeometry PathGeometry_1() + { + return _c.CreatePathGeometry(new CompositionPath(Geometry_1())); + } + + // - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // Path 1 + CompositionSpriteShape SpriteShape_0() + { + // Offset:<28.255, 18.903> + var geometry = PathGeometry_0(); + var result = CreateSpriteShape(geometry, new Matrix3x2(1F, 0F, 0F, 1F, 28.2549992F, 18.9029999F), ColorBrush_AlmostDarkSlateGray_FF2D3846());; + return result; + } + + // - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // Path 1 + CompositionSpriteShape SpriteShape_1() + { + // Offset:<19.745, 29.096> + var geometry = PathGeometry_1(); + var result = CreateSpriteShape(geometry, new Matrix3x2(1F, 0F, 0F, 1F, 19.7450008F, 29.0960007F), _colorBrush_AlmostDarkSlateGray_FF2D3846);; + return result; + } + + // The root of the composition. + ContainerVisual Root() + { + var result = _root = _c.CreateContainerVisual(); + var propertySet = result.Properties; + propertySet.InsertScalar("Progress", 0F); + // Shape tree root for layer: main_library_shelf_icon_sync Outlines + result.Children.InsertAtTop(ShapeVisual_0()); + return result; + } + + // - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // Rotation + ScalarKeyFrameAnimation RotationAngleInDegreesScalarAnimation_0_to_360() + { + // Frame 0. + var result = CreateScalarKeyFrameAnimation(0F, 0F, HoldThenStepEasingFunction()); + // Frame 61. + result.InsertKeyFrame(1F, 360F, _c.CreateCubicBezierEasingFunction(new Vector2(0.314999998F, 0F), new Vector2(0.465000004F, 0.861999989F))); + return result; + } + + // Shape tree root for layer: main_library_shelf_icon_sync Outlines + ShapeVisual ShapeVisual_0() + { + var result = _c.CreateShapeVisual(); + result.Size = new Vector2(48F, 48F); + result.Shapes.Add(ContainerShape()); + return result; + } + + // - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // RotationAngleInDegrees + StepEasingFunction HoldThenStepEasingFunction() + { + var result = _c.CreateStepEasingFunction(); + result.IsFinalStepSingleFrame = true; + return result; + } + + internal Sync_AnimatedVisual_UAPv15( + Compositor compositor + ) + { + _c = compositor; + _reusableExpressionAnimation = compositor.CreateExpressionAnimation(); + Root(); + } + + public Visual RootVisual => _root; + public TimeSpan Duration => TimeSpan.FromTicks(c_durationTicks); + public Vector2 Size => new Vector2(48F, 48F); + void IDisposable.Dispose() => _root?.Dispose(); + + internal static bool IsRuntimeCompatible() + { + return Windows.Foundation.Metadata.ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 15); + } + } + + sealed class Sync_AnimatedVisual_UAPv7 + : Microsoft.UI.Xaml.Controls.IAnimatedVisual + { + const long c_durationTicks = 10166666; + readonly Compositor _c; + readonly ExpressionAnimation _reusableExpressionAnimation; + CompositionColorBrush _colorBrush_AlmostDarkSlateGray_FF2D3846; + ContainerVisual _root; + + void BindProperty( + CompositionObject target, + string animatedPropertyName, + string expression, + string referenceParameterName, + CompositionObject referencedObject) + { + _reusableExpressionAnimation.ClearAllParameters(); + _reusableExpressionAnimation.Expression = expression; + _reusableExpressionAnimation.SetReferenceParameter(referenceParameterName, referencedObject); + target.StartAnimation(animatedPropertyName, _reusableExpressionAnimation); + } + + ScalarKeyFrameAnimation CreateScalarKeyFrameAnimation(float initialProgress, float initialValue, CompositionEasingFunction initialEasingFunction) + { + var result = _c.CreateScalarKeyFrameAnimation(); + result.Duration = TimeSpan.FromTicks(c_durationTicks); + result.InsertKeyFrame(initialProgress, initialValue, initialEasingFunction); + return result; + } + + CompositionSpriteShape CreateSpriteShape(CompositionGeometry geometry, Matrix3x2 transformMatrix, CompositionBrush fillBrush) + { + var result = _c.CreateSpriteShape(geometry); + result.TransformMatrix = transformMatrix; + result.FillBrush = fillBrush; + return result; + } + + // - - - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // - - ShapeGroup: Group 2 Offset:<28.255, 18.903> + CanvasGeometry Geometry_0() + { + CanvasGeometry result; + using (var builder = new CanvasPathBuilder(null)) + { + builder.SetFilledRegionDetermination(CanvasFilledRegionDetermination.Winding); + builder.BeginFigure(new Vector2(11.7449999F, 5.09700012F)); + builder.AddCubicBezier(new Vector2(11.7449999F, -3.66000009F), new Vector2(4.56699991F, -10.9189997F), new Vector2(-4.25500011F, -10.9189997F)); + builder.AddCubicBezier(new Vector2(-8.6239996F, -10.9189997F), new Vector2(-12.7040005F, -9.20300007F), new Vector2(-15.7449999F, -6.08900023F)); + builder.AddLine(new Vector2(-12.8739996F, -3.32599998F)); + builder.AddCubicBezier(new Vector2(-10.5930004F, -5.66200018F), new Vector2(-7.53200006F, -6.94799995F), new Vector2(-4.25500011F, -6.94799995F)); + builder.AddCubicBezier(new Vector2(2.36199999F, -6.94799995F), new Vector2(7.74499989F, -1.47099996F), new Vector2(7.74499989F, 5.09700012F)); + builder.AddLine(new Vector2(3.74499989F, 5.09700012F)); + builder.AddLine(new Vector2(9.74499989F, 10.9189997F)); + builder.AddLine(new Vector2(15.7449999F, 5.09700012F)); + builder.AddLine(new Vector2(11.7449999F, 5.09700012F)); + builder.EndFigure(CanvasFigureLoop.Closed); + result = CanvasGeometry.CreatePath(builder); + } + return result; + } + + // - - - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // - - ShapeGroup: Group 1 Offset:<19.745, 29.096> + CanvasGeometry Geometry_1() + { + CanvasGeometry result; + using (var builder = new CanvasPathBuilder(null)) + { + builder.SetFilledRegionDetermination(CanvasFilledRegionDetermination.Winding); + builder.BeginFigure(new Vector2(4.25500011F, 6.94799995F)); + builder.AddCubicBezier(new Vector2(-2.36199999F, 6.94799995F), new Vector2(-7.74499989F, 1.472F), new Vector2(-7.74499989F, -5.09499979F)); + builder.AddLine(new Vector2(-3.74499989F, -5.09499979F)); + builder.AddLine(new Vector2(-9.74499989F, -10.9189997F)); + builder.AddLine(new Vector2(-15.7449999F, -5.09499979F)); + builder.AddLine(new Vector2(-11.7449999F, -5.09499979F)); + builder.AddCubicBezier(new Vector2(-11.7449999F, 3.66199994F), new Vector2(-4.56699991F, 10.9189997F), new Vector2(4.25500011F, 10.9189997F)); + builder.AddCubicBezier(new Vector2(8.6260004F, 10.9189997F), new Vector2(12.7060003F, 9.20300007F), new Vector2(15.7449999F, 6.08900023F)); + builder.AddLine(new Vector2(12.8739996F, 3.32500005F)); + builder.AddCubicBezier(new Vector2(10.5930004F, 5.66099977F), new Vector2(7.53200006F, 6.94799995F), new Vector2(4.25500011F, 6.94799995F)); + builder.EndFigure(CanvasFigureLoop.Closed); + result = CanvasGeometry.CreatePath(builder); + } + return result; + } + + CompositionColorBrush ColorBrush_AlmostDarkSlateGray_FF2D3846() + { + return _colorBrush_AlmostDarkSlateGray_FF2D3846 = _c.CreateColorBrush(Color.FromArgb(0xFF, 0x2D, 0x38, 0x46)); + } + + // Shape tree root for layer: main_library_shelf_icon_sync Outlines + CompositionContainerShape ContainerShape() + { + var result = _c.CreateContainerShape(); + result.CenterPoint = new Vector2(24F, 24F); + var shapes = result.Shapes; + // ShapeGroup: Group 2 Offset:<28.255, 18.903> + shapes.Add(SpriteShape_0()); + // ShapeGroup: Group 1 Offset:<19.745, 29.096> + shapes.Add(SpriteShape_1()); + result.StartAnimation("RotationAngleInDegrees", RotationAngleInDegreesScalarAnimation_0_to_360()); + var controller = result.TryGetAnimationController("RotationAngleInDegrees"); + controller.Pause(); + BindProperty(controller, "Progress", "_.Progress", "_", _root); + return result; + } + + // - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // ShapeGroup: Group 2 Offset:<28.255, 18.903> + CompositionPathGeometry PathGeometry_0() + { + return _c.CreatePathGeometry(new CompositionPath(Geometry_0())); + } + + // - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // ShapeGroup: Group 1 Offset:<19.745, 29.096> + CompositionPathGeometry PathGeometry_1() + { + return _c.CreatePathGeometry(new CompositionPath(Geometry_1())); + } + + // - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // Path 1 + CompositionSpriteShape SpriteShape_0() + { + // Offset:<28.255, 18.903> + var geometry = PathGeometry_0(); + var result = CreateSpriteShape(geometry, new Matrix3x2(1F, 0F, 0F, 1F, 28.2549992F, 18.9029999F), ColorBrush_AlmostDarkSlateGray_FF2D3846());; + return result; + } + + // - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // Path 1 + CompositionSpriteShape SpriteShape_1() + { + // Offset:<19.745, 29.096> + var geometry = PathGeometry_1(); + var result = CreateSpriteShape(geometry, new Matrix3x2(1F, 0F, 0F, 1F, 19.7450008F, 29.0960007F), _colorBrush_AlmostDarkSlateGray_FF2D3846);; + return result; + } + + // The root of the composition. + ContainerVisual Root() + { + var result = _root = _c.CreateContainerVisual(); + var propertySet = result.Properties; + propertySet.InsertScalar("Progress", 0F); + // Shape tree root for layer: main_library_shelf_icon_sync Outlines + result.Children.InsertAtTop(ShapeVisual_0()); + return result; + } + + // - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // Rotation + ScalarKeyFrameAnimation RotationAngleInDegreesScalarAnimation_0_to_360() + { + // Frame 0. + var result = CreateScalarKeyFrameAnimation(0F, 0F, HoldThenStepEasingFunction()); + // Frame 61. + result.InsertKeyFrame(1F, 360F, _c.CreateCubicBezierEasingFunction(new Vector2(0.314999998F, 0F), new Vector2(0.465000004F, 0.861999989F))); + return result; + } + + // Shape tree root for layer: main_library_shelf_icon_sync Outlines + ShapeVisual ShapeVisual_0() + { + var result = _c.CreateShapeVisual(); + result.Size = new Vector2(48F, 48F); + result.Shapes.Add(ContainerShape()); + return result; + } + + // - - Shape tree root for layer: main_library_shelf_icon_sync Outlines + // RotationAngleInDegrees + StepEasingFunction HoldThenStepEasingFunction() + { + var result = _c.CreateStepEasingFunction(); + result.IsFinalStepSingleFrame = true; + return result; + } + + internal Sync_AnimatedVisual_UAPv7( + Compositor compositor + ) + { + _c = compositor; + _reusableExpressionAnimation = compositor.CreateExpressionAnimation(); + Root(); + } + + public Visual RootVisual => _root; + public TimeSpan Duration => TimeSpan.FromTicks(c_durationTicks); + public Vector2 Size => new Vector2(48F, 48F); + void IDisposable.Dispose() => _root?.Dispose(); + + internal static bool IsRuntimeCompatible() + { + return Windows.Foundation.Metadata.ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 7); + } + } + } +} diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index d59a30b0..456507d1 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Collections.Generic; using System; using System.IO; @@ -48,7 +49,8 @@ public partial class App : WinoApplication, IRecipient, IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient { private const int InboxSyncsPerFullSync = 20; private const string ToggleDefaultModeLaunchArgument = "--mode=toggle-default"; @@ -63,7 +65,7 @@ public partial class App : WinoApplication, private bool _isExiting; private CancellationTokenSource? _autoSynchronizationLoopCts; private readonly SemaphoreSlim _autoSynchronizationSemaphore = new(1, 1); - private readonly Dictionary _inboxSyncCounters = []; + private readonly ConcurrentDictionary _inboxSyncCounters = []; private NativeTrayIcon? _trayIcon; internal bool IsExiting => _isExiting; @@ -756,7 +758,9 @@ public partial class App : WinoApplication, /// Creates the main window without activating it. /// Used for both normal launch and startup task launch (tray only). /// - private void CreateWindow(Microsoft.UI.Xaml.LaunchActivatedEventArgs? args, string? forcedLaunchArguments = null) + private void CreateWindow(Microsoft.UI.Xaml.LaunchActivatedEventArgs? args, + string? forcedLaunchArguments = null, + ShellModeActivationContext? activationContextOverride = null) { LogActivation("Creating main window."); @@ -769,14 +773,28 @@ public partial class App : WinoApplication, windowManager.SetPrimaryNavigationFrame(WinoWindowKind.Shell, shellWindow.GetMainFrame()); + var navigationService = Services.GetRequiredService(); + var defaultMode = _preferencesService?.DefaultApplicationMode ?? WinoApplicationMode.Mail; + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); + + if (activationContextOverride != null) + { + var targetMode = !string.IsNullOrWhiteSpace(forcedLaunchArguments) + ? AppModeActivationResolver.Resolve(forcedLaunchArguments, null, null, defaultMode) + : TryResolveActivationMode(activationArgs, defaultMode, out var resolvedActivationMode) + ? resolvedActivationMode + : AppModeActivationResolver.Resolve(args?.Arguments, GetCurrentLaunchTileId(), Environment.CommandLine, defaultMode); + + navigationService.ChangeApplicationMode(targetMode, activationContextOverride); + return; + } + if (!string.IsNullOrWhiteSpace(forcedLaunchArguments)) { shellWindow.HandleAppActivation(forcedLaunchArguments); return; } - var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); - if (activationArgs.Kind == ExtendedActivationKind.Launch && activationArgs.Data is ILaunchActivatedEventArgs launchArgs) { @@ -791,7 +809,7 @@ public partial class App : WinoApplication, return; } - if (TryResolveActivationMode(activationArgs, _preferencesService?.DefaultApplicationMode ?? WinoApplicationMode.Mail, out var activationMode)) + if (TryResolveActivationMode(activationArgs, defaultMode, out var activationMode)) { shellWindow.HandleAppActivation(GetModeLaunchArgument(activationMode)); return; @@ -859,6 +877,7 @@ public partial class App : WinoApplication, WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); } public async void Receive(NewMailSynchronizationRequested message) @@ -882,6 +901,11 @@ public partial class App : WinoApplication, syncResult.CompletedState, message.Options.GroupedSynchronizationTrackingId)); + if (syncResult.CompletedState is SynchronizationCompletedState.Success or SynchronizationCompletedState.PartiallyCompleted) + { + await ClearInvalidCredentialAttentionIfNeededAsync(message.Options.AccountId).ConfigureAwait(false); + } + if (syncResult.CompletedState == SynchronizationCompletedState.Failed || syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted) { @@ -906,7 +930,12 @@ public partial class App : WinoApplication, var dialogService = Services.GetRequiredService(); dialogService.InfoBarMessage( Translator.Info_SyncFailedTitle, - Translator.Exception_FailedToSynchronizeFolders, + message.Options.Type switch + { + CalendarSynchronizationType.CalendarMetadata => Translator.Exception_FailedToSynchronizeCalendarMetadata, + CalendarSynchronizationType.Strict => Translator.Exception_FailedToSynchronizeCalendarData, + _ => Translator.Exception_FailedToSynchronizeCalendarEvents + }, InfoBarMessageType.Error); } } @@ -942,6 +971,47 @@ public partial class App : WinoApplication, }); } + public void Receive(WelcomeImportCompletedMessage message) + { + _hasConfiguredAccounts = message.ImportedMailboxCount > 0; + + var windowManager = Services.GetRequiredService(); + if (windowManager.GetWindow(WinoWindowKind.Welcome) == null) + return; + + MainWindow?.DispatcherQueue?.TryEnqueue(async () => + { + if (_preferencesService != null) + { + _preferencesService.PreferenceChanged -= PreferencesServiceChanged; + _preferencesService.PreferenceChanged += PreferencesServiceChanged; + } + + CreateWindow( + null, + GetModeLaunchArgument(WinoApplicationMode.Mail), + new ShellModeActivationContext + { + SuppressStartupFlows = true + }); + + await LoadInitialWinoAccountAsync(); + CloseWelcomeWindowIfPresent(); + + if (MainWindow != null) + { + await ActivateWindowAsync(MainWindow); + } + + RestartAutoSynchronizationLoop(); + + Services.GetRequiredService().InfoBarMessage( + Translator.GeneralTitle_Info, + Translator.WinoAccount_Management_ImportReloginReminder, + InfoBarMessageType.Information); + }); + } + public void Receive(AccountRemovedMessage message) { var windowManager = Services.GetRequiredService(); @@ -1078,52 +1148,16 @@ public partial class App : WinoApplication, 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) + foreach (var staleAccountId in _inboxSyncCounters.Keys.Where(a => !currentAccountIds.Contains(a)).ToList()) { - 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); + _inboxSyncCounters.TryRemove(staleAccountId, out _); } + + var synchronizationTasks = accounts + .Select(account => ExecuteAutoSynchronizationForAccountAsync(account, cancellationToken)) + .ToList(); + + await Task.WhenAll(synchronizationTasks).ConfigureAwait(false); } finally { @@ -1134,6 +1168,68 @@ public partial class App : WinoApplication, } } + private async Task ExecuteAutoSynchronizationForAccountAsync(Wino.Core.Domain.Entities.Shared.MailAccount account, CancellationToken cancellationToken) + { + if (_synchronizationManager == null) + return; + + cancellationToken.ThrowIfCancellationRequested(); + + if (_synchronizationManager.IsAccountSynchronizing(account.Id)) + return; + + 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) + { + await ClearInvalidCredentialAttentionIfNeededAsync(account.Id).ConfigureAwait(false); + + var inboxSyncCount = _inboxSyncCounters.AddOrUpdate(account.Id, 1, (_, currentCount) => currentCount + 1); + + if (inboxSyncCount >= 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) + return; + + var calendarOptions = new CalendarSynchronizationOptions + { + AccountId = account.Id, + Type = CalendarSynchronizationType.CalendarMetadata + }; + + await _synchronizationManager.SynchronizeCalendarAsync(calendarOptions, cancellationToken).ConfigureAwait(false); + } + + private async Task ClearInvalidCredentialAttentionIfNeededAsync(Guid accountId) + { + if (_accountService == null) + return; + + var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); + + if (account?.AttentionReason != AccountAttentionReason.InvalidCredentials) + return; + + await _accountService.ClearAccountAttentionAsync(accountId).ConfigureAwait(false); + } + /// /// 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/Controls/CalendarTitleBarContent.xaml b/Wino.Mail.WinUI/Controls/CalendarTitleBarContent.xaml index 5f23310f..732d901f 100644 --- a/Wino.Mail.WinUI/Controls/CalendarTitleBarContent.xaml +++ b/Wino.Mail.WinUI/Controls/CalendarTitleBarContent.xaml @@ -15,8 +15,9 @@ TargetType="Button"> + - + @@ -27,7 +28,7 @@ - + diff --git a/Wino.Mail.WinUI/Controls/SyncAnimationControl.xaml b/Wino.Mail.WinUI/Controls/SyncAnimationControl.xaml new file mode 100644 index 00000000..c4e27c66 --- /dev/null +++ b/Wino.Mail.WinUI/Controls/SyncAnimationControl.xaml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/Wino.Mail.WinUI/Controls/SyncAnimationControl.xaml.cs b/Wino.Mail.WinUI/Controls/SyncAnimationControl.xaml.cs new file mode 100644 index 00000000..c313b84d --- /dev/null +++ b/Wino.Mail.WinUI/Controls/SyncAnimationControl.xaml.cs @@ -0,0 +1,54 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Wino.Mail.WinUI.Controls; + +public sealed partial class SyncAnimationControl : UserControl +{ + public static readonly DependencyProperty IsPlayingProperty = DependencyProperty.Register( + nameof(IsPlaying), + typeof(bool), + typeof(SyncAnimationControl), + new PropertyMetadata(true, OnIsPlayingChanged)); + + public bool IsPlaying + { + get => (bool)GetValue(IsPlayingProperty); + set => SetValue(IsPlayingProperty, value); + } + + public SyncAnimationControl() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (IsPlaying) + { + PlayAnimation(); + } + } + + private static void OnIsPlayingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (SyncAnimationControl)d; + + if ((bool)e.NewValue) + { + control.PlayAnimation(); + } + else + { + control.AnimationPlayer.Stop(); + } + } + + private void PlayAnimation() + { +#pragma warning disable CS4014 // Fire-and-forget is intentional for looped animation playback. + AnimationPlayer.PlayAsync(0, 1, looped: true); +#pragma warning restore CS4014 + } +} diff --git a/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml b/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml deleted file mode 100644 index b58b26e3..00000000 --- a/Wino.Mail.WinUI/Dialogs/WhatIsNewDialog.xaml +++ /dev/null @@ -1,43 +0,0 @@ - - - - 480 - 560 - 480 - 700 - - - - - - - - - - - - + @@ -481,9 +487,31 @@ Text="{x:Bind domain:Translator.CalendarEventCompose_NewEventButton, Mode=OneTime}" /> + + + + + + + + @@ -519,13 +548,29 @@ IsChecked="{x:Bind IsCheckedState, Mode=TwoWay}" IsThreeState="True" /> - - - - + Fill="{x:Bind AccountColorHex, Converter={StaticResource HexToColorBrushConverter}, Mode=OneWay}" /> + + + + + + + + diff --git a/Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs b/Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs index 0304ec16..5a60c728 100644 --- a/Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs +++ b/Wino.Mail.WinUI/Views/WinoAppShell.xaml.cs @@ -277,6 +277,17 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract, await InvokeNewCalendarEventAsync(); } + private async void AttentionIconClicked(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { DataContext: AccountMenuItem accountMenuItem }) + return; + + if (ViewModel.MailClient is MailAppShellViewModel mailClient) + { + await mailClient.HandleAccountAttentionAsync(accountMenuItem.Parameter); + } + } + private async void NewCalendarEventNavigationItemKeyDown(object sender, KeyRoutedEventArgs e) { if (e.Key is not (VirtualKey.Enter or VirtualKey.Space)) @@ -289,6 +300,31 @@ public sealed partial class WinoAppShell : Views.Abstract.WinoAppShellAbstract, private Task InvokeNewCalendarEventAsync() => ViewModel.CalendarClient.HandleNavigationItemInvokedAsync(new NewCalendarEventMenuItem()); + private async void SynchronizeCalendarsNavigationItemTapped(object sender, TappedRoutedEventArgs e) + { + e.Handled = true; + await InvokeCalendarSynchronizationAsync(); + } + + private async void SynchronizeCalendarsNavigationItemKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key is not (VirtualKey.Enter or VirtualKey.Space)) + return; + + e.Handled = true; + await InvokeCalendarSynchronizationAsync(); + } + + private Task InvokeCalendarSynchronizationAsync() + { + if (ViewModel.CalendarClient.SyncCommand.CanExecute(null)) + { + ViewModel.CalendarClient.SyncCommand.Execute(null); + } + + return Task.CompletedTask; + } + public void Receive(CalendarDisplayTypeChangedMessage message) => NotifyTitleBarContentChanged(); public void Receive(AccountCreatedMessage message) diff --git a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj index ad8073fd..87965fa0 100644 --- a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj +++ b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj @@ -200,6 +200,7 @@ + diff --git a/Wino.Messages/UI/AccountCalendarSynchronizationStateChanged.cs b/Wino.Messages/UI/AccountCalendarSynchronizationStateChanged.cs new file mode 100644 index 00000000..85792d07 --- /dev/null +++ b/Wino.Messages/UI/AccountCalendarSynchronizationStateChanged.cs @@ -0,0 +1,13 @@ +using System; +using Wino.Core.Domain.Enums; + +namespace Wino.Messaging.UI; + +/// +/// Emitted when calendar synchronization state for an account changes. +/// +public record AccountCalendarSynchronizationStateChanged( + Guid AccountId, + CalendarSynchronizationType SynchronizationType, + bool IsSynchronizationInProgress, + string SynchronizationStatus = "") : UIMessageBase; diff --git a/Wino.Messages/UI/WelcomeImportCompletedMessage.cs b/Wino.Messages/UI/WelcomeImportCompletedMessage.cs new file mode 100644 index 00000000..b35a34e0 --- /dev/null +++ b/Wino.Messages/UI/WelcomeImportCompletedMessage.cs @@ -0,0 +1,3 @@ +namespace Wino.Messaging.UI; + +public record WelcomeImportCompletedMessage(int ImportedMailboxCount) : UIMessageBase; diff --git a/Wino.Services/AccountService.cs b/Wino.Services/AccountService.cs index 658cc41a..8ae13070 100644 --- a/Wino.Services/AccountService.cs +++ b/Wino.Services/AccountService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Diagnostics; @@ -642,10 +643,11 @@ public class AccountService : BaseDatabaseService, IAccountService IsExtended = true, RemoteCalendarId = string.Empty, TimeZone = string.Empty, - BackgroundColorHex = await GetNextDistinctCalendarColorAsync().ConfigureAwait(false), - TextColorHex = "#FFFFFF" + BackgroundColorHex = await GetNextDistinctCalendarColorAsync().ConfigureAwait(false) }; + localCalendar.TextColorHex = GetReadableTextColorHex(localCalendar.BackgroundColorHex); + await Connection.InsertAsync(localCalendar, typeof(AccountCalendar)).ConfigureAwait(false); } @@ -658,6 +660,16 @@ public class AccountService : BaseDatabaseService, IAccountService return CalendarColorPalette.GetDistinctColor(usedColors.Select(a => a.BackgroundColorHex)); } + private static string GetReadableTextColorHex(string backgroundColorHex) + { + if (string.IsNullOrWhiteSpace(backgroundColorHex)) + return "#FFFFFF"; + + var color = ColorTranslator.FromHtml(backgroundColorHex); + var luminance = ((0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B)) / 255d; + return luminance > 0.6 ? "#111111" : "#FFFFFF"; + } + public async Task UpdateAccountOrdersAsync(Dictionary accountIdOrderPair) { foreach (var pair in accountIdOrderPair) diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index 5fd390a3..5c2668b1 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -29,6 +29,7 @@ public static class ServicesContainerSetup services.AddTransient(); services.AddSingleton(); services.AddSingleton(); + services.AddTransient(); services.AddSingleton(); services.AddTransient(); diff --git a/Wino.Services/WinoAccountApiClient.cs b/Wino.Services/WinoAccountApiClient.cs index 29026a6a..63a8b757 100644 --- a/Wino.Services/WinoAccountApiClient.cs +++ b/Wino.Services/WinoAccountApiClient.cs @@ -16,6 +16,7 @@ using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Common; +using Wino.Mail.Api.Contracts.Users; namespace Wino.Services; @@ -160,49 +161,83 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable public async Task GetSettingsAsync(CancellationToken cancellationToken = default) { - try + using var response = await SendAuthorizedAsync( + () => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings"), + cancellationToken).ConfigureAwait(false); + + if (response == null) { - using var response = await SendAuthorizedAsync( - () => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/settings"), - cancellationToken).ConfigureAwait(false); - - if (response == null) - return null; - - if (response.StatusCode == System.Net.HttpStatusCode.NoContent) - return null; - - if (!response.IsSuccessStatusCode) - return null; - - return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("MissingAccessToken"); } - catch + + if (response.StatusCode == System.Net.HttpStatusCode.NoContent) { return null; } + + await EnsureSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false); + + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } - public async Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default) + public async Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default) { - try - { - using var response = await SendAuthorizedAsync( - () => CreateAuthorizedRequestAsync( - HttpMethod.Put, - "api/v1/users/me/settings", - () => new StringContent(settingsJson, Encoding.UTF8, "application/json")), - cancellationToken).ConfigureAwait(false); + using var response = await SendAuthorizedAsync( + () => CreateAuthorizedRequestAsync( + HttpMethod.Put, + "api/v1/users/me/settings", + () => new StringContent(settingsJson, Encoding.UTF8, "application/json")), + cancellationToken).ConfigureAwait(false); - if (response == null) - return false; - - return response.IsSuccessStatusCode; - } - catch + if (response == null) { - return false; + throw new InvalidOperationException("MissingAccessToken"); } + + await EnsureSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false); + } + + public async Task GetMailboxesAsync(CancellationToken cancellationToken = default) + { + using var response = await SendAuthorizedAsync( + () => CreateAuthorizedRequestAsync(HttpMethod.Get, "api/v1/users/me/mailboxes"), + cancellationToken).ConfigureAwait(false); + + if (response == null) + { + throw new InvalidOperationException("MissingAccessToken"); + } + + await EnsureSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false); + + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var envelope = string.IsNullOrWhiteSpace(payload) + ? null + : JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeUserMailboxSyncListDto); + + if (envelope?.IsSuccess == true && envelope.Result != null) + { + return envelope.Result; + } + + throw new InvalidOperationException(ExtractErrorMessage(payload) ?? envelope?.ErrorCode ?? "Mailbox synchronization request failed."); + } + + public async Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default) + { + using var response = await SendAuthorizedAsync( + () => CreateAuthorizedRequestAsync( + HttpMethod.Put, + "api/v1/users/me/mailboxes", + () => JsonContent.Create(request, WinoAccountApiJsonContext.Default.ReplaceUserMailboxesRequestDto)), + cancellationToken).ConfigureAwait(false); + + if (response == null) + { + throw new InvalidOperationException("MissingAccessToken"); + } + + await EnsureSuccessResponseAsync(response, cancellationToken).ConfigureAwait(false); } private async Task> SendAuthRequestAsync(string endpoint, TRequest request, JsonTypeInfo typeInfo, CancellationToken cancellationToken) @@ -321,6 +356,19 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable return !string.IsNullOrWhiteSpace(value); } + private static async Task EnsureSuccessResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException( + ExtractErrorMessage(payload) + ?? $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim()); + } + private Task> SendAuthorizedRequestAsync(string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken) => SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken); @@ -549,7 +597,9 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] +[JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] +[JsonSerializable(typeof(ReplaceUserMailboxesRequestDto))] internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext; internal sealed record SyncStoreEntitlementsRequest(string? StoreIdKey, string? PurchaseIdKey); diff --git a/Wino.Services/WinoAccountDataSyncService.cs b/Wino.Services/WinoAccountDataSyncService.cs new file mode 100644 index 00000000..01f3e973 --- /dev/null +++ b/Wino.Services/WinoAccountDataSyncService.cs @@ -0,0 +1,289 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; +using Wino.Mail.Api.Contracts.Users; +using Wino.Messaging.Client.Accounts; + +namespace Wino.Services; + +public sealed class WinoAccountDataSyncService : IWinoAccountDataSyncService +{ + private const int DefaultMaxConcurrentClients = 5; + + private readonly IWinoAccountProfileService _profileService; + private readonly IPreferencesService _preferencesService; + private readonly IAccountService _accountService; + + public WinoAccountDataSyncService( + IWinoAccountProfileService profileService, + IPreferencesService preferencesService, + IAccountService accountService) + { + _profileService = profileService; + _preferencesService = preferencesService; + _accountService = accountService; + } + + public async Task ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default) + { + var exportedMailboxCount = 0; + + if (selection.IncludePreferences) + { + await _profileService.SaveSettingsAsync(_preferencesService.ExportPreferences(), cancellationToken).ConfigureAwait(false); + } + + if (selection.IncludeAccounts) + { + var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + var request = new ReplaceUserMailboxesRequestDto + { + Mailboxes = accounts + .OrderBy(a => a.Order) + .Select(MapMailbox) + .ToList() + }; + + await _profileService.ReplaceMailboxesAsync(request, cancellationToken).ConfigureAwait(false); + exportedMailboxCount = request.Mailboxes.Count; + } + + return new WinoAccountSyncExportResult + { + IncludedPreferences = selection.IncludePreferences, + IncludedAccounts = selection.IncludeAccounts, + ExportedMailboxCount = exportedMailboxCount + }; + } + + public async Task ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default) + { + var result = new WinoAccountSyncImportResult + { + IncludedPreferences = selection.IncludePreferences, + IncludedAccounts = selection.IncludeAccounts + }; + + if (selection.IncludePreferences) + { + var settingsJson = await _profileService.GetSettingsAsync(cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(settingsJson)) + { + var (appliedCount, failedCount) = _preferencesService.ImportPreferences(settingsJson); + result = new WinoAccountSyncImportResult + { + IncludedPreferences = result.IncludedPreferences, + IncludedAccounts = result.IncludedAccounts, + HadRemotePreferences = true, + AppliedPreferenceCount = appliedCount, + FailedPreferenceCount = failedCount, + ImportedMailboxCount = result.ImportedMailboxCount, + SkippedDuplicateMailboxCount = result.SkippedDuplicateMailboxCount, + RemoteMailboxCount = result.RemoteMailboxCount + }; + } + } + + if (selection.IncludeAccounts) + { + var mailboxes = await _profileService.GetMailboxesAsync(cancellationToken).ConfigureAwait(false); + var orderedMailboxes = mailboxes.Mailboxes + .OrderBy(a => a.SortOrder) + .ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var localAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + var existingKeys = localAccounts + .Select(CreateMailboxKey) + .ToHashSet(StringComparer.Ordinal); + + var importedMailboxCount = 0; + var skippedDuplicateMailboxCount = 0; + + foreach (var mailbox in orderedMailboxes) + { + cancellationToken.ThrowIfCancellationRequested(); + + var mailboxKey = CreateMailboxKey(mailbox.Address, mailbox.ProviderType); + if (!existingKeys.Add(mailboxKey)) + { + skippedDuplicateMailboxCount++; + continue; + } + + var account = CreateImportedAccount(mailbox); + var serverInformation = CreateImportedServerInformation(mailbox, account.Id); + + await _accountService.CreateAccountAsync(account, serverInformation).ConfigureAwait(false); + await _accountService.CreateRootAliasAsync(account.Id, account.Address).ConfigureAwait(false); + + if (account.ProviderType == MailProviderType.IMAP4) + { + var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false); + if (persistedAccount != null && persistedAccount.AttentionReason != AccountAttentionReason.InvalidCredentials) + { + persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials; + await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false); + } + } + + importedMailboxCount++; + } + + if (importedMailboxCount > 0) + { + WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested(false)); + } + + result = new WinoAccountSyncImportResult + { + IncludedPreferences = result.IncludedPreferences, + IncludedAccounts = result.IncludedAccounts, + HadRemotePreferences = result.HadRemotePreferences, + AppliedPreferenceCount = result.AppliedPreferenceCount, + FailedPreferenceCount = result.FailedPreferenceCount, + ImportedMailboxCount = importedMailboxCount, + SkippedDuplicateMailboxCount = skippedDuplicateMailboxCount, + RemoteMailboxCount = orderedMailboxes.Count + }; + } + + await RepairStartupEntityAsync().ConfigureAwait(false); + + return result; + } + + private static UserMailboxSyncItemDto MapMailbox(MailAccount account) + { + var serverInformation = account.ProviderType == MailProviderType.IMAP4 + ? account.ServerInformation + : null; + + return new UserMailboxSyncItemDto + { + Address = account.Address ?? string.Empty, + ProviderType = (int)account.ProviderType, + SpecialImapProvider = (int)account.SpecialImapProvider, + AccountName = account.Name, + SenderName = account.SenderName, + AccountColorHex = account.AccountColorHex, + SortOrder = account.Order, + IsCalendarAccessGranted = account.IsCalendarAccessGranted, + CalendarSupportMode = serverInformation != null ? (int)serverInformation.CalendarSupportMode : 0, + IncomingServer = serverInformation?.IncomingServer, + IncomingServerPort = serverInformation?.IncomingServerPort, + IncomingServerUsername = serverInformation?.IncomingServerUsername, + IncomingServerSocketOption = serverInformation != null ? (int?)serverInformation.IncomingServerSocketOption : null, + IncomingAuthenticationMethod = serverInformation != null ? (int?)serverInformation.IncomingAuthenticationMethod : null, + OutgoingServer = serverInformation?.OutgoingServer, + OutgoingServerPort = serverInformation?.OutgoingServerPort, + OutgoingServerUsername = serverInformation?.OutgoingServerUsername, + OutgoingServerSocketOption = serverInformation != null ? (int?)serverInformation.OutgoingServerSocketOption : null, + OutgoingAuthenticationMethod = serverInformation != null ? (int?)serverInformation.OutgoingAuthenticationMethod : null, + CalDavServiceUrl = serverInformation?.CalDavServiceUrl, + CalDavUsername = serverInformation?.CalDavUsername, + ProxyServer = serverInformation?.ProxyServer, + ProxyServerPort = serverInformation?.ProxyServerPort, + MaxConcurrentClients = serverInformation?.MaxConcurrentClients + }; + } + + private static MailAccount CreateImportedAccount(UserMailboxSyncItemDto mailbox) + { + var providerType = (MailProviderType)mailbox.ProviderType; + + return new MailAccount + { + Id = Guid.NewGuid(), + Address = mailbox.Address.Trim(), + Name = string.IsNullOrWhiteSpace(mailbox.AccountName) ? mailbox.Address.Trim() : mailbox.AccountName.Trim(), + SenderName = string.IsNullOrWhiteSpace(mailbox.SenderName) ? mailbox.Address.Trim() : mailbox.SenderName.Trim(), + ProviderType = providerType, + SpecialImapProvider = (SpecialImapProvider)mailbox.SpecialImapProvider, + AccountColorHex = mailbox.AccountColorHex?.Trim(), + Base64ProfilePictureData = string.Empty, + IsCalendarAccessGranted = mailbox.IsCalendarAccessGranted, + SynchronizationDeltaIdentifier = string.Empty, + CalendarSynchronizationDeltaIdentifier = string.Empty, + AttentionReason = AccountAttentionReason.InvalidCredentials + }; + } + + private static CustomServerInformation? CreateImportedServerInformation(UserMailboxSyncItemDto mailbox, Guid accountId) + { + var providerType = (MailProviderType)mailbox.ProviderType; + if (providerType != MailProviderType.IMAP4) + { + return null; + } + + return new CustomServerInformation + { + Id = Guid.NewGuid(), + AccountId = accountId, + Address = mailbox.Address.Trim(), + IncomingServer = mailbox.IncomingServer?.Trim(), + IncomingServerPort = mailbox.IncomingServerPort?.Trim(), + IncomingServerUsername = mailbox.IncomingServerUsername?.Trim(), + IncomingServerPassword = string.Empty, + IncomingServerSocketOption = mailbox.IncomingServerSocketOption is int incomingSocketOption + ? (ImapConnectionSecurity)incomingSocketOption + : ImapConnectionSecurity.Auto, + IncomingAuthenticationMethod = mailbox.IncomingAuthenticationMethod is int incomingAuthMethod + ? (ImapAuthenticationMethod)incomingAuthMethod + : ImapAuthenticationMethod.Auto, + OutgoingServer = mailbox.OutgoingServer?.Trim(), + OutgoingServerPort = mailbox.OutgoingServerPort?.Trim(), + OutgoingServerUsername = mailbox.OutgoingServerUsername?.Trim(), + OutgoingServerPassword = string.Empty, + OutgoingServerSocketOption = mailbox.OutgoingServerSocketOption is int outgoingSocketOption + ? (ImapConnectionSecurity)outgoingSocketOption + : ImapConnectionSecurity.Auto, + OutgoingAuthenticationMethod = mailbox.OutgoingAuthenticationMethod is int outgoingAuthMethod + ? (ImapAuthenticationMethod)outgoingAuthMethod + : ImapAuthenticationMethod.Auto, + CalDavServiceUrl = mailbox.CalDavServiceUrl?.Trim(), + CalDavUsername = mailbox.CalDavUsername?.Trim(), + CalDavPassword = string.Empty, + CalendarSupportMode = (ImapCalendarSupportMode)mailbox.CalendarSupportMode, + ProxyServer = mailbox.ProxyServer?.Trim(), + ProxyServerPort = mailbox.ProxyServerPort?.Trim(), + MaxConcurrentClients = mailbox.MaxConcurrentClients.GetValueOrDefault(DefaultMaxConcurrentClients) + }; + } + + private async Task RepairStartupEntityAsync() + { + if (!_preferencesService.StartupEntityId.HasValue) + { + return; + } + + var startupEntityId = _preferencesService.StartupEntityId.Value; + var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + var accountIds = accounts.Select(a => a.Id); + var mergedInboxIds = accounts.Where(a => a.MergedInboxId.HasValue).Select(a => a.MergedInboxId!.Value); + + if (accountIds.Concat(mergedInboxIds).Contains(startupEntityId)) + { + return; + } + + _preferencesService.StartupEntityId = accounts.FirstOrDefault()?.Id; + } + + private static string CreateMailboxKey(MailAccount account) + => CreateMailboxKey(account.Address, (int)account.ProviderType); + + private static string CreateMailboxKey(string? address, int providerType) + => $"{address?.Trim().ToLowerInvariant()}|{providerType}"; +} diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs index d44c522b..54a4fa98 100644 --- a/Wino.Services/WinoAccountProfileService.cs +++ b/Wino.Services/WinoAccountProfileService.cs @@ -11,6 +11,7 @@ using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Common; +using Wino.Mail.Api.Contracts.Users; using Wino.Messaging.UI; namespace Wino.Services; @@ -285,6 +286,38 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return response; } + public async Task GetSettingsAsync(CancellationToken cancellationToken = default) + { + _ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("MissingAccessToken"); + + return await _apiClient.GetSettingsAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default) + { + _ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("MissingAccessToken"); + + await _apiClient.SaveSettingsAsync(settingsJson, cancellationToken).ConfigureAwait(false); + } + + public async Task GetMailboxesAsync(CancellationToken cancellationToken = default) + { + _ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("MissingAccessToken"); + + return await _apiClient.GetMailboxesAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task ReplaceMailboxesAsync(ReplaceUserMailboxesRequestDto request, CancellationToken cancellationToken = default) + { + _ = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("MissingAccessToken"); + + await _apiClient.ReplaceMailboxesAsync(request, cancellationToken).ConfigureAwait(false); + } + public async Task ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default) { await _billingCallbackLock.WaitAsync(cancellationToken).ConfigureAwait(false);