Ionite
7 months ago
12 changed files with 1368 additions and 1 deletions
@ -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; } |
||||
} |
||||
} |
@ -0,0 +1,42 @@
|
||||
using System; |
||||
using Avalonia.Interactivity; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Controls.VendorLabs; |
||||
|
||||
public partial class BetterAsyncImage |
||||
{ |
||||
/// <summary> |
||||
/// Deines the <see cref="Opened"/> event |
||||
/// </summary> |
||||
public static readonly RoutedEvent<RoutedEventArgs> OpenedEvent = RoutedEvent.Register< |
||||
BetterAsyncImage, |
||||
RoutedEventArgs |
||||
>(nameof(Opened), RoutingStrategies.Bubble); |
||||
|
||||
/// <summary> |
||||
/// Deines the <see cref="Failed"/> event |
||||
/// </summary> |
||||
public static readonly RoutedEvent<global::Avalonia.Labs.Controls.AsyncImage.AsyncImageFailedEventArgs> FailedEvent = |
||||
RoutedEvent.Register< |
||||
BetterAsyncImage, |
||||
global::Avalonia.Labs.Controls.AsyncImage.AsyncImageFailedEventArgs |
||||
>(nameof(Failed), RoutingStrategies.Bubble); |
||||
|
||||
/// <summary> |
||||
/// Occurs when the image is successfully loaded. |
||||
/// </summary> |
||||
public event EventHandler<RoutedEventArgs>? Opened |
||||
{ |
||||
add => AddHandler(OpenedEvent, value); |
||||
remove => RemoveHandler(OpenedEvent, value); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Occurs when the image fails to load the uri provided. |
||||
/// </summary> |
||||
public event EventHandler<global::Avalonia.Labs.Controls.AsyncImage.AsyncImageFailedEventArgs>? Failed |
||||
{ |
||||
add => AddHandler(FailedEvent, value); |
||||
remove => RemoveHandler(FailedEvent, value); |
||||
} |
||||
} |
@ -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 |
||||
{ |
||||
/// <summary> |
||||
/// Defines the <see cref="PlaceholderSource"/> property. |
||||
/// </summary> |
||||
public static readonly StyledProperty<IImage?> PlaceholderSourceProperty = AvaloniaProperty.Register< |
||||
BetterAsyncImage, |
||||
IImage? |
||||
>(nameof(PlaceholderSource)); |
||||
|
||||
/// <summary> |
||||
/// Defines the <see cref="Source"/> property. |
||||
/// </summary> |
||||
public static readonly StyledProperty<Uri?> SourceProperty = AvaloniaProperty.Register< |
||||
BetterAsyncImage, |
||||
Uri? |
||||
>(nameof(Source)); |
||||
|
||||
/// <summary> |
||||
/// Defines the <see cref="Stretch"/> property. |
||||
/// </summary> |
||||
public static readonly StyledProperty<Stretch> StretchProperty = AvaloniaProperty.Register< |
||||
BetterAsyncImage, |
||||
Stretch |
||||
>(nameof(Stretch), Stretch.Uniform); |
||||
|
||||
/// <summary> |
||||
/// Defines the <see cref="PlaceholderStretch"/> property. |
||||
/// </summary> |
||||
public static readonly StyledProperty<Stretch> PlaceholderStretchProperty = AvaloniaProperty.Register< |
||||
BetterAsyncImage, |
||||
Stretch |
||||
>(nameof(PlaceholderStretch), Stretch.Uniform); |
||||
|
||||
/// <summary> |
||||
/// Defines the <see cref="State"/> property. |
||||
/// </summary> |
||||
public static readonly DirectProperty<BetterAsyncImage, AsyncImageState> StateProperty = |
||||
AvaloniaProperty.RegisterDirect<BetterAsyncImage, AsyncImageState>( |
||||
nameof(State), |
||||
o => o.State, |
||||
(o, v) => o.State = v |
||||
); |
||||
|
||||
/// <summary> |
||||
/// Defines the <see cref="ImageTransition"/> property. |
||||
/// </summary> |
||||
public static readonly StyledProperty<IPageTransition?> ImageTransitionProperty = |
||||
AvaloniaProperty.Register<BetterAsyncImage, IPageTransition?>( |
||||
nameof(ImageTransition), |
||||
new CrossFade(TimeSpan.FromSeconds(0.25)) |
||||
); |
||||
|
||||
/// <summary> |
||||
/// Defines the <see cref="IsCacheEnabled"/> property. |
||||
/// </summary> |
||||
public static readonly DirectProperty<BetterAsyncImage, bool> IsCacheEnabledProperty = |
||||
AvaloniaProperty.RegisterDirect<BetterAsyncImage, bool>( |
||||
nameof(IsCacheEnabled), |
||||
o => o.IsCacheEnabled, |
||||
(o, v) => o.IsCacheEnabled = v |
||||
); |
||||
private bool _isCacheEnabled; |
||||
|
||||
/// <summary> |
||||
/// Gets or sets the placeholder image. |
||||
/// </summary> |
||||
public IImage? PlaceholderSource |
||||
{ |
||||
get => GetValue(PlaceholderSourceProperty); |
||||
set => SetValue(PlaceholderSourceProperty, value); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets or sets the uri pointing to the image resource |
||||
/// </summary> |
||||
public Uri? Source |
||||
{ |
||||
get => GetValue(SourceProperty); |
||||
set => SetValue(SourceProperty, value); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets or sets a value controlling how the image will be stretched. |
||||
/// </summary> |
||||
public Stretch Stretch |
||||
{ |
||||
get { return GetValue(StretchProperty); } |
||||
set { SetValue(StretchProperty, value); } |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets or sets a value controlling how the placeholder will be stretched. |
||||
/// </summary> |
||||
public Stretch PlaceholderStretch |
||||
{ |
||||
get { return GetValue(StretchProperty); } |
||||
set { SetValue(StretchProperty, value); } |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets the current loading state of the image. |
||||
/// </summary> |
||||
public AsyncImageState State |
||||
{ |
||||
get => _state; |
||||
private set => SetAndRaise(StateProperty, ref _state, value); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets or sets the transition to run when the image is loaded. |
||||
/// </summary> |
||||
public IPageTransition? ImageTransition |
||||
{ |
||||
get => GetValue(ImageTransitionProperty); |
||||
set => SetValue(ImageTransitionProperty, value); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets or sets whether to use cache for retrieved images |
||||
/// </summary> |
||||
public bool IsCacheEnabled |
||||
{ |
||||
get => _isCacheEnabled; |
||||
set => SetAndRaise(IsCacheEnabledProperty, ref _isCacheEnabled, value); |
||||
} |
||||
} |
@ -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; |
||||
|
||||
/// <summary> |
||||
/// An image control that asynchronously retrieves an image using a <see cref="Uri"/>. |
||||
/// </summary> |
||||
[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<Image>("PART_Image"); |
||||
PlaceholderPart = e.NameScope.Get<Image>("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<Bitmap> 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<Bitmap?> ProvideCachedResourceAsync(Uri? imageUri, CancellationToken token) |
||||
{ |
||||
if (IsCacheEnabled && imageUri != null) |
||||
{ |
||||
return await ImageCache |
||||
.Instance.GetFromCacheAsync(imageUri, cancellationToken: token) |
||||
.ConfigureAwait(false); |
||||
} |
||||
return null; |
||||
} |
||||
} |
@ -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<T> |
||||
{ |
||||
private class ConcurrentRequest |
||||
{ |
||||
public Task<T?>? 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<T>? _inMemoryFileStorage = null; |
||||
|
||||
private ConcurrentDictionary<string, ConcurrentRequest> _concurrentTasks = |
||||
new ConcurrentDictionary<string, ConcurrentRequest>(); |
||||
|
||||
private HttpClient? _httpClient = null; |
||||
|
||||
/// <summary> |
||||
/// Initializes a new instance of the <see cref="CacheBase{T}"/> class. |
||||
/// </summary> |
||||
protected CacheBase() |
||||
{ |
||||
var options = CacheOptions.Default; |
||||
CacheDuration = options?.CacheDuration ?? TimeSpan.FromDays(1); |
||||
_baseFolder = options?.BaseCachePath ?? null; |
||||
_inMemoryFileStorage = new InMemoryStorage<T>(); |
||||
RetryCount = 1; |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets or sets the life duration of every cache entry. |
||||
/// </summary> |
||||
public TimeSpan CacheDuration { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Gets or sets the number of retries trying to ensure the file is cached. |
||||
/// </summary> |
||||
public uint RetryCount { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Gets or sets max in-memory item storage count |
||||
/// </summary> |
||||
public int MaxMemoryCacheCount |
||||
{ |
||||
get { return _inMemoryFileStorage?.MaxItemCount ?? 0; } |
||||
set |
||||
{ |
||||
if (_inMemoryFileStorage != null) |
||||
_inMemoryFileStorage.MaxItemCount = value; |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets instance of <see cref="HttpClient"/> |
||||
/// </summary> |
||||
protected HttpClient HttpClient |
||||
{ |
||||
get |
||||
{ |
||||
if (_httpClient == null) |
||||
{ |
||||
var messageHandler = new HttpClientHandler(); |
||||
|
||||
_httpClient = new HttpClient(messageHandler); |
||||
} |
||||
|
||||
return _httpClient; |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Initializes FileCache and provides root folder and cache folder name |
||||
/// </summary> |
||||
/// <param name="folder">Folder that is used as root for cache</param> |
||||
/// <param name="folderName">Cache folder name</param> |
||||
/// <param name="httpMessageHandler">instance of <see cref="HttpMessageHandler"/></param> |
||||
/// <returns>awaitable task</returns> |
||||
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); |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Clears all files in the cache |
||||
/// </summary> |
||||
/// <returns>awaitable task</returns> |
||||
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(); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Clears file if it has expired |
||||
/// </summary> |
||||
/// <param name="duration">timespan to compute whether file has expired or not</param> |
||||
/// <returns>awaitable task</returns> |
||||
public Task ClearAsync(TimeSpan duration) |
||||
{ |
||||
return RemoveExpiredAsync(duration); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Removes cached files that have expired |
||||
/// </summary> |
||||
/// <param name="duration">Optional timespan to compute whether file has expired or not. If no value is supplied, <see cref="CacheDuration"/> is used.</param> |
||||
/// <returns>awaitable task</returns> |
||||
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<string>(); |
||||
|
||||
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); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Removed items based on uri list passed |
||||
/// </summary> |
||||
/// <param name="uriForCachedItems">Enumerable uri list</param> |
||||
/// <returns>awaitable Task</returns> |
||||
public async Task RemoveAsync(IEnumerable<Uri> uriForCachedItems) |
||||
{ |
||||
if (uriForCachedItems == null || !uriForCachedItems.Any()) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
var folder = await GetCacheFolderAsync().ConfigureAwait(false); |
||||
var files = Directory.EnumerateFiles(folder!); |
||||
var filesToDelete = new List<string>(); |
||||
var keys = new List<string>(); |
||||
|
||||
Dictionary<string, string> hashDictionary = new Dictionary<string, string>(); |
||||
|
||||
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); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Assures that item represented by Uri is cached. |
||||
/// </summary> |
||||
/// <param name="uri">Uri of the item</param> |
||||
/// <param name="throwOnError">Indicates whether or not exception should be thrown if item cannot be cached</param> |
||||
/// <param name="storeToMemoryCache">Indicates if item should be loaded into the in-memory storage</param> |
||||
/// <param name="cancellationToken">instance of <see cref="CancellationToken"/></param> |
||||
/// <returns>Awaitable Task</returns> |
||||
public Task PreCacheAsync( |
||||
Uri uri, |
||||
bool throwOnError = false, |
||||
bool storeToMemoryCache = false, |
||||
CancellationToken cancellationToken = default(CancellationToken) |
||||
) |
||||
{ |
||||
return GetItemAsync(uri, throwOnError, !storeToMemoryCache, cancellationToken); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// 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. |
||||
/// </summary> |
||||
/// <param name="uri">Uri of the item.</param> |
||||
/// <param name="throwOnError">Indicates whether or not exception should be thrown if item cannot be found / downloaded.</param> |
||||
/// <param name="cancellationToken">instance of <see cref="CancellationToken"/></param> |
||||
/// <returns>an instance of Generic type</returns> |
||||
public Task<T?> GetFromCacheAsync( |
||||
Uri uri, |
||||
bool throwOnError = false, |
||||
CancellationToken cancellationToken = default(CancellationToken) |
||||
) |
||||
{ |
||||
return GetItemAsync(uri, throwOnError, false, cancellationToken); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Gets the string containing cached item for given Uri |
||||
/// </summary> |
||||
/// <param name="uri">Uri of the item.</param> |
||||
/// <returns>a string</returns> |
||||
public async Task<string> GetFileFromCacheAsync(Uri uri) |
||||
{ |
||||
var folder = await GetCacheFolderAsync().ConfigureAwait(false); |
||||
|
||||
return Path.Combine(folder!, GetCacheFileName(uri)); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// 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. |
||||
/// </summary> |
||||
/// <param name="uri">Uri of the item.</param> |
||||
/// <returns>an instance of Generic type</returns> |
||||
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; |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Cache specific hooks to process items from HTTP response |
||||
/// </summary> |
||||
/// <param name="stream">input stream</param> |
||||
/// <returns>awaitable task</returns> |
||||
protected abstract Task<T> ConvertFromAsync(Stream stream); |
||||
|
||||
/// <summary> |
||||
/// Cache specific hooks to process items from HTTP response |
||||
/// </summary> |
||||
/// <param name="baseFile">storage file</param> |
||||
/// <returns>awaitable task</returns> |
||||
protected abstract Task<T> ConvertFromAsync(string baseFile); |
||||
|
||||
/// <summary> |
||||
/// Override-able method that checks whether file is valid or not. |
||||
/// </summary> |
||||
/// <param name="file">storage file</param> |
||||
/// <param name="duration">cache duration</param> |
||||
/// <param name="treatNullFileAsOutOfDate">option to mark uninitialized file as expired</param> |
||||
/// <returns>bool indicate whether file has expired or not</returns> |
||||
protected virtual async Task<bool> 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<T?> 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<T?> 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<T>.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<T>(fileName, properties.LastWriteTime, instance); |
||||
_inMemoryFileStorage?.SetItem(msi); |
||||
} |
||||
} |
||||
return instance; |
||||
} |
||||
|
||||
private async Task<T?> 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<string?> files) |
||||
{ |
||||
foreach (var file in files) |
||||
{ |
||||
try |
||||
{ |
||||
File.Delete(file!); |
||||
} |
||||
catch |
||||
{ |
||||
// Just ignore errors for now} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Initializes with default values if user has not initialized explicitly |
||||
/// </summary> |
||||
/// <returns>awaitable task</returns> |
||||
private async Task ForceInitialiseAsync() |
||||
{ |
||||
if (_cacheFolder != null) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
await _cacheFolderSemaphore.WaitAsync().ConfigureAwait(false); |
||||
|
||||
_inMemoryFileStorage = new InMemoryStorage<T>(); |
||||
|
||||
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<string?> GetCacheFolderAsync() |
||||
{ |
||||
if (_cacheFolder == null) |
||||
{ |
||||
await ForceInitialiseAsync().ConfigureAwait(false); |
||||
} |
||||
|
||||
return _cacheFolder; |
||||
} |
||||
} |
@ -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; } |
||||
} |
@ -0,0 +1,36 @@
|
||||
using System.IO; |
||||
using System.Threading.Tasks; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||
|
||||
/// <summary> |
||||
/// Provides methods and tools to cache files in a folder |
||||
/// </summary> |
||||
internal class FileCache : CacheBase<string> |
||||
{ |
||||
/// <summary> |
||||
/// Private singleton field. |
||||
/// </summary> |
||||
private static FileCache? _instance; |
||||
|
||||
/// <summary> |
||||
/// Gets public singleton property. |
||||
/// </summary> |
||||
public static FileCache Instance => _instance ?? (_instance = new FileCache()); |
||||
|
||||
protected override Task<string> ConvertFromAsync(Stream stream) |
||||
{ |
||||
// nothing to do in this instance; |
||||
return Task.FromResult<string>(""); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Returns a cached path |
||||
/// </summary> |
||||
/// <param name="baseFile">storage file</param> |
||||
/// <returns>awaitable task</returns> |
||||
protected override Task<string> ConvertFromAsync(string baseFile) |
||||
{ |
||||
return Task.FromResult(baseFile); |
||||
} |
||||
} |
@ -0,0 +1,76 @@
|
||||
using System; |
||||
using System.IO; |
||||
using System.Threading.Tasks; |
||||
using Avalonia.Media.Imaging; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Controls.VendorLabs.Cache; |
||||
|
||||
/// <summary> |
||||
/// Provides methods and tools to cache images in a folder |
||||
/// </summary> |
||||
internal class ImageCache : CacheBase<Bitmap> |
||||
{ |
||||
/// <summary> |
||||
/// Private singleton field. |
||||
/// </summary> |
||||
[ThreadStatic] |
||||
private static ImageCache? _instance; |
||||
|
||||
/// <summary> |
||||
/// Gets public singleton property. |
||||
/// </summary> |
||||
public static ImageCache Instance => _instance ?? (_instance = new ImageCache()); |
||||
|
||||
/// <summary> |
||||
/// Creates a bitmap from a stream |
||||
/// </summary> |
||||
/// <param name="stream">input stream</param> |
||||
/// <returns>awaitable task</returns> |
||||
protected override async Task<Bitmap> ConvertFromAsync(Stream stream) |
||||
{ |
||||
if (stream.Length == 0) |
||||
{ |
||||
throw new FileNotFoundException(); |
||||
} |
||||
|
||||
return new Bitmap(stream); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Creates a bitmap from a cached file |
||||
/// </summary> |
||||
/// <param name="baseFile">file</param> |
||||
/// <returns>awaitable task</returns> |
||||
protected override async Task<Bitmap> ConvertFromAsync(string baseFile) |
||||
{ |
||||
using (var stream = File.OpenRead(baseFile)) |
||||
{ |
||||
return await ConvertFromAsync(stream).ConfigureAwait(false); |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Checks whether file is valid or not. |
||||
/// </summary> |
||||
/// <param name="file">file</param> |
||||
/// <param name="duration">cache duration</param> |
||||
/// <param name="treatNullFileAsOutOfDate">option to mark uninitialized file as expired</param> |
||||
/// <returns>bool indicate whether file has expired or not</returns> |
||||
protected override async Task<bool> 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; |
||||
} |
||||
} |
@ -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; |
||||
|
||||
/// <summary> |
||||
/// Generic in-memory storage of items |
||||
/// </summary> |
||||
/// <typeparam name="T">T defines the type of item stored</typeparam> |
||||
public class InMemoryStorage<T> |
||||
{ |
||||
private int _maxItemCount; |
||||
private ConcurrentDictionary<string, InMemoryStorageItem<T>> _inMemoryStorage = |
||||
new ConcurrentDictionary<string, InMemoryStorageItem<T>>(); |
||||
private object _settingMaxItemCountLocker = new object(); |
||||
|
||||
/// <summary> |
||||
/// Gets or sets the maximum count of Items that can be stored in this InMemoryStorage instance. |
||||
/// </summary> |
||||
public int MaxItemCount |
||||
{ |
||||
get { return _maxItemCount; } |
||||
set |
||||
{ |
||||
if (_maxItemCount == value) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
_maxItemCount = value; |
||||
|
||||
lock (_settingMaxItemCountLocker) |
||||
{ |
||||
EnsureStorageBounds(value); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Clears all items stored in memory |
||||
/// </summary> |
||||
public void Clear() |
||||
{ |
||||
_inMemoryStorage.Clear(); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Clears items stored in memory based on duration passed |
||||
/// </summary> |
||||
/// <param name="duration">TimeSpan to identify expired items</param> |
||||
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); |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Remove items based on provided keys |
||||
/// </summary> |
||||
/// <param name="keys">identified of the in-memory storage item</param> |
||||
public void Remove(IEnumerable<string> keys) |
||||
{ |
||||
foreach (var key in keys) |
||||
{ |
||||
if (string.IsNullOrWhiteSpace(key)) |
||||
{ |
||||
continue; |
||||
} |
||||
|
||||
_inMemoryStorage.TryRemove(key, out _); |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Add new item to in-memory storage |
||||
/// </summary> |
||||
/// <param name="item">item to be stored</param> |
||||
public void SetItem(InMemoryStorageItem<T> 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); |
||||
} |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Get item from in-memory storage as long as it has not ex |
||||
/// </summary> |
||||
/// <param name="id">id of the in-memory storage item</param> |
||||
/// <param name="duration">timespan denoting expiration</param> |
||||
/// <returns>Valid item if not out of date or return null if out of date or item does not exist</returns> |
||||
public InMemoryStorageItem<T>? 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)); |
||||
} |
||||
} |
||||
} |
@ -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; |
||||
|
||||
/// <summary> |
||||
/// Generic InMemoryStorageItem holds items for InMemoryStorage. |
||||
/// </summary> |
||||
/// <typeparam name="T">Type is set by consuming cache</typeparam> |
||||
public class InMemoryStorageItem<T> |
||||
{ |
||||
/// <summary> |
||||
/// Gets the item identifier |
||||
/// </summary> |
||||
public string Id { get; private set; } |
||||
|
||||
/// <summary> |
||||
/// Gets the item created timestamp. |
||||
/// </summary> |
||||
public DateTime Created { get; private set; } |
||||
|
||||
/// <summary> |
||||
/// Gets the item last updated timestamp. |
||||
/// </summary> |
||||
public DateTime LastUpdated { get; private set; } |
||||
|
||||
/// <summary> |
||||
/// Gets the item being stored. |
||||
/// </summary> |
||||
public T Item { get; private set; } |
||||
|
||||
/// <summary> |
||||
/// Initializes a new instance of the <see cref="InMemoryStorageItem{T}"/> class. |
||||
/// Constructor for InMemoryStorageItem |
||||
/// </summary> |
||||
/// <param name="id">uniquely identifies the item</param> |
||||
/// <param name="lastUpdated">last updated timestamp</param> |
||||
/// <param name="item">the item being stored</param> |
||||
public InMemoryStorageItem(string id, DateTime lastUpdated, T item) |
||||
{ |
||||
Id = id; |
||||
LastUpdated = lastUpdated; |
||||
Item = item; |
||||
Created = DateTime.Now; |
||||
} |
||||
} |
@ -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. |
Loading…
Reference in new issue