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