diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/AsyncImageFailedEventArgs.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/AsyncImageFailedEventArgs.cs new file mode 100644 index 00000000..b236c348 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/AsyncImageFailedEventArgs.cs @@ -0,0 +1,20 @@ +using System; +using Avalonia.Interactivity; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs; + +public partial class BetterAsyncImage +{ + public class AsyncImageFailedEventArgs : RoutedEventArgs + { + internal AsyncImageFailedEventArgs(Exception? errorException = null, string errorMessage = "") + : base(FailedEvent) + { + ErrorException = errorException; + ErrorMessage = errorMessage; + } + + public Exception? ErrorException { get; private set; } + public string ErrorMessage { get; private set; } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.Events.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.Events.cs new file mode 100644 index 00000000..3219a46d --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.Events.cs @@ -0,0 +1,42 @@ +using System; +using Avalonia.Interactivity; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs; + +public partial class BetterAsyncImage +{ + /// + /// Deines the event + /// + public static readonly RoutedEvent OpenedEvent = RoutedEvent.Register< + BetterAsyncImage, + RoutedEventArgs + >(nameof(Opened), RoutingStrategies.Bubble); + + /// + /// Deines the event + /// + public static readonly RoutedEvent FailedEvent = + RoutedEvent.Register< + BetterAsyncImage, + global::Avalonia.Labs.Controls.AsyncImage.AsyncImageFailedEventArgs + >(nameof(Failed), RoutingStrategies.Bubble); + + /// + /// Occurs when the image is successfully loaded. + /// + public event EventHandler? Opened + { + add => AddHandler(OpenedEvent, value); + remove => RemoveHandler(OpenedEvent, value); + } + + /// + /// Occurs when the image fails to load the uri provided. + /// + public event EventHandler? Failed + { + add => AddHandler(FailedEvent, value); + remove => RemoveHandler(FailedEvent, value); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.Properties.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.Properties.cs new file mode 100644 index 00000000..c31f00aa --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.Properties.cs @@ -0,0 +1,135 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Labs.Controls; +using Avalonia.Media; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs; + +public partial class BetterAsyncImage +{ + /// + /// Defines the property. + /// + public static readonly StyledProperty PlaceholderSourceProperty = AvaloniaProperty.Register< + BetterAsyncImage, + IImage? + >(nameof(PlaceholderSource)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register< + BetterAsyncImage, + Uri? + >(nameof(Source)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty StretchProperty = AvaloniaProperty.Register< + BetterAsyncImage, + Stretch + >(nameof(Stretch), Stretch.Uniform); + + /// + /// Defines the property. + /// + public static readonly StyledProperty PlaceholderStretchProperty = AvaloniaProperty.Register< + BetterAsyncImage, + Stretch + >(nameof(PlaceholderStretch), Stretch.Uniform); + + /// + /// Defines the property. + /// + public static readonly DirectProperty StateProperty = + AvaloniaProperty.RegisterDirect( + nameof(State), + o => o.State, + (o, v) => o.State = v + ); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ImageTransitionProperty = + AvaloniaProperty.Register( + nameof(ImageTransition), + new CrossFade(TimeSpan.FromSeconds(0.25)) + ); + + /// + /// Defines the property. + /// + public static readonly DirectProperty IsCacheEnabledProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsCacheEnabled), + o => o.IsCacheEnabled, + (o, v) => o.IsCacheEnabled = v + ); + private bool _isCacheEnabled; + + /// + /// Gets or sets the placeholder image. + /// + public IImage? PlaceholderSource + { + get => GetValue(PlaceholderSourceProperty); + set => SetValue(PlaceholderSourceProperty, value); + } + + /// + /// Gets or sets the uri pointing to the image resource + /// + public Uri? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + /// + /// Gets or sets a value controlling how the image will be stretched. + /// + public Stretch Stretch + { + get { return GetValue(StretchProperty); } + set { SetValue(StretchProperty, value); } + } + + /// + /// Gets or sets a value controlling how the placeholder will be stretched. + /// + public Stretch PlaceholderStretch + { + get { return GetValue(StretchProperty); } + set { SetValue(StretchProperty, value); } + } + + /// + /// Gets the current loading state of the image. + /// + public AsyncImageState State + { + get => _state; + private set => SetAndRaise(StateProperty, ref _state, value); + } + + /// + /// Gets or sets the transition to run when the image is loaded. + /// + public IPageTransition? ImageTransition + { + get => GetValue(ImageTransitionProperty); + set => SetValue(ImageTransitionProperty, value); + } + + /// + /// Gets or sets whether to use cache for retrieved images + /// + public bool IsCacheEnabled + { + get => _isCacheEnabled; + set => SetAndRaise(IsCacheEnabledProperty, ref _isCacheEnabled, value); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.cs new file mode 100644 index 00000000..ec0cfa54 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/AsyncImage/BetterAsyncImage.cs @@ -0,0 +1,248 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Labs.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs; + +/// +/// An image control that asynchronously retrieves an image using a . +/// +[TemplatePart("PART_Image", typeof(Image))] +[TemplatePart("PART_PlaceholderImage", typeof(Image))] +public partial class BetterAsyncImage : TemplatedControl +{ + protected Image? ImagePart { get; private set; } + protected Image? PlaceholderPart { get; private set; } + + private bool _isInitialized; + private CancellationTokenSource? _tokenSource; + private AsyncImageState _state; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + ImagePart = e.NameScope.Get("PART_Image"); + PlaceholderPart = e.NameScope.Get("PART_PlaceholderImage"); + + _tokenSource = new CancellationTokenSource(); + + _isInitialized = true; + + if (Source != null) + { + SetSource(Source); + } + } + + private async void SetSource(object? source) + { + if (!_isInitialized) + { + return; + } + + _tokenSource?.Cancel(); + + _tokenSource = new CancellationTokenSource(); + + AttachSource(null); + + if (source == null) + { + return; + } + + State = AsyncImageState.Loading; + + if (Source is IImage image) + { + AttachSource(image); + + return; + } + + if (Source == null) + { + return; + } + + var uri = Source; + + if (uri != null && uri.IsAbsoluteUri) + { + if (uri.Scheme == "http" || uri.Scheme == "https") + { + Bitmap? bitmap = null; + // Android doesn't allow network requests on the main thread, even though we are using async apis. +#if NET6_0_OR_GREATER + if (OperatingSystem.IsAndroid()) + { + await Task.Run(async () => + { + try + { + bitmap = await LoadImageAsync(uri, _tokenSource.Token); + } + catch (Exception ex) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + State = AsyncImageState.Failed; + + RaiseEvent(new AsyncImageFailedEventArgs(ex)); + }); + } + }); + } + else +#endif + { + try + { + bitmap = await LoadImageAsync(uri, _tokenSource.Token); + } + catch (Exception ex) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + State = AsyncImageState.Failed; + + RaiseEvent(new AsyncImageFailedEventArgs(ex)); + }); + } + } + + AttachSource(bitmap); + } + else if (uri.Scheme == "avares") + { + try + { + AttachSource(new Bitmap(AssetLoader.Open(uri))); + } + catch (Exception ex) + { + State = AsyncImageState.Failed; + + RaiseEvent(new AsyncImageFailedEventArgs(ex)); + } + } + else if (uri.Scheme == "file" && File.Exists(uri.LocalPath)) + { + // Added error handling here for local files + try + { + AttachSource(new Bitmap(uri.LocalPath)); + } + catch (Exception ex) + { + State = AsyncImageState.Failed; + + RaiseEvent(new AsyncImageFailedEventArgs(ex)); + } + } + else + { + RaiseEvent( + new AsyncImageFailedEventArgs( + new UriFormatException($"Uri has unsupported scheme. Uri:{source}") + ) + ); + } + } + else + { + RaiseEvent( + new AsyncImageFailedEventArgs( + new UriFormatException($"Relative paths aren't supported. Uri:{source}") + ) + ); + } + } + + private void AttachSource(IImage? image) + { + if (ImagePart != null) + { + ImagePart.Source = image; + } + + _tokenSource?.Cancel(); + _tokenSource = new CancellationTokenSource(); + + if (image == null) + { + State = AsyncImageState.Unloaded; + + ImageTransition?.Start(ImagePart, PlaceholderPart, true, _tokenSource.Token); + } + else if (image.Size != default) + { + State = AsyncImageState.Loaded; + + ImageTransition?.Start(PlaceholderPart, ImagePart, true, _tokenSource.Token); + + RaiseEvent(new RoutedEventArgs(OpenedEvent)); + } + } + + private async Task LoadImageAsync(Uri? url, CancellationToken token) + { + if (await ProvideCachedResourceAsync(url, token) is { } bitmap) + { + return bitmap; + } +#if NET6_0_OR_GREATER + using var client = new HttpClient(); + var stream = await client.GetStreamAsync(url, token).ConfigureAwait(false); + + await using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream, token).ConfigureAwait(false); +#elif NETSTANDARD2_0 + using var client = new HttpClient(); + var response = await client.GetAsync(url, token).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream).ConfigureAwait(false); +#endif + + memoryStream.Position = 0; + return new Bitmap(memoryStream); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SourceProperty) + { + SetSource(Source); + } + } + + protected virtual async Task ProvideCachedResourceAsync(Uri? imageUri, CancellationToken token) + { + if (IsCacheEnabled && imageUri != null) + { + return await ImageCache + .Instance.GetFromCacheAsync(imageUri, cancellationToken: token) + .ConfigureAwait(false); + } + return null; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/CacheBase.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/CacheBase.cs new file mode 100644 index 00000000..a4604082 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/CacheBase.cs @@ -0,0 +1,565 @@ +// Parts of this file was taken from Windows Community Toolkit CacheBase implementation +// https://github.com/CommunityToolkit/WindowsCommunityToolkit/blob/main/Microsoft.Toolkit.Uwp.UI/Cache/ImageCache.cs + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; + +internal abstract class CacheBase +{ + private class ConcurrentRequest + { + public Task? Task { get; set; } + + public bool EnsureCachedCopy { get; set; } + } + + private readonly SemaphoreSlim _cacheFolderSemaphore = new SemaphoreSlim(1); + private string? _baseFolder = null; + private string? _cacheFolderName = null; + + private string? _cacheFolder = null; + private InMemoryStorage? _inMemoryFileStorage = null; + + private ConcurrentDictionary _concurrentTasks = + new ConcurrentDictionary(); + + private HttpClient? _httpClient = null; + + /// + /// Initializes a new instance of the class. + /// + protected CacheBase() + { + var options = CacheOptions.Default; + CacheDuration = options?.CacheDuration ?? TimeSpan.FromDays(1); + _baseFolder = options?.BaseCachePath ?? null; + _inMemoryFileStorage = new InMemoryStorage(); + RetryCount = 1; + } + + /// + /// Gets or sets the life duration of every cache entry. + /// + public TimeSpan CacheDuration { get; set; } + + /// + /// Gets or sets the number of retries trying to ensure the file is cached. + /// + public uint RetryCount { get; set; } + + /// + /// Gets or sets max in-memory item storage count + /// + public int MaxMemoryCacheCount + { + get { return _inMemoryFileStorage?.MaxItemCount ?? 0; } + set + { + if (_inMemoryFileStorage != null) + _inMemoryFileStorage.MaxItemCount = value; + } + } + + /// + /// Gets instance of + /// + protected HttpClient HttpClient + { + get + { + if (_httpClient == null) + { + var messageHandler = new HttpClientHandler(); + + _httpClient = new HttpClient(messageHandler); + } + + return _httpClient; + } + } + + /// + /// Initializes FileCache and provides root folder and cache folder name + /// + /// Folder that is used as root for cache + /// Cache folder name + /// instance of + /// awaitable task + public virtual async Task InitializeAsync( + string? folder = null, + string? folderName = null, + HttpMessageHandler? httpMessageHandler = null + ) + { + _baseFolder = folder; + _cacheFolderName = folderName; + + _cacheFolder = await GetCacheFolderAsync().ConfigureAwait(false); + + if (httpMessageHandler != null) + { + _httpClient = new HttpClient(httpMessageHandler); + } + } + + /// + /// Clears all files in the cache + /// + /// awaitable task + public async Task ClearAsync() + { + var folder = await GetCacheFolderAsync().ConfigureAwait(false); + var files = Directory.EnumerateFiles(folder!); + + await InternalClearAsync(files.Select(x => x as string)).ConfigureAwait(false); + + _inMemoryFileStorage?.Clear(); + } + + /// + /// Clears file if it has expired + /// + /// timespan to compute whether file has expired or not + /// awaitable task + public Task ClearAsync(TimeSpan duration) + { + return RemoveExpiredAsync(duration); + } + + /// + /// Removes cached files that have expired + /// + /// Optional timespan to compute whether file has expired or not. If no value is supplied, is used. + /// awaitable task + public async Task RemoveExpiredAsync(TimeSpan? duration = null) + { + TimeSpan expiryDuration = duration ?? CacheDuration; + + var folder = await GetCacheFolderAsync().ConfigureAwait(false); + var files = Directory.EnumerateFiles(folder!); + + var filesToDelete = new List(); + + foreach (var file in files) + { + if (file == null) + { + continue; + } + + if (await IsFileOutOfDateAsync(file, expiryDuration, false).ConfigureAwait(false)) + { + filesToDelete.Add(file); + } + } + + await InternalClearAsync(filesToDelete).ConfigureAwait(false); + + _inMemoryFileStorage?.Clear(expiryDuration); + } + + /// + /// Removed items based on uri list passed + /// + /// Enumerable uri list + /// awaitable Task + public async Task RemoveAsync(IEnumerable uriForCachedItems) + { + if (uriForCachedItems == null || !uriForCachedItems.Any()) + { + return; + } + + var folder = await GetCacheFolderAsync().ConfigureAwait(false); + var files = Directory.EnumerateFiles(folder!); + var filesToDelete = new List(); + var keys = new List(); + + Dictionary hashDictionary = new Dictionary(); + + foreach (var file in files) + { + hashDictionary.Add(Path.GetFileName(file), file); + } + + foreach (var uri in uriForCachedItems) + { + string fileName = GetCacheFileName(uri); + if (hashDictionary.TryGetValue(fileName, out var file)) + { + filesToDelete.Add(file); + keys.Add(fileName); + } + } + + await InternalClearAsync(filesToDelete).ConfigureAwait(false); + + _inMemoryFileStorage?.Remove(keys); + } + + /// + /// Assures that item represented by Uri is cached. + /// + /// Uri of the item + /// Indicates whether or not exception should be thrown if item cannot be cached + /// Indicates if item should be loaded into the in-memory storage + /// instance of + /// Awaitable Task + public Task PreCacheAsync( + Uri uri, + bool throwOnError = false, + bool storeToMemoryCache = false, + CancellationToken cancellationToken = default(CancellationToken) + ) + { + return GetItemAsync(uri, throwOnError, !storeToMemoryCache, cancellationToken); + } + + /// + /// Retrieves item represented by Uri from the cache. If the item is not found in the cache, it will try to downloaded and saved before returning it to the caller. + /// + /// Uri of the item. + /// Indicates whether or not exception should be thrown if item cannot be found / downloaded. + /// instance of + /// an instance of Generic type + public Task GetFromCacheAsync( + Uri uri, + bool throwOnError = false, + CancellationToken cancellationToken = default(CancellationToken) + ) + { + return GetItemAsync(uri, throwOnError, false, cancellationToken); + } + + /// + /// Gets the string containing cached item for given Uri + /// + /// Uri of the item. + /// a string + public async Task GetFileFromCacheAsync(Uri uri) + { + var folder = await GetCacheFolderAsync().ConfigureAwait(false); + + return Path.Combine(folder!, GetCacheFileName(uri)); + } + + /// + /// Retrieves item represented by Uri from the in-memory cache if it exists and is not out of date. If item is not found or is out of date, default instance of the generic type is returned. + /// + /// Uri of the item. + /// an instance of Generic type + public T? GetFromMemoryCache(Uri uri) + { + T? instance = default(T); + + string fileName = GetCacheFileName(uri); + + if (_inMemoryFileStorage?.MaxItemCount > 0) + { + var msi = _inMemoryFileStorage?.GetItem(fileName, CacheDuration); + if (msi != null) + { + instance = msi.Item; + } + } + + return instance; + } + + /// + /// Cache specific hooks to process items from HTTP response + /// + /// input stream + /// awaitable task + protected abstract Task ConvertFromAsync(Stream stream); + + /// + /// Cache specific hooks to process items from HTTP response + /// + /// storage file + /// awaitable task + protected abstract Task ConvertFromAsync(string baseFile); + + /// + /// Override-able method that checks whether file is valid or not. + /// + /// storage file + /// cache duration + /// option to mark uninitialized file as expired + /// bool indicate whether file has expired or not + protected virtual async Task IsFileOutOfDateAsync( + string file, + TimeSpan duration, + bool treatNullFileAsOutOfDate = true + ) + { + if (file == null) + { + return treatNullFileAsOutOfDate; + } + + var info = new FileInfo(file); + + return info.Length == 0 || DateTime.Now.Subtract(info.LastWriteTime) > duration; + } + + private static string GetCacheFileName(Uri uri) + { + return CreateHash64(uri.ToString()).ToString(); + } + + private static ulong CreateHash64(string str) + { + byte[] utf8 = System.Text.Encoding.UTF8.GetBytes(str); + + ulong value = (ulong)utf8.Length; + for (int n = 0; n < utf8.Length; n++) + { + value += (ulong)utf8[n] << ((n * 5) % 56); + } + + return value; + } + + private async Task GetItemAsync( + Uri uri, + bool throwOnError, + bool preCacheOnly, + CancellationToken cancellationToken + ) + { + T? instance = default(T); + + string fileName = GetCacheFileName(uri); + _concurrentTasks.TryGetValue(fileName, out var request); + + // if similar request exists check if it was preCacheOnly and validate that current request isn't preCacheOnly + if (request != null && request.EnsureCachedCopy && !preCacheOnly) + { + if (request.Task != null) + await request.Task.ConfigureAwait(false); + request = null; + } + + if (request == null) + { + request = new ConcurrentRequest() + { + Task = GetFromCacheOrDownloadAsync(uri, fileName, preCacheOnly, cancellationToken), + EnsureCachedCopy = preCacheOnly + }; + + _concurrentTasks[fileName] = request; + } + + try + { + if (request.Task != null) + instance = await request.Task.ConfigureAwait(false); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine(ex.Message); + if (throwOnError) + { + throw; + } + } + finally + { + _concurrentTasks.TryRemove(fileName, out _); + } + + return instance; + } + + private async Task GetFromCacheOrDownloadAsync( + Uri uri, + string fileName, + bool preCacheOnly, + CancellationToken cancellationToken + ) + { + T? instance = default(T); + + if (_inMemoryFileStorage?.MaxItemCount > 0) + { + var msi = _inMemoryFileStorage?.GetItem(fileName, CacheDuration); + if (msi != null) + { + instance = msi.Item; + } + } + + if (instance != null) + { + return instance; + } + + var folder = await GetCacheFolderAsync().ConfigureAwait(false); + var baseFile = Path.Combine(folder!, fileName); + + bool downloadDataFile = + !File.Exists(baseFile) + || await IsFileOutOfDateAsync(baseFile, CacheDuration).ConfigureAwait(false); + + if (!File.Exists(baseFile)) + { + File.Create(baseFile).Dispose(); + } + + if (downloadDataFile) + { + uint retries = 0; + try + { + while (retries < RetryCount) + { + try + { + instance = await DownloadFileAsync(uri, baseFile, preCacheOnly, cancellationToken) + .ConfigureAwait(false); + + if (instance != null) + { + break; + } + } + catch (FileNotFoundException) { } + + retries++; + } + } + catch (Exception ex) + { + File.Delete(baseFile); + throw; // re-throwing the exception changes the stack trace. just throw + } + } + + if (EqualityComparer.Default.Equals(instance, default(T)) && !preCacheOnly) + { + instance = await ConvertFromAsync(baseFile).ConfigureAwait(false); + + if (_inMemoryFileStorage?.MaxItemCount > 0) + { + var properties = new FileInfo(baseFile); + + var msi = new InMemoryStorageItem(fileName, properties.LastWriteTime, instance); + _inMemoryFileStorage?.SetItem(msi); + } + } + return instance; + } + + private async Task DownloadFileAsync( + Uri uri, + string baseFile, + bool preCacheOnly, + CancellationToken cancellationToken + ) + { + T? instance = default(T); + + using (MemoryStream ms = new MemoryStream()) + { + using (var stream = await HttpClient.GetStreamAsync(uri)) + { + stream.CopyTo(ms); + ms.Flush(); + + ms.Position = 0; + + using (var fs = File.Open(baseFile, FileMode.OpenOrCreate, FileAccess.Write)) + { + ms.CopyTo(fs); + + fs.Flush(); + + ms.Position = 0; + } + } + + // if its pre-cache we aren't looking to load items in memory + if (!preCacheOnly) + { + instance = await ConvertFromAsync(ms).ConfigureAwait(false); + } + } + + return instance; + } + + private async Task InternalClearAsync(IEnumerable files) + { + foreach (var file in files) + { + try + { + File.Delete(file!); + } + catch + { + // Just ignore errors for now} + } + } + } + + /// + /// Initializes with default values if user has not initialized explicitly + /// + /// awaitable task + private async Task ForceInitialiseAsync() + { + if (_cacheFolder != null) + { + return; + } + + await _cacheFolderSemaphore.WaitAsync().ConfigureAwait(false); + + _inMemoryFileStorage = new InMemoryStorage(); + + if (_baseFolder == null) + { + _baseFolder = Path.GetTempPath(); + } + + if (string.IsNullOrWhiteSpace(_cacheFolderName)) + { + _cacheFolderName = GetType().Name; + } + + try + { + _cacheFolder = Path.Combine(_baseFolder, _cacheFolderName); + Directory.CreateDirectory(_cacheFolder); + } + finally + { + _cacheFolderSemaphore.Release(); + } + } + + private async Task GetCacheFolderAsync() + { + if (_cacheFolder == null) + { + await ForceInitialiseAsync().ConfigureAwait(false); + } + + return _cacheFolder; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/CacheOptions.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/CacheOptions.cs new file mode 100644 index 00000000..a07ca33c --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/CacheOptions.cs @@ -0,0 +1,18 @@ +using System; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; + +public class CacheOptions +{ + private static CacheOptions? _cacheOptions; + + public static CacheOptions Default => _cacheOptions ??= new CacheOptions(); + + public static void SetDefault(CacheOptions defaultCacheOptions) + { + _cacheOptions = defaultCacheOptions; + } + + public string? BaseCachePath { get; set; } + public TimeSpan? CacheDuration { get; set; } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/FileCache.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/FileCache.cs new file mode 100644 index 00000000..a273ca0b --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/FileCache.cs @@ -0,0 +1,36 @@ +using System.IO; +using System.Threading.Tasks; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; + +/// +/// Provides methods and tools to cache files in a folder +/// +internal class FileCache : CacheBase +{ + /// + /// Private singleton field. + /// + private static FileCache? _instance; + + /// + /// Gets public singleton property. + /// + public static FileCache Instance => _instance ?? (_instance = new FileCache()); + + protected override Task ConvertFromAsync(Stream stream) + { + // nothing to do in this instance; + return Task.FromResult(""); + } + + /// + /// Returns a cached path + /// + /// storage file + /// awaitable task + protected override Task ConvertFromAsync(string baseFile) + { + return Task.FromResult(baseFile); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/ImageCache.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/ImageCache.cs new file mode 100644 index 00000000..5582b071 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/ImageCache.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; + +/// +/// Provides methods and tools to cache images in a folder +/// +internal class ImageCache : CacheBase +{ + /// + /// Private singleton field. + /// + [ThreadStatic] + private static ImageCache? _instance; + + /// + /// Gets public singleton property. + /// + public static ImageCache Instance => _instance ?? (_instance = new ImageCache()); + + /// + /// Creates a bitmap from a stream + /// + /// input stream + /// awaitable task + protected override async Task ConvertFromAsync(Stream stream) + { + if (stream.Length == 0) + { + throw new FileNotFoundException(); + } + + return new Bitmap(stream); + } + + /// + /// Creates a bitmap from a cached file + /// + /// file + /// awaitable task + protected override async Task ConvertFromAsync(string baseFile) + { + using (var stream = File.OpenRead(baseFile)) + { + return await ConvertFromAsync(stream).ConfigureAwait(false); + } + } + + /// + /// Checks whether file is valid or not. + /// + /// file + /// cache duration + /// option to mark uninitialized file as expired + /// bool indicate whether file has expired or not + protected override async Task IsFileOutOfDateAsync( + string file, + TimeSpan duration, + bool treatNullFileAsOutOfDate = true + ) + { + if (file == null) + { + return treatNullFileAsOutOfDate; + } + + var fileInfo = new FileInfo(file); + + return fileInfo.Length == 0 + || DateTime.Now.Subtract(File.GetLastAccessTime(file)) > duration + || DateTime.Now.Subtract(File.GetLastWriteTime(file)) > duration; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/InMemoryStorage.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/InMemoryStorage.cs new file mode 100644 index 00000000..5643e679 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/InMemoryStorage.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Labs.Controls.Cache; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; + +/// +/// Generic in-memory storage of items +/// +/// T defines the type of item stored +public class InMemoryStorage +{ + private int _maxItemCount; + private ConcurrentDictionary> _inMemoryStorage = + new ConcurrentDictionary>(); + private object _settingMaxItemCountLocker = new object(); + + /// + /// Gets or sets the maximum count of Items that can be stored in this InMemoryStorage instance. + /// + public int MaxItemCount + { + get { return _maxItemCount; } + set + { + if (_maxItemCount == value) + { + return; + } + + _maxItemCount = value; + + lock (_settingMaxItemCountLocker) + { + EnsureStorageBounds(value); + } + } + } + + /// + /// Clears all items stored in memory + /// + public void Clear() + { + _inMemoryStorage.Clear(); + } + + /// + /// Clears items stored in memory based on duration passed + /// + /// TimeSpan to identify expired items + public void Clear(TimeSpan duration) + { + var expirationDate = DateTime.Now.Subtract(duration); + + var itemsToRemove = _inMemoryStorage + .Where(kvp => kvp.Value.LastUpdated <= expirationDate) + .Select(kvp => kvp.Key); + + if (itemsToRemove.Any()) + { + Remove(itemsToRemove); + } + } + + /// + /// Remove items based on provided keys + /// + /// identified of the in-memory storage item + public void Remove(IEnumerable keys) + { + foreach (var key in keys) + { + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + _inMemoryStorage.TryRemove(key, out _); + } + } + + /// + /// Add new item to in-memory storage + /// + /// item to be stored + public void SetItem(InMemoryStorageItem item) + { + if (MaxItemCount == 0) + { + return; + } + + _inMemoryStorage[item.Id] = item; + + // ensure max limit is maintained. trim older entries first + if (_inMemoryStorage.Count > MaxItemCount) + { + var itemsToRemove = _inMemoryStorage + .OrderBy(kvp => kvp.Value.Created) + .Take(_inMemoryStorage.Count - MaxItemCount) + .Select(kvp => kvp.Key); + Remove(itemsToRemove); + } + } + + /// + /// Get item from in-memory storage as long as it has not ex + /// + /// id of the in-memory storage item + /// timespan denoting expiration + /// Valid item if not out of date or return null if out of date or item does not exist + public InMemoryStorageItem? GetItem(string id, TimeSpan duration) + { + if (!_inMemoryStorage.TryGetValue(id, out var tempItem)) + { + return null; + } + + var expirationDate = DateTime.Now.Subtract(duration); + + if (tempItem.LastUpdated > expirationDate) + { + return tempItem; + } + + _inMemoryStorage.TryRemove(id, out _); + + return null; + } + + private void EnsureStorageBounds(int maxCount) + { + if (_inMemoryStorage.Count == 0) + { + return; + } + + if (maxCount == 0) + { + _inMemoryStorage.Clear(); + return; + } + + if (_inMemoryStorage.Count > maxCount) + { + Remove(_inMemoryStorage.Keys.Take(_inMemoryStorage.Count - maxCount)); + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/InMemoryStorageItem.cs b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/InMemoryStorageItem.cs new file mode 100644 index 00000000..4bad770d --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/Cache/InMemoryStorageItem.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; + +/// +/// Generic InMemoryStorageItem holds items for InMemoryStorage. +/// +/// Type is set by consuming cache +public class InMemoryStorageItem +{ + /// + /// Gets the item identifier + /// + public string Id { get; private set; } + + /// + /// Gets the item created timestamp. + /// + public DateTime Created { get; private set; } + + /// + /// Gets the item last updated timestamp. + /// + public DateTime LastUpdated { get; private set; } + + /// + /// Gets the item being stored. + /// + public T Item { get; private set; } + + /// + /// Initializes a new instance of the class. + /// Constructor for InMemoryStorageItem + /// + /// uniquely identifies the item + /// last updated timestamp + /// the item being stored + public InMemoryStorageItem(string id, DateTime lastUpdated, T item) + { + Id = id; + LastUpdated = lastUpdated; + Item = item; + Created = DateTime.Now; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/VendorLabs/LICENSE b/StabilityMatrix.Avalonia/Controls/VendorLabs/LICENSE new file mode 100644 index 00000000..8a6071d2 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/VendorLabs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 AvaloniaUI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings index ef254925..524abe98 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings @@ -2,4 +2,5 @@ Yes Pessimistic UI - True + True + True