From 73f8f64c8fc0eba655d9601a99ab788108db562f Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 21 Aug 2023 20:13:19 -0400 Subject: [PATCH] Add tracked download stuff --- StabilityMatrix.Avalonia/App.axaml.cs | 1 + .../DesignData/DesignData.cs | 12 +- .../MockDownloadProgressItemViewModel.cs | 65 +++++ .../DesignData/MockDownloadService.cs | 13 +- .../DesignData/MockTrackedDownloadService.cs | 18 ++ .../Base/PausableProgressItemViewModelBase.cs | 49 ++++ .../Base/ProgressItemViewModelBase.cs | 16 ++ .../CheckpointBrowserCardViewModel.cs | 11 +- .../DownloadProgressItemViewModel.cs | 73 ++++++ .../ViewModels/ProgressItemViewModel.cs | 12 +- .../ViewModels/ProgressManagerViewModel.cs | 28 +- .../Views/ProgressManagerPage.axaml | 206 +++++++++++---- .../Models/FileInterfaces/FilePath.cs | 14 +- .../Models/Progress/ProgressState.cs | 3 +- .../Models/TrackedDownload.cs | 245 ++++++++++++++++++ 15 files changed, 680 insertions(+), 86 deletions(-) create mode 100644 StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs create mode 100644 StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs create mode 100644 StabilityMatrix.Core/Models/TrackedDownload.cs diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 382f7274..71272417 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -332,6 +332,7 @@ public sealed class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Rich presence services.AddSingleton(); diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index b0fc04ba..3ccf7b3e 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -91,7 +91,8 @@ public static class DesignData .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); // Placeholder services that nobody should need during design time services @@ -223,13 +224,12 @@ public static class DesignData }) }; - ProgressManagerViewModel.ProgressItems = new ObservableCollection + ProgressManagerViewModel.ProgressItems.AddRange(new ProgressItemViewModelBase[] { - new(new ProgressItem(Guid.NewGuid(), "Test File.exe", + new ProgressItemViewModel(new ProgressItem(Guid.NewGuid(), "Test File.exe", new ProgressReport(0.5f, "Downloading..."))), - new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu", - new ProgressReport(0.25f, "Extracting..."))) - }; + new MockDownloadProgressItemViewModel("Test File 2.exe"), + }); UpdateViewModel = Services.GetRequiredService(); UpdateViewModel.UpdateText = diff --git a/StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs b/StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs new file mode 100644 index 00000000..5f94c91d --- /dev/null +++ b/StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs @@ -0,0 +1,65 @@ +using System.Threading; +using System.Threading.Tasks; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Avalonia.DesignData; + +public class MockDownloadProgressItemViewModel : PausableProgressItemViewModelBase +{ + private Task? dummyTask; + private CancellationTokenSource? cts; + + public MockDownloadProgressItemViewModel(string fileName) + { + Name = fileName; + Progress.Value = 5; + Progress.IsIndeterminate = false; + Progress.Text = "Downloading..."; + } + + /// + public override Task Cancel() + { + // Cancel the task that updates progress + cts?.Cancel(); + cts = null; + dummyTask = null; + + State = ProgressState.Cancelled; + Progress.Text = "Cancelled"; + return Task.CompletedTask; + } + + /// + public override Task Pause() + { + // Cancel the task that updates progress + cts?.Cancel(); + cts = null; + dummyTask = null; + + State = ProgressState.Inactive; + + return Task.CompletedTask; + } + + /// + public override Task Resume() + { + // Start a task that updates progress every 100ms + cts = new CancellationTokenSource(); + dummyTask = Task.Run(async () => + { + while (State != ProgressState.Success) + { + await Task.Delay(100, cts.Token); + Progress.Value += 1; + } + }, cts.Token); + + State = ProgressState.Working; + + return Task.CompletedTask; + } +} diff --git a/StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs b/StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs index 419d972e..f3b35519 100644 --- a/StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs +++ b/StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading; using System.Threading.Tasks; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Services; @@ -8,8 +9,16 @@ namespace StabilityMatrix.Avalonia.DesignData; public class MockDownloadService : IDownloadService { - public Task DownloadToFileAsync(string downloadUrl, string downloadPath, - IProgress? progress = null, string? httpClientName = null) + public Task DownloadToFileAsync(string downloadUrl, string downloadPath, IProgress? progress = null, + string? httpClientName = null, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// + public Task ResumeDownloadToFileAsync(string downloadUrl, string downloadPath, long existingFileSize, + IProgress? progress = null, string? httpClientName = null, + CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs b/StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs new file mode 100644 index 00000000..9900f919 --- /dev/null +++ b/StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs @@ -0,0 +1,18 @@ +using System; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.DesignData; + +public class MockTrackedDownloadService : ITrackedDownloadService +{ + /// + public event EventHandler? DownloadAdded; + + /// + public TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath) + { + throw new NotImplementedException(); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs new file mode 100644 index 00000000..2d0f2672 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Avalonia.ViewModels.Base; + +[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")] +public abstract partial class PausableProgressItemViewModelBase : ProgressItemViewModelBase +{ + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPaused), nameof(IsCompleted), nameof(CanPauseResume), nameof(CanCancel))] + private ProgressState state = ProgressState.Inactive; + + /// + /// Whether the progress is paused + /// + public bool IsPaused => State == ProgressState.Inactive; + + /// + /// Whether the progress has succeeded, failed or was cancelled + /// + public override bool IsCompleted => State is ProgressState.Success or ProgressState.Failed or ProgressState.Cancelled; + + public virtual bool SupportsPauseResume => true; + public virtual bool SupportsCancel => true; + + public bool CanPauseResume => SupportsPauseResume && !IsCompleted; + public bool CanCancel => SupportsCancel && !IsCompleted; + + private AsyncRelayCommand? pauseCommand; + public IAsyncRelayCommand PauseCommand => pauseCommand ??= new AsyncRelayCommand(Pause); + public virtual Task Pause() => Task.CompletedTask; + + private AsyncRelayCommand? resumeCommand; + public IAsyncRelayCommand ResumeCommand => resumeCommand ??= new AsyncRelayCommand(Resume); + public virtual Task Resume() => Task.CompletedTask; + + private AsyncRelayCommand? cancelCommand; + public IAsyncRelayCommand CancelCommand => cancelCommand ??= new AsyncRelayCommand(Cancel); + public virtual Task Cancel() => Task.CompletedTask; + + [RelayCommand] + private Task TogglePauseResume() + { + return IsPaused ? Resume() : Pause(); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs new file mode 100644 index 00000000..a304da5b --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace StabilityMatrix.Avalonia.ViewModels.Base; + +public abstract partial class ProgressItemViewModelBase : ViewModelBase +{ + [ObservableProperty] private Guid id; + [ObservableProperty] private string? name; + [ObservableProperty] private bool failed; + + public virtual bool IsCompleted => Progress.Value >= 100 || Failed; + + public ProgressViewModel Progress { get; } = new(); +} diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs index 3b7f0120..f0502fa5 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs @@ -32,10 +32,10 @@ using Notification = Avalonia.Controls.Notifications.Notification; namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel - { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private readonly IDownloadService downloadService; + private readonly ITrackedDownloadService trackedDownloadService; private readonly ISettingsManager settingsManager; private readonly ServiceManager dialogFactory; private readonly INotificationService notificationService; @@ -63,11 +63,13 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel public CheckpointBrowserCardViewModel( IDownloadService downloadService, + ITrackedDownloadService trackedDownloadService, ISettingsManager settingsManager, ServiceManager dialogFactory, INotificationService notificationService) { this.downloadService = downloadService; + this.trackedDownloadService = trackedDownloadService; this.settingsManager = settingsManager; this.dialogFactory = dialogFactory; this.notificationService = notificationService; @@ -240,7 +242,10 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel filesForCleanup.Add(downloadPath); // Do the download - var progressId = Guid.NewGuid(); + var download = trackedDownloadService.NewDownload(modelFile.DownloadUrl, downloadPath); + download.Start(); + + /*var progressId = Guid.NewGuid(); var downloadTask = downloadService.DownloadToFileAsync(modelFile.DownloadUrl, downloadPath, new Progress(report => @@ -258,7 +263,7 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel })); var downloadResult = - await notificationService.TryAsync(downloadTask, "Could not download file"); + await notificationService.TryAsync(downloadTask, "Could not download file");*/ // Failed download handling if (downloadResult.Exception is not null) diff --git a/StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs new file mode 100644 index 00000000..403d85e1 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs @@ -0,0 +1,73 @@ +using System.Threading.Tasks; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Avalonia.ViewModels; + +public class DownloadProgressItemViewModel : PausableProgressItemViewModelBase +{ + private readonly TrackedDownload download; + + /// + public override bool SupportsPauseResume => true; + + public DownloadProgressItemViewModel(TrackedDownload download) + { + this.download = download; + + download.ProgressUpdate += (s, e) => + { + Progress.Value = e.Percentage; + Progress.IsIndeterminate = e.IsIndeterminate; + Progress.Text = e.Title; + }; + + download.ProgressStateChanged += (s, e) => + { + State = e; + + if (e == ProgressState.Inactive) + { + Progress.Text = "Paused"; + } + else if (e == ProgressState.Working) + { + Progress.Text = "Downloading..."; + } + else if (e == ProgressState.Success) + { + Progress.Text = "Completed"; + } + else if (e == ProgressState.Cancelled) + { + Progress.Text = "Cancelled"; + } + else if (e == ProgressState.Failed) + { + Progress.Text = "Failed"; + } + }; + } + + /// + public override Task Cancel() + { + download.Cancel(); + return Task.CompletedTask; + } + + /// + public override Task Pause() + { + download.Pause(); + return Task.CompletedTask; + } + + /// + public override Task Resume() + { + download.Resume(); + return Task.CompletedTask; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs index ceb7a2de..9a511042 100644 --- a/StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs @@ -6,21 +6,19 @@ using StabilityMatrix.Core.Models.Progress; namespace StabilityMatrix.Avalonia.ViewModels; -public partial class ProgressItemViewModel : ViewModelBase +public partial class ProgressItemViewModel : ProgressItemViewModelBase { [ObservableProperty] private Guid id; [ObservableProperty] private string name; - [ObservableProperty] private ProgressReport progress; [ObservableProperty] private bool failed; - [ObservableProperty] private string? progressText; public ProgressItemViewModel(ProgressItem progressItem) { Id = progressItem.ProgressId; Name = progressItem.Name; - Progress = progressItem.Progress; + Progress.Value = progressItem.Progress.Percentage; Failed = progressItem.Failed; - ProgressText = GetProgressText(Progress); + Progress.Text = GetProgressText(progressItem.Progress); EventManager.Instance.ProgressChanged += OnProgressChanged; } @@ -30,9 +28,9 @@ public partial class ProgressItemViewModel : ViewModelBase if (e.ProgressId != Id) return; - Progress = e.Progress; + Progress.Value = e.Progress.Percentage; Failed = e.Failed; - ProgressText = GetProgressText(Progress); + Progress.Text = GetProgressText(e.Progress); } private string GetProgressText(ProgressReport report) diff --git a/StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs index 7109a042..9024965a 100644 --- a/StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs @@ -1,13 +1,18 @@ using System; using System.Collections.ObjectModel; using System.Linq; +using Avalonia.Collections; using CommunityToolkit.Mvvm.ComponentModel; using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; @@ -19,14 +24,20 @@ public partial class ProgressManagerViewModel : PageViewModelBase public override string Title => "Download Manager"; public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true}; - [ObservableProperty] - private ObservableCollection progressItems; + public AvaloniaList ProgressItems { get; } = new(); + + public ProgressManagerViewModel(ITrackedDownloadService trackedDownloadService) + { + // Attach to the event + trackedDownloadService.DownloadAdded += TrackedDownloadService_OnDownloadAdded; + } - public ProgressManagerViewModel() + private void TrackedDownloadService_OnDownloadAdded(object? sender, TrackedDownload e) { - ProgressItems = new ObservableCollection(); + var vm = new DownloadProgressItemViewModel(e); + ProgressItems.Add(vm); } - + public void StartEventListener() { EventManager.Instance.ProgressChanged += OnProgressChanged; @@ -34,12 +45,7 @@ public partial class ProgressManagerViewModel : PageViewModelBase public void ClearDownloads() { - if (!ProgressItems.Any(p => Math.Abs(p.Progress.Percentage - 100) < 0.01f || p.Failed)) - return; - - var itemsInProgress = ProgressItems - .Where(p => p.Progress.Percentage < 100 && !p.Failed).ToList(); - ProgressItems = new ObservableCollection(itemsInProgress); + ProgressItems.RemoveAll(ProgressItems.Where(x => x.IsCompleted)); } private void OnProgressChanged(object? sender, ProgressItem e) diff --git a/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml b/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml index f45ab5d3..44955c68 100644 --- a/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml +++ b/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml @@ -1,46 +1,156 @@ - + - - + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + @@ -51,23 +161,11 @@ - - - - - - - - - - + - - + + diff --git a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs index d8a54fcd..4bc1b2e2 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs @@ -115,12 +115,22 @@ public class FilePath : FileSystemPath, IPathObject return File.WriteAllBytesAsync(FullPath, bytes, ct); } + /// + /// Move the file to a directory. + /// + public FilePath MoveTo(FilePath destinationFile) + { + Info.MoveTo(destinationFile.FullPath, true); + // Return the new path + return destinationFile; + } + /// /// Move the file to a directory. /// public async Task MoveToAsync(DirectoryPath directory) { - await Task.Run(() => Info.MoveTo(directory.FullPath)); + await Task.Run(() => Info.MoveTo(directory.FullPath)).ConfigureAwait(false); // Return the new path return directory.JoinFile(this); } @@ -130,7 +140,7 @@ public class FilePath : FileSystemPath, IPathObject /// public async Task MoveToAsync(FilePath destinationFile) { - await Task.Run(() => Info.MoveTo(destinationFile.FullPath)); + await Task.Run(() => Info.MoveTo(destinationFile.FullPath)).ConfigureAwait(false); // Return the new path return destinationFile; } diff --git a/StabilityMatrix.Core/Models/Progress/ProgressState.cs b/StabilityMatrix.Core/Models/Progress/ProgressState.cs index 8a62751c..ec64960c 100644 --- a/StabilityMatrix.Core/Models/Progress/ProgressState.cs +++ b/StabilityMatrix.Core/Models/Progress/ProgressState.cs @@ -5,5 +5,6 @@ public enum ProgressState Inactive, Working, Success, - Failed + Failed, + Cancelled } diff --git a/StabilityMatrix.Core/Models/TrackedDownload.cs b/StabilityMatrix.Core/Models/TrackedDownload.cs new file mode 100644 index 00000000..ed62471b --- /dev/null +++ b/StabilityMatrix.Core/Models/TrackedDownload.cs @@ -0,0 +1,245 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using AsyncAwaitBestPractices; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Core.Models; + +public class TrackedDownloadProgressEventArgs : EventArgs +{ + public ProgressReport Progress { get; init; } + public ProgressState State { get; init; } +} + +public class TrackedDownload +{ + [JsonIgnore] + private IDownloadService? downloadService; + + [JsonIgnore] + private Task? downloadTask; + + [JsonIgnore] + private CancellationTokenSource? downloadCancellationTokenSource; + + [JsonIgnore] + private CancellationTokenSource? downloadPauseTokenSource; + + private CancellationTokenSource AggregateCancellationTokenSource => + CancellationTokenSource.CreateLinkedTokenSource( + downloadCancellationTokenSource?.Token ?? CancellationToken.None, + downloadPauseTokenSource?.Token ?? CancellationToken.None); + + public required Guid Id { get; init; } + + public required Uri SourceUrl { get; init; } + + public Uri? RedirectedUrl { get; init; } + + public required DirectoryPath DownloadDirectory { get; init; } + + public required string FileName { get; init; } + + public required string TempFileName { get; init; } + + public string? ExpectedHashSha256 { get; init; } + + public bool ValidateHash { get; init; } + + public ProgressState ProgressState { get; private set; } = ProgressState.Inactive; + + public Exception? Exception { get; private set; } + + #region Events + private WeakEventManager? progressUpdateEventManager; + + public event EventHandler ProgressUpdate + { + add + { + progressUpdateEventManager ??= new WeakEventManager(); + progressUpdateEventManager.AddEventHandler(value); + } + remove => progressUpdateEventManager?.RemoveEventHandler(value); + } + + protected void OnProgressUpdate(ProgressReport e) + { + progressUpdateEventManager?.RaiseEvent(this, e, nameof(ProgressUpdate)); + } + + private WeakEventManager? progressStateChangedEventManager; + + public event EventHandler ProgressStateChanged + { + add + { + progressStateChangedEventManager ??= new WeakEventManager(); + progressStateChangedEventManager.AddEventHandler(value); + } + remove => progressStateChangedEventManager?.RemoveEventHandler(value); + } + + protected void OnProgressStateChanged(ProgressState e) + { + progressStateChangedEventManager?.RaiseEvent(this, e, nameof(ProgressStateChanged)); + } + #endregion + + [MemberNotNull(nameof(downloadService))] + private void EnsureDownloadService() + { + if (downloadService == null) + { + throw new InvalidOperationException("Download service is not set."); + } + } + + private async Task StartDownloadTask(long resumeFromByte, CancellationToken cancellationToken) + { + var progress = new Progress(OnProgressUpdate); + + await downloadService!.ResumeDownloadToFileAsync( + SourceUrl.ToString(), + DownloadDirectory.JoinFile(TempFileName), + resumeFromByte, + progress, + cancellationToken: cancellationToken).ConfigureAwait(false); + + // If hash validation is enabled, validate the hash + if (ValidateHash) + { + var hash = await FileHash.GetSha256Async(DownloadDirectory.JoinFile(TempFileName), progress).ConfigureAwait(false); + if (hash != ExpectedHashSha256) + { + throw new Exception($"Hash validation for {FileName} failed, expected {ExpectedHashSha256} but got {hash}"); + } + } + } + + public void Start() + { + if (ProgressState != ProgressState.Inactive) + { + throw new InvalidOperationException($"Download state must be inactive to start, not {ProgressState}"); + } + + EnsureDownloadService(); + + downloadCancellationTokenSource = new CancellationTokenSource(); + downloadPauseTokenSource = new CancellationTokenSource(); + + downloadTask = StartDownloadTask(0, AggregateCancellationTokenSource.Token) + .ContinueWith(OnDownloadTaskCompleted); + } + + public void Resume() + { + if (ProgressState != ProgressState.Inactive) return; + + EnsureDownloadService(); + + downloadCancellationTokenSource = new CancellationTokenSource(); + downloadPauseTokenSource = new CancellationTokenSource(); + + downloadTask = StartDownloadTask(0, AggregateCancellationTokenSource.Token) + .ContinueWith(OnDownloadTaskCompleted); + } + + public void Pause() + { + if (ProgressState != ProgressState.Working) return; + + downloadPauseTokenSource?.Cancel(); + } + + public void Cancel() + { + if (ProgressState is not (ProgressState.Working or ProgressState.Inactive)) return; + + downloadCancellationTokenSource?.Cancel(); + } + + /// + /// Invoked by the task's completion callback + /// + private void OnDownloadTaskCompleted(Task task) + { + // For cancelled, check if it was actually cancelled or paused + if (task.IsCanceled) + { + // If the task was cancelled, set the state to cancelled + if (downloadCancellationTokenSource?.IsCancellationRequested == true) + { + ProgressState = ProgressState.Cancelled; + } + // If the task was not cancelled, set the state to paused + else if (downloadPauseTokenSource?.IsCancellationRequested == true) + { + ProgressState = ProgressState.Inactive; + } + else + { + throw new InvalidOperationException("Download task was cancelled but neither cancellation token was cancelled."); + } + } + // For faulted + else if (task.IsFaulted) + { + // Set the exception + Exception = task.Exception; + + // Delete the temp file + try + { + DownloadDirectory.JoinFile(TempFileName).Delete(); + } + catch (IOException) + { + } + + ProgressState = ProgressState.Failed; + } + // Otherwise success + else + { + ProgressState = ProgressState.Success; + } + + // For failed or cancelled, delete the temp file + if (ProgressState is ProgressState.Failed or ProgressState.Cancelled) + { + // Delete the temp file + try + { + DownloadDirectory.JoinFile(TempFileName).Delete(); + } + catch (IOException) + { + } + } + else if (ProgressState == ProgressState.Success) + { + // Move the temp file to the final file + DownloadDirectory.JoinFile(TempFileName).MoveTo(DownloadDirectory.JoinFile(FileName)); + } + + // For pause, just do nothing + + OnProgressStateChanged(ProgressState); + + // Dispose of the task and cancellation token + downloadTask?.Dispose(); + downloadTask = null; + downloadCancellationTokenSource?.Dispose(); + downloadCancellationTokenSource = null; + } + + public void SetDownloadService(IDownloadService service) + { + downloadService = service; + } +}