using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; #if WPF using System.Windows.Media; #elif UWP using Windows.UI.Xaml.Media; #elif WINUI using Microsoft.UI.Xaml.Media; #endif namespace MapControl { /// /// Loads and optionally caches map tile images for a MapTileLayer. /// public interface ITileImageLoader { Task LoadTilesAsync(IEnumerable tiles, TileSource tileSource, string cacheName, IProgress progress, CancellationToken cancellationToken); } public partial class TileImageLoader : ITileImageLoader { /// /// Default folder path where a persistent cache implementation may save data, i.e. "C:\ProgramData\MapControl\TileCache". /// public static string DefaultCacheFolder => #if UWP Path.Combine(Windows.Storage.ApplicationData.Current.LocalCacheFolder.Path, "TileCache"); #else Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "MapControl", "TileCache"); #endif /// /// An IDistributedCache implementation used to cache tile images. /// The default value is a MemoryDistributedCache instance. /// public static IDistributedCache Cache { get; set; } = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); /// /// Default expiration time for cached tile images. Used when no expiration time /// was transmitted on download. The default value is one day. /// public static TimeSpan DefaultCacheExpiration { get; set; } = TimeSpan.FromDays(1); /// /// Minimum expiration time for cached tile images. A transmitted expiration time /// that falls below this value is ignored. The default value is TimeSpan.Zero. /// public static TimeSpan MinCacheExpiration { get; set; } = TimeSpan.Zero; /// /// Maximum expiration time for cached tile images. A transmitted expiration time /// that exceeds this value is ignored. The default value is ten days. /// public static TimeSpan MaxCacheExpiration { get; set; } = TimeSpan.FromDays(10); /// /// Maximum number of parallel tile loading tasks. The default value is 4. /// public static int MaxLoadTasks { get; set; } = 4; /// /// Indicates whether HTTP requests are cancelled when the LoadTilesAsync method is cancelled. /// If the property value is false, cancellation only stops dequeuing entries from the tile queue, /// but lets currently running requests run to completion. /// public static bool RequestCancellationEnabled { get; set; } private static ILogger logger; private static ILogger Logger => logger ?? (logger = ImageLoader.LoggerFactory?.CreateLogger()); /// /// Loads all pending tiles from the tiles collection. Tile image caching is enabled when the Cache /// property is not null and tileSource.UriFormat starts with "http" and cacheName is a non-empty string. /// public async Task LoadTilesAsync(IEnumerable tiles, TileSource tileSource, string cacheName, IProgress progress, CancellationToken cancellationToken) { using (var semaphore = new SemaphoreSlim(MaxLoadTasks, MaxLoadTasks)) { var pendingTiles = tiles.Where(tile => tile.IsPending).ToList(); var tileCount = 0; async Task LoadTile(Tile tile) { try { await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { return; } tile.IsPending = false; progress?.Report((double)++tileCount / pendingTiles.Count); Logger?.LogTrace("[{thread}] Loading tile image {zoom}/{column}/{row}", Environment.CurrentManagedThreadId, tile.ZoomLevel, tile.Column, tile.Row); var requestCancellationToken = RequestCancellationEnabled ? cancellationToken : CancellationToken.None; try { await LoadTileImage(tile, tileSource, cacheName, requestCancellationToken).ConfigureAwait(false); } catch (Exception ex) { Logger?.LogError(ex, "Failed loading tile image {zoom}/{column}/{row}", tile.ZoomLevel, tile.Column, tile.Row); } semaphore.Release(); } await Task.WhenAll(pendingTiles.Select(LoadTile)); if (cancellationToken.IsCancellationRequested) { Logger?.LogTrace("Cancelled LoadTilesAsync with {count} pending tiles", pendingTiles.Count); } } } private static async Task LoadTileImage(Tile tile, TileSource tileSource, string cacheName, CancellationToken cancellationToken) { // Pass tileSource.LoadImageAsync calls to platform-specific method // LoadTileImage(Tile, Func>) for execution on the UI thread in WinUI and UWP. if (string.IsNullOrEmpty(cacheName)) { Task LoadImage() => tileSource.LoadImageAsync(tile.Column, tile.Row, tile.ZoomLevel, cancellationToken); await LoadTileImage(tile, LoadImage, cancellationToken).ConfigureAwait(false); } else { var uri = tileSource.GetUri(tile.Column, tile.Row, tile.ZoomLevel); if (uri != null) { var buffer = await LoadCachedBuffer(tile, uri, cacheName, cancellationToken).ConfigureAwait(false); if (buffer != null && buffer.Length > 0) { Task LoadImage() => tileSource.LoadImageAsync(buffer); await LoadTileImage(tile, LoadImage, cancellationToken).ConfigureAwait(false); } } } } private static async Task LoadCachedBuffer(Tile tile, Uri uri, string cacheName, CancellationToken cancellationToken) { byte[] buffer = null; var extension = Path.GetExtension(uri.LocalPath); if (string.IsNullOrEmpty(extension) || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)) { extension = ".jpg"; } var cacheKey = $"{cacheName}/{tile.ZoomLevel}/{tile.Column}/{tile.Row}{extension}"; try { buffer = await Cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { Logger?.LogTrace("Cancelled Cache.GetAsync({cacheKey})", cacheKey); } catch (Exception ex) { Logger?.LogError(ex, "Cache.GetAsync({cacheKey})", cacheKey); } if (buffer == null) { var response = await ImageLoader.GetHttpResponseAsync(uri, null, cancellationToken).ConfigureAwait(false); if (response != null) { buffer = response.Buffer ?? Array.Empty(); // cache even if null, when no tile available try { var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = !response.MaxAge.HasValue ? DefaultCacheExpiration : response.MaxAge.Value < MinCacheExpiration ? MinCacheExpiration : response.MaxAge.Value > MaxCacheExpiration ? MaxCacheExpiration : response.MaxAge.Value }; await Cache.SetAsync(cacheKey, buffer, options, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { Logger?.LogTrace("Cancelled Cache.SetAsync({cacheKey})", cacheKey); } catch (Exception ex) { Logger?.LogError(ex, "Cache.SetAsync({cacheKey})", cacheKey); } } } return buffer; } } }