Skip to content

Instantly share code, notes, and snippets.

@DamianSuess
Created January 18, 2025 19:28
Show Gist options
  • Save DamianSuess/96dae336338810b4064906ef6e763674 to your computer and use it in GitHub Desktop.
Save DamianSuess/96dae336338810b4064906ef6e763674 to your computer and use it in GitHub Desktop.
ImageCache for .NET MAUI
using Microsoft.Maui.Storage;
using System.Security.Cryptography;
using System.Text;
// Based on: https://github.com/soyfrien/ImageCache
namespace MyProjectNamespace;
/// <summary>
/// Use this class to cache images from the Internet.
/// Its functions receive a URI and turn the resource into an ImageSource, byte array, Stream, or Func‹Stream›.
/// Where ever you would give a control a URL, a stream, or byte[], do so as normal, but have ImageCache sit in the middle. For example, in .NET MAUI:
///
/// <code>
/// // MauiProgram.cs:
/// builder.Services.AddSingleton‹ImageCache›();
/// </code>
///
/// Then use Dependency Injection to gain access to the class of a View, ViewModel, page or control:
///
/// <code>
/// Using Ppdac.ImageCache.Maui;
/// ImageCache _imageCache;
/// ...
/// Page(ViewModel viewModel, ImageCache imageCache)
/// {
/// _imageCache = imageCache;
/// foreach (Image image in viewModel.Images)
/// Image.Source = _imageCache.GetImageAsImageSource(image.Url);
///
/// Stream imageStream = await _imageCache.GetImageAsStreamAsync(image.Url);
/// Bitmap bitmap = new(imageStream);
///
/// byte[] imageBytes = await _imageCache.GetImageAsBytesAsync(image.Url);
/// ...
/// </code>
/// See the demo project on the project's GitHub page for more examples.
/// </summary>
public class ImageCache
{
/// <summary>
/// Provides a quicker and more energy efficient lookup of the known (and thus cached) <see cref="Uri"/>s when compared to scanning the filesystem.
/// </summary>
/// <remarks>
/// This field isn't intended to be directly manipulated by a consumer, and was originally created to ease development.
/// It's of type <see cref="string"/> instead of <see cref="Uri"/> based on an unproven assumption that this will use slightly less memory.
/// For reasons like these it may be removed in a future version.</remarks>
protected static readonly List<string> _cachedUris = new();
private static readonly HashAlgorithm HashMd5 = MD5.Create();
/// <summary>
/// Gets the number of items in the cache.
/// </summary>
public static int Count => _cachedUris.Count;
/// <summary>Gets or sets the number of seconds after which to fetch a remote resource.</summary>
/// <remarks>Defaults to twelve seconds.</remarks>
/// TODO: Make a test for expired cache files.
public int CacheExpiryInSeconds { get; set; } = 12;
/// <summary>Gets or sets the path of the cache folder.</summary>
/// <remarks>
/// Defaults to the App's CacheDirectory on Windows, Android, iOS, and Mac Catalyst, or when running on .NET 7.0 or newer,
/// or to the user's AppData folder on Windows when running on .NET 5.0 or older.
///
/// Release value for ImageCachePath when Microsoft.Maui.Storage is available: Path.Combine(FileSystem.Current.CacheDirectory, "ppdac");
/// BenchmarkDotNet value: "C:\\Users\\Louis\\source\\repos\\ImageCache.Benchmarks\\bin\\Debug\\Images";
/// </remarks>
#if NET7_0_OR_GREATER && (ANDROID || IOS || MACCATALYST || WINDOWS)
public string ImageCachePath { get; set; } = Path.Combine(FileSystem.Current.CacheDirectory, "ppdac.cache.imagecache");
#else
public string ImageCachePath { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ppdac.cache.imagecache");
#endif
/// <summary>
/// Clears the tracked Uris from memory, but does not delete the files from the filesystem, and will still hit the cache before the Internet.
/// </summary>
public static Task Clear()
{
_cachedUris.Clear();
return Task.CompletedTask;
}
/// <summary>
/// Retrieves the resource at the specified <see cref="Uri"/> as a <see cref="byte"/>[] array.
/// </summary>
/// <param name="uri">The URI is both the key and the location of the image on the Internet.</param>
/// <returns><see cref="byte"/>[] of the image if found, or an empty byte array.</returns>
public async Task<byte[]> GetAsBytesAsync(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}";
if (Directory.Exists(ImageCachePath) is false)
Directory.CreateDirectory(ImageCachePath);
if (File.Exists(pathToCachedFile))
return File.ReadAllBytes(pathToCachedFile);
using HttpClient httpClient = new();
HttpResponseMessage responseMessage = await httpClient.GetAsync(uri).ConfigureAwait(false);
if (responseMessage.IsSuccessStatusCode is false)
return Array.Empty<byte>(); //// throw new Exception($"Failed to get image from {url.AbsoluteUri}.");
using Stream stream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false);
byte[] buffer = new byte[stream.Length];
_ = await stream.ReadAsync(buffer.AsMemory(0, (int)stream.Length))
.ConfigureAwait(false);
return buffer;
}
/// <summary>
/// A Func that returns a MemoryStream which can be used to set an ImageSource
/// on objects like the Microsoft.Maui.Controls.Image or the Microsoft.Maui.Graphics.IImage,
/// for example in conjunction with ImageSource.FromStream(Func‹stream› stream):
/// <code>
/// Microsoft.Maui.Controls.Image _img;
/// ImageCache _imageCache = new ImageCache();
/// _img.Source = ImageSource.FromStream(_imageCache.AsFuncStream(url))
/// </code>
/// </summary>
/// <example>
/// Microsoft.Maui.Controls.Image _img;
/// ImageCache _imageCache = new ImageCache();
/// _img.Source = ImageSource.FromStream(_imageCache.AsFuncStream(url));
/// </example>
/// <param name="uri">A <see cref="Uri"/> whose .AbsoluteUri property is the location of the image.</param>
/// <returns>A <see cref="MemoryStream"/> of the byte array of the image.</returns>
public async Task<Func<Stream>> GetAsFuncStreamAsync(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
if (!_cachedUris.Contains(uri.AbsoluteUri))
await KeepAsync(uri);
// byte[] buffer = await GetFromStorageAsBytesAsync(uri);
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}";
byte[] buffer = File.ReadAllBytes(pathToCachedFile);
return () => new MemoryStream(buffer);
}
/// <summary>
/// Looks for a key equal to the URL and returns a stream of byte[], which can be used to set an ImageSource
/// on objects like the Microsoft.Maui.Controls.Image or the Microsoft.Maui.Graphics.IImage.
///
/// If not found, the image is cached and then as a MemoryStream of its buffer.
/// </summary>
/// <param name="uri">The URI is both the key and the location of the image on the Internet.</param>
/// <returns>A MemoryStream of the byte array of the image.</returns>
public async Task<Stream> GetAsStreamAsync(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
if (Directory.Exists(ImageCachePath) is false)
Directory.CreateDirectory(ImageCachePath);
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}";
if (File.Exists(pathToCachedFile) is false)
await KeepAsync(uri);
byte[] buffer = await File.ReadAllBytesAsync(pathToCachedFile)
.ConfigureAwait(false);
return new MemoryStream(buffer);
}
/// <summary>
/// Looks for a key equal to the string and returns a the size image in buffer.
/// If not found it is added then the size is returned. If null, -1 is returned.
/// </summary>
/// <remarks>This is currently limited to <see cref="Int32.MaxValue"/> as it uses <see cref="Stream.ReadAsync"/> which returns a <see cref="ValueTask"/>‹int›.</remarks>
/// <param name="uri">The URL is both the key and the location of the image on the Internet.</param>
/// <returns>The size of the image if found in buffer, as long. Or -1, if null.</returns>
public async Task<int> GetByteCountAsync(Uri uri)
{
if (uri is null)
return -1;
if (!_cachedUris.Contains(uri.AbsoluteUri))
{
_cachedUris.Add(uri.AbsoluteUri);
await KeepAsync(uri).ConfigureAwait(false);
}
// return (await GetImageAsBytesAsync(uri).ConfigureAwait(false))?.Length ?? -1;
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}";
if (Directory.Exists(ImageCachePath) is false)
Directory.CreateDirectory(ImageCachePath);
if (File.Exists(pathToCachedFile))
return File.ReadAllBytes(pathToCachedFile).Length;
using HttpClient httpClient = new();
HttpResponseMessage responseMessage = await httpClient.GetAsync(uri).ConfigureAwait(false);
if (responseMessage.IsSuccessStatusCode is false)
return -404; //// throw new Exception($"Failed to get image from {url.AbsoluteUri}.");
using Stream stream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false);
return await stream.ReadAsync(new byte[stream.Length])
.ConfigureAwait(false);
}
/// <summary>
/// See <see cref="GetByteCountAsync(Uri)"/>.
/// </summary>
/// <remarks>Using a <see cref="Uri"/> instead of a <see cref="string"/> URL is recommended.</remarks>
/// <param name="url">The URL is both the key and the location of the image on the Internet.</param>
/// <returns>The size of the image if found in buffer. Or -1, if null.</returns>
public async Task<int> GetByteCountAsync(string url) => await GetByteCountAsync(new Uri(url));
/// <summary>
/// Saves the resource to the <see cref="FileSystem.CacheDirectory"/> and becomes aware of its <see cref="Uri"/>.
/// </summary>
/// <remarks>You can use this to pre-cache items.</remarks>
/// <param name="uri">The URI of the image about to be kept in storage.</param>
public async Task KeepAsync(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
if (Directory.Exists(ImageCachePath) is false)
Directory.CreateDirectory(ImageCachePath);
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}";
if (File.Exists(pathToCachedFile))
return;
byte[]? bytes = await GetAsBytesAsync(uri).ConfigureAwait(false);
await File.WriteAllBytesAsync($"{pathToCachedFile}", bytes!).ConfigureAwait(false);
if (_cachedUris.Contains(uri.AbsoluteUri) is false)
_cachedUris.Add(uri.AbsoluteUri);
}
/// <summary>
/// Removes the cache from the file system, but does not clear the tracked URIs in memory.
/// </summary>
/// <code>StatusLabel.Text = _imageStore.Purge().Result;</code>
/// <remarks>You can, for example, Purge the images from disk and use <see cref="Save"/> to write them back to the cache.</remarks>
/// <returns>A string from a TResult.</returns>
public Task<string> Purge()
{
if (Directory.Exists(ImageCachePath) is false)
return Task.FromResult($"{ImageCachePath} wasn't there.");
if (Directory.GetFiles(ImageCachePath).ToList().Count > 0)
{
foreach (string file in Directory.GetFiles(ImageCachePath).ToList())
File.Delete(file);
}
Directory.Delete($"{ImageCachePath}");
return Task.FromResult($"Image cache purged from {ImageCachePath}.");
}
/// <summary>
/// Restores the cache from the file system.
/// </summary>
/// <code>StatusLabel.Text = _imageStore.Restore().Result;</code>
/// <remarks>For example, when used after using Dependency Injection to access the ImageCache</remarks>
/// <returns>A string as TResult.</returns>
public Task<string> Restore()
{
if (!Directory.Exists(ImageCachePath))
Directory.CreateDirectory(ImageCachePath);
int count = Directory.GetFiles(ImageCachePath).ToList().Count;
return Task.FromResult($"{count} items in cache.");
}
/// <summary>
/// Gives an item count and size of the cache, in mebibytes.
/// </summary>
/// <returns>A Task‹string› giving the item count and amount of storage used.</returns>
public Task<string> Report()
{
if (!Directory.Exists(ImageCachePath))
Directory.CreateDirectory(ImageCachePath);
int count = Directory.GetFiles(ImageCachePath).ToList().Count;
long size = 0;
foreach (string file in Directory.GetFiles(ImageCachePath).ToList())
size += new FileInfo(file).Length;
if (size / 1024 / 1024 / 1024 >= 1)
return Task.FromResult($"{count} items in cache ({size / 1024 / 1024 / 1024} GiB).");
else if (size / 1024 / 1024 >= 1)
return Task.FromResult($"{count} items in cache ({size / 1024 / 1024} MiB).");
else if ((size / 1024) >= 1)
return Task.FromResult($"{count} items in cache ({size / 1024} KiB).");
else
return Task.FromResult($"{count} items in cache ({size} bytes).");
}
/// <summary>Saves the cache to the filesystem.</summary>
/// <code>StatusLabel.Text = _imageStore.Save().Result;</code>
/// <remarks>Files are now saved automatically. <see cref="Restore"/> is also no longer neccessary.</remarks>
/// <returns>A result string from a TResult. </returns>
public async Task<string> SaveAsync()
{
foreach (string url in _cachedUris)
await KeepAsync(new Uri(url)).ConfigureAwait(false);
return "Tracked URIs saved to cache.";
}
/// <summary>
/// Gets image from <see cref="Uri"/> and returns it as an <see cref="ImageSource"/>.
/// </summary>
/// <param name="uri"><see cref="Uri"/> of image to cache.</param>
/// <returns>The image is returned as an <see cref="ImageSource"/>.</returns>
public async Task<ImageSource?> GetAsImageSourceAsync(Uri uri)
{
if (string.IsNullOrEmpty(uri.AbsoluteUri))
return null!;
if (Directory.Exists(ImageCachePath) is false)
Directory.CreateDirectory(ImageCachePath);
string pathToCachedFile = Path.Combine(ImageCachePath, GetFilename(uri));
if (File.Exists(pathToCachedFile) is false || _cachedUris.Contains(uri.AbsoluteUri) is false)
await KeepAsync(uri).ConfigureAwait(false);
byte[] buffer = await File.ReadAllBytesAsync(pathToCachedFile).ConfigureAwait(false);
return ImageSource.FromStream(() =>
{
return new MemoryStream(buffer);
});
}
/// <summary>See <see cref="GetAsBytesAsync(Uri)"/>.</summary>
/// <param name="url">The string URL</param>
/// <returns>A byte array.</returns>
internal async Task<byte[]?> GetAsBytesAsync(string url) => await GetAsBytesAsync(new Uri(url));
/// <summary>
/// Looks for a key equal to the URL and returns a stream of <see cref="byte"/>[],
/// which can be used to set an ImageSource.
/// </summary>
/// <param name="uri">A <see cref="Uri"/> whose .AbsoluteUri property is the location of the image.</param>
/// <returns>A <see cref="byte"/> array of the image from storage.</returns>
internal async Task<byte[]> GetFromStorageAsBytesAsync(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
string pathToCachedFile = Path.Combine(ImageCachePath, GetFilename(uri));
if (Directory.Exists(ImageCachePath) is false)
{
Directory.CreateDirectory(ImageCachePath);
await KeepAsync(uri);
}
return File.ReadAllBytes(pathToCachedFile);
}
/// <summary>Checks the date of the cached file and returns true if it's older than the specified time.</summary>
/// <param name="filename">The filename of the cached file.</param>
/// <returns>True if the file is older than the specified time.</returns>
internal bool CachedFileExpired(string filename)
{
TimeSpan timeSinceLastWrite = DateTime.Now - File.GetLastWriteTime(filename);
if (timeSinceLastWrite.TotalSeconds > CacheExpiryInSeconds)
return true;
else
return false;
}
/// <summary>
/// Using the first sixteen buffer of the <see cref="Uri"/>, computes the deterministic filename as a <see cref="Guid"/> <see cref="string"/>.
/// </summary>
/// <param name="uri">The <see cref="Uri"/> whose filename to lookup.</param>
/// <returns>A <see cref="Guid"/> as a <see cref="string"/>.</returns>
/// <remarks>
/// TODO: Even after switching to 16-byte hashes, is this a good solution? Probably not.
/// Also, is a faster MD5 implementation really better than a slower collision-free hash?
/// Needs both tested.
/// </remarks>
protected internal string GetFilename(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
lock (HashMd5)
{
byte[] uriHash = HashMd5.ComputeHash(Encoding.UTF8.GetBytes(uri.AbsoluteUri));
Guid filename = new(uriHash);
var pth = Path.Combine(ImageCachePath, filename.ToString());
if (CachedFileExpired(pth) is true)
File.Delete(pth);
return $"{filename}";
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment