diff --git a/.gitignore b/.gitignore index f6b7bdaa8..370a69658 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ migrate_working_dir/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. -#.vscode/ +.vscode/ # Flutter/Dart/Pub related # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 3ae100b98..72bfc53dd 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -29,6 +29,11 @@ export 'package:flutter_map/src/layer/attribution_layer/rich/widget.dart'; export 'package:flutter_map/src/layer/attribution_layer/simple.dart'; export 'package:flutter_map/src/layer/circle_layer/circle_layer.dart'; export 'package:flutter_map/src/layer/marker_layer/marker_layer.dart'; +export 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/built_in_caching_provider.dart'; +export 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/caching_provider.dart'; +export 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/disabled/disabled_caching_provider.dart'; +export 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_metadata.dart'; +export 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_read_failure_exception.dart'; export 'package:flutter_map/src/layer/overlay_image_layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer/label/deprecated_placements.dart'; export 'package:flutter_map/src/layer/polygon_layer/label/placement_calculators/placement_calculator.dart'; @@ -49,11 +54,6 @@ export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset/provider.da export 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/file/stub_tile_provider.dart' if (dart.library.io) 'package:flutter_map/src/layer/tile_layer/tile_provider/file/native_tile_provider.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/built_in/built_in_caching_provider.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/caching_provider.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/disabled/disabled_caching_provider.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/tile_metadata.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/tile_read_failure_exception.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_provider/network/tile_provider.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; diff --git a/lib/src/layer/modern_tile_layer/README.md b/lib/src/layer/modern_tile_layer/README.md new file mode 100644 index 000000000..e3c620977 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/README.md @@ -0,0 +1,35 @@ +# Modern Tile Layer + +The modern tile layer is a rework of the original tile layer which: + +* should be significantly more flexible (-> provide better integration support for plugins) +* resolve some hard-to-debug bugs +* improve performance in the default case + +It does this by: + +* splitting the logic of the current `TileLayer` & `TileProvider` into 3-5 parts: + * `BaseTileLayer`: responsible for tile management (initial workings provided by @mootw) + + * a tile loader: responsible for getting the data for individual tiles given the coordinates from the manager + In the default implementation, this is further split: + * a source generator: responsible for telling the source fetcher what to fetch for the tile + * a source fetcher: responsible for actually fetching the tile data + In the default implementation, this is further split: + * a bytes fetcher: responsible for actually fetching the tile data + + * a tile renderer: responsible for painting tiled data + +* using a canvas implementation for the default raster tile layer + +Significant uestions remaining: + +* Is the default tile loader setup (with two stages) too much frameworking/overly-complicated? +* Simulating retina mode affects all parts of the system - but only (conceptually/for reasoning) applies to raster tiles (although technically it's no different to a top layer option). How should this be represented? +* What should the top-level options be (`TileLayerOptions`)? See also retina mode simulation. +* Who's responsibility is enforcing the max-zoom level? Is max-zoom = native max-zoom or MapOptions.maxZoom? + +This new functionality has no deadline or estimated completion date - although it's something we've been wanting to do for a while, and we have some work in the +background which may be integrating with this. + +Contribution greatly appriciated! diff --git a/lib/src/layer/modern_tile_layer/base_tile_data.dart b/lib/src/layer/modern_tile_layer/base_tile_data.dart new file mode 100644 index 000000000..723fc1583 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/base_tile_data.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:flutter_map/src/layer/modern_tile_layer/base_tile_layer.dart'; +import 'package:meta/meta.dart'; + +/// Data associated with a particular tile coordinate which 'loads' +/// asynchronously. +/// +/// These are generated by the [BaseTileLayer.tileLoader] and consumed by the +/// [BaseTileLayer.renderer]. +/// +/// Association with a tile coordinate is made in the tile layer. +/// +/// It is up to the implementation as to what 'loads' means. However, the +/// [BaseTileLayer] will use [whenLoaded], [isLoaded], and [dispose] to manage +/// (such as pruning) the tile for the renderer. +abstract interface class BaseTileData { + /// Completes when the underlying resource is 'loaded' + Future get whenLoaded; + + /// Whether the underlying resource is 'loaded' + bool get isLoaded; + + /// Called when a tile is removed from the map of visible tiles + /// + /// This should usually be used to abort loading of the underlying resource + /// if it has not yet loaded, or release the resources held by it if already + /// loaded. + /// + /// This should not usually be called externally. + @internal + void dispose(); +} + +/// Wrapper for custom-shape data as a [BaseTileData]. +/// +/// The data carried is usually made available asynchronously, for example as +/// the result of an I/O operation or HTTP request. Alternatively, data may be +/// available synchronously if the data is loaded from prepared memory. This +/// container supports either form of data. +class WrapperTileData implements BaseTileData { + D? _data; + + /// Data resource + /// + /// This may be `null` if [D] is nullable & the data is `null`. In this case, + /// use [isLoaded] to determine whether this accurately reflects the `null` + /// data. Otherwise, `null` means the data is not yet available. + D? get data => _data; + + final _loadedTracker = Completer.sync(); + + /// Completes with loaded data when the data is loaded successfully + /// + /// This never completes if the data completes to an error. + @override + Future get whenLoaded => _loadedTracker.future; + + /// Whether [data] represents the loaded data + @override + bool get isLoaded => _loadedTracker.isCompleted; + + @internal + @override + void dispose() => _dispose?.call(); + final void Function()? _dispose; + + /// Create a container with the specified data (or the data result of the + /// specified future) + WrapperTileData({ + required FutureOr data, + void Function()? dispose, + }) : _dispose = dispose { + if (data is Future) { + data.then((data) => _loadedTracker.complete(_data = data)); + } else { + _loadedTracker.complete(_data = data); + } + } +} diff --git a/lib/src/layer/modern_tile_layer/base_tile_layer.dart b/lib/src/layer/modern_tile_layer/base_tile_layer.dart new file mode 100644 index 000000000..edcc590af --- /dev/null +++ b/lib/src/layer/modern_tile_layer/base_tile_layer.dart @@ -0,0 +1,251 @@ +import 'dart:collection'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/base_tile_data.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/options.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/tile_loader.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; + +/// A map layer formed from adjacent square tiles loaded individually on demand +/// +/// This widget provides the tile management logic, giving responsibility for +/// tile loading and tile rendering to the [tileLoader] & [renderer] delegates +/// respectively. +/// +/// This layer is often used to draw the map itself, for example as raster image +/// tiles. However, it may be used for any reasonable purpose where the contract +/// is met. +class BaseTileLayer extends StatefulWidget { + final TileLayerOptions options; + final TileLoader tileLoader; + final Widget Function( + BuildContext context, + Object layerKey, + TileLayerOptions options, + Map<({TileCoordinates coordinates, Object layerKey}), D> visibleTiles, + ) renderer; + + const BaseTileLayer({ + super.key, + this.options = const TileLayerOptions(), + required this.tileLoader, + required this.renderer, + }); + + @override + State> createState() => _BaseTileLayerState(); +} + +class _BaseTileLayerState + extends State> { + late Object layerKey = UniqueKey(); + + final tiles = _TilesTracker(); + + @override + void didUpdateWidget(covariant BaseTileLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.options != widget.options || + oldWidget.tileLoader != widget.tileLoader || + oldWidget.renderer != widget.renderer) { + layerKey = UniqueKey(); + } + } + + @override + Widget build(BuildContext context) { + final camera = MapCamera.of(context); + final zoom = camera.zoom.round(); + final visibleTileCoordinates = _getVisibleTiles(camera); + + // Load new tiles + for (final coordinates in visibleTileCoordinates) { + final key = (coordinates: coordinates, layerKey: layerKey); + tiles.putIfAbsent( + key, + () => widget.tileLoader(coordinates, widget.options) + ..whenLoaded.then((_) => _pruneOnLoadedTile(key)), + // TODO: Consider how to handle errors + ); + } + + // Prune tiles that are at the same zoom level, but not visible to the camera + // These tiles are NEVER visible to the camera regardless of their loading + // status + tiles.removeWhere( + (key, _) => + key.coordinates.z == zoom && + !visibleTileCoordinates.contains(key.coordinates), + ); + + // If all visible tiles are loaded correctly, prune ALL other tiles + // This is mostly a catch-all, as there is likely some weird edge case + // that keeps old tiles loaded when they shouldn't be + final allLoaded = tiles.entries + .where( + (tile) => + visibleTileCoordinates.contains(tile.key.coordinates) && + tile.key.layerKey == layerKey, + ) + .every((tile) => tile.value.isLoaded); + if (allLoaded) { + tiles.removeWhere( + (key, _) => !visibleTileCoordinates.contains(key.coordinates), + ); + } + + return widget.renderer( + context, + layerKey, + widget.options, + Map.unmodifiable(tiles), + ); + } + + /// Eventually pruning could be restricted to tiles if there is an animation + /// phase that needs to be waited for, quite easily! + void _pruneOnLoadedTile(_TileKey key) { + /// PRUNE PHASE + // Remove all identical tiles of other (old) keys. aka replace my ancestor + tiles.removeWhere( + (otherKey, otherData) => + otherData.isLoaded && + otherKey.coordinates == key.coordinates && + otherKey.layerKey != key.layerKey, + ); + + for (final childCoordinates in key.coordinates.children()) { + // Prune all children + // TODO decide if children of different keys should be pruned + // or not + tiles.removeWhere( + (otherKey, otherData) => + otherData.isLoaded && otherKey.coordinates == childCoordinates, + ); + } + + // TODO there is still some minor flickering when zooming quickly + // This appears to be caused by pruning tiles that are loaded but + // then getting replaced with tiles that get loaded but then pruned. + // It seems to only happen when zooming more than 1 level at a time + + if (key.coordinates.z != 0) { + // Ensure that this is not called on the z = 0 tile + final siblingCoordinates = key.coordinates.parent().children(); + siblingCoordinates.remove(key.coordinates); + + // True when all tiles are loaded with the latest key + final allLoaded = tiles.entries + .where( + (other) => + siblingCoordinates.contains(other.key.coordinates) && + other.key.layerKey == key.layerKey, + ) + .every((other) => other.value.isLoaded); + + if (allLoaded) { + // Prune parent if me and my siblings are all loaded + // Key does not matter as the tile is getting replaced by + // tiles with the correct key + tiles.removeWhere( + (otherKey, _) => otherKey.coordinates == key.coordinates.parent(), + ); + } + } + + /// PRUNE COMPLETE + if (mounted) { + setState(() {}); + } + } + + Offset _floor(Offset point) => + Offset(point.dx.floorToDouble(), point.dy.floorToDouble()); + + Offset _ceil(Offset point) => + Offset(point.dx.ceilToDouble(), point.dy.ceilToDouble()); + + Rect _calculatePixelBounds( + MapCamera camera, + LatLng center, + double viewingZoom, + int tileZoom, + ) { + final tileZoomDouble = tileZoom.toDouble(); + final scale = camera.getZoomScale(viewingZoom, tileZoomDouble); + final pixelCenter = camera.projectAtZoom(center, tileZoomDouble); + final halfSize = camera.size / (scale * 2); + + return Rect.fromPoints( + pixelCenter - halfSize.bottomRight(Offset.zero), + pixelCenter + halfSize.bottomRight(Offset.zero), + ); + } + + List _getVisibleTiles(MapCamera camera) { + final pixelBounds = _calculatePixelBounds( + camera, + camera.center, + camera.zoom, + camera.zoom.round(), // TODO: `maxZoom`? + ); + + final tileBounds = Rect.fromPoints( + _floor(pixelBounds.topLeft / widget.options.tileDimension.toDouble()), + _ceil(pixelBounds.bottomRight / widget.options.tileDimension.toDouble()) - + const Offset(1, 1), + ); + + return [ + for (int x = tileBounds.left.round(); x <= tileBounds.right; x++) + for (int y = tileBounds.top.round(); y <= tileBounds.bottom; y++) + TileCoordinates(x, y, camera.zoom.round()), + ]; + } +} + +extension _ParentChildTraversal on TileCoordinates { + /// This tile coordinate zoomed out by one + TileCoordinates parent() => z == 0 + ? throw RangeError.range( + 0, + 0, + null, + null, + 'Tiles at zoom level 0 are orphans', + ) + : TileCoordinates(x ~/ 2, y ~/ 2, z - 1); + + /// This tile coordinate but zoomed in by 1 + Set children() { + final topLeftChild = TileCoordinates(x * 2, y * 2, z + 1); + + return { + topLeftChild, + TileCoordinates(topLeftChild.x + 1, topLeftChild.y, topLeftChild.z), + TileCoordinates(topLeftChild.x, topLeftChild.y + 1, topLeftChild.z), + TileCoordinates(topLeftChild.x + 1, topLeftChild.y + 1, topLeftChild.z), + }; + } +} + +typedef _TileKey = ({TileCoordinates coordinates, Object layerKey}); + +extension type _TilesTracker._( + SplayTreeMap<_TileKey, D> map) implements SplayTreeMap<_TileKey, D> { + _TilesTracker() + : this._( + SplayTreeMap<_TileKey, D>( + (a, b) => + a.coordinates.z.compareTo(b.coordinates.z) | + a.coordinates.x.compareTo(b.coordinates.x) | + a.coordinates.y.compareTo(b.coordinates.y), + ), + ); + + @redeclare + D? remove(Object? key) => map.remove(key)?..dispose(); +} diff --git a/lib/src/layer/modern_tile_layer/options.dart b/lib/src/layer/modern_tile_layer/options.dart new file mode 100644 index 000000000..b233ebcdd --- /dev/null +++ b/lib/src/layer/modern_tile_layer/options.dart @@ -0,0 +1,41 @@ +import 'package:flutter_map/src/layer/modern_tile_layer/base_tile_layer.dart'; +import 'package:meta/meta.dart'; + +/// Configuration of a [BaseTileLayer], which can be used by all parts of the +/// tile layer. +@immutable +class TileLayerOptions { + final double maxZoom; // TODO: Is this the same as the old `nativeMaxZoom`? + final double zoomOffset; + final bool zoomReverse; + + /// Size in pixels of each tile image. + /// + /// Should be a positive power of 2. Defaults to 256px. + /// + /// If increasing past 256(px) (default), adjust [zoomOffset] as necessary, + /// for example 512px: -1. + final int tileDimension; + + /// Configuration of a [BaseTileLayer], which can be used by all parts of the + /// tile layer. + const TileLayerOptions({ + this.maxZoom = double.infinity, + this.zoomOffset = 0, + this.zoomReverse = false, + this.tileDimension = 256, + }); + + @override + int get hashCode => + Object.hash(maxZoom, zoomOffset, zoomReverse, tileDimension); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TileLayerOptions && + other.maxZoom == maxZoom && + other.zoomOffset == zoomOffset && + other.zoomReverse == zoomReverse && + other.tileDimension == tileDimension); +} diff --git a/lib/src/layer/modern_tile_layer/source_generators/source_generator.dart b/lib/src/layer/modern_tile_layer/source_generators/source_generator.dart new file mode 100644 index 000000000..75f19e6db --- /dev/null +++ b/lib/src/layer/modern_tile_layer/source_generators/source_generator.dart @@ -0,0 +1,13 @@ +import 'package:flutter_map/src/layer/modern_tile_layer/options.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:meta/meta.dart'; + +/// Responsible for generating a tile's 'source' ([S]), which is later used to +/// get the tile's data, given the [TileCoordinates] and ambient +/// [TileLayerOptions]. +@immutable +abstract interface class SourceGenerator { + /// Generate the 'source' ([S]) for the tile at [coordinates], with the + /// ambient layer [options]. + S call(TileCoordinates coordinates, TileLayerOptions options); +} diff --git a/lib/src/layer/modern_tile_layer/source_generators/wms.dart b/lib/src/layer/modern_tile_layer/source_generators/wms.dart new file mode 100644 index 000000000..4a084f78a --- /dev/null +++ b/lib/src/layer/modern_tile_layer/source_generators/wms.dart @@ -0,0 +1,115 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_map/src/geo/crs.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/options.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/source_generator.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/tile_source.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:flutter_map/src/misc/extensions.dart'; +import 'package:meta/meta.dart'; + +/// A tile source generator which generates tiles for the +/// [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) referencing system. +@immutable +class WMSSourceGenerator implements SourceGenerator { + /// WMS service's URL, for example 'http://ows.mundialis.de/services/service?' + final String baseUrl; + + /// List of WMS layers to show + final List layers; + + /// List of WMS styles + final List styles; + + /// WMS image format (use 'image/png' for layers with transparency) + final String format; + + /// Version of the WMS service to use + final String version; + + /// Whether to make tiles transparent + final bool transparent; + + /// Encode boolean values as uppercase in request + final bool uppercaseBoolValue; + + /// Sets map projection standard + final Crs crs; + + /// The scalar to multiply the calculated width & height for each request by. + /// + /// This may be used to simulate retina mode, for example, by setting to 2. + /// + /// Defaults to 1. + // TODO: This is simulating retina mode - see README for questions + final int dimensionsMultiplier; + + /// Other request parameters + final Map otherParameters; + + late final String _encodedBaseUrl; + + late final double _versionNumber; + + /// Create a new [WMSSourceGenerator] instance. + WMSSourceGenerator({ + required this.baseUrl, + this.layers = const [], + this.styles = const [], + this.format = 'image/png', + this.version = '1.1.1', + this.transparent = true, + this.uppercaseBoolValue = false, + this.crs = const Epsg3857(), + this.dimensionsMultiplier = 1, + this.otherParameters = const {}, + }) { + _versionNumber = double.tryParse(version.split('.').take(2).join('.')) ?? 0; + _encodedBaseUrl = _buildEncodedBaseUrl(); + } + + String _buildEncodedBaseUrl() { + final projectionKey = _versionNumber >= 1.3 ? 'crs' : 'srs'; + final buffer = StringBuffer(baseUrl) + ..write('&service=WMS') + ..write('&request=GetMap') + ..write('&layers=${layers.map(Uri.encodeComponent).join(',')}') + ..write('&styles=${styles.map(Uri.encodeComponent).join(',')}') + ..write('&format=${Uri.encodeComponent(format)}') + ..write('&$projectionKey=${Uri.encodeComponent(crs.code)}') + ..write('&version=${Uri.encodeComponent(version)}') + ..write( + '&transparent=${uppercaseBoolValue ? transparent.toString().toUpperCase() : transparent}'); + otherParameters + .forEach((k, v) => buffer.write('&$k=${Uri.encodeComponent(v)}')); + return buffer.toString(); + } + + @override + TileSource call(TileCoordinates coordinates, TileLayerOptions options) { + final nwPoint = Offset( + (coordinates.x * options.tileDimension).toDouble(), + (coordinates.y * options.tileDimension).toDouble(), + ); + final sePoint = + nwPoint + (const Offset(1, 1) * options.tileDimension.toDouble()); + + final nwCoords = crs.offsetToLatLng(nwPoint, coordinates.z.toDouble()); + final seCoords = crs.offsetToLatLng(sePoint, coordinates.z.toDouble()); + + final nw = crs.projection.project(nwCoords); + final se = crs.projection.project(seCoords); + + final bounds = Rect.fromPoints(nw, se); + final bbox = (_versionNumber >= 1.3 && crs is Epsg4326) + ? [bounds.min.dy, bounds.min.dx, bounds.max.dy, bounds.max.dx] + : [bounds.min.dx, bounds.min.dy, bounds.max.dx, bounds.max.dy]; + + return TileSource( + (StringBuffer(_encodedBaseUrl) + ..write('&width=${options.tileDimension * dimensionsMultiplier}') + ..write('&height=${options.tileDimension * dimensionsMultiplier}') + ..write('&bbox=${bbox.join(',')}')) + .toString(), + ); + } +} diff --git a/lib/src/layer/modern_tile_layer/source_generators/xyz.dart b/lib/src/layer/modern_tile_layer/source_generators/xyz.dart new file mode 100644 index 000000000..258fead1e --- /dev/null +++ b/lib/src/layer/modern_tile_layer/source_generators/xyz.dart @@ -0,0 +1,153 @@ +import 'package:flutter_map/src/layer/modern_tile_layer/options.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/source_generator.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/file/file_stub.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/tile_source.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:meta/meta.dart'; + +/// A tile source generator which generates tiles for slippy map tile servers +/// following the standard XYZ tile referencing system. +/// +/// [Slippy maps](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames) are +/// also known as [tiled web maps](https://en.wikipedia.org/wiki/Tiled_web_map) +/// more generally, or sometimes as 'CARTO'. This is the most common map tile +/// referencing system in use. +/// +/// This generator can also support part of the alternative +/// [TMS](https://en.wikipedia.org/wiki/Tile_Map_Service) standard by flipping +/// the Y axis. +@immutable +class XYZSourceGenerator implements SourceGenerator { + /// List of endpoints for tile resources, in XYZ template format. + /// + /// Endpoints are used by the [TileGenerator] in use, and so their meaning + /// is context dependent. For example, a HTTP URL would likely be used with + /// the [NetworkBytesFetcher], whilst a file URI would be used with the + /// [FileBytesFetcher]. + /// + /// In all 3 default [SourceBytesFetcher]s, the first endpoint is used for + /// requests, unless it fails, in which case following endpoints are used as + /// fallbacks. + /// + /// > [!WARNING] + /// > Using fallbacks may incur a (potentially significant) performance + /// > penalty, and may not be understood by all [TileGenerator]s. + /// > Note that failing each endpoint may take some time (such as a HTTP + /// > timeout elapsing). + /// + /// The following placeholders are supported, in addition to any described in + /// [additionalPlaceholders] : + /// + /// * `{z}`, `{x}`, `{y}`: tile coordinates + /// * `{s}`: subdomain chosen from [subdomains] + /// * `{r}`: retina mode (filled with "@2x" when enabled) + /// * `{d}`: current [TileLayerOptions.tileDimension] + final List uriTemplates; + + /// List of subdomains for the [uriTemplates] (to replace the `{s}` + /// placeholder). + /// + /// > [!NOTE] + /// > This may no longer be necessary for many tile servers in many cases. + /// > See online documentation for more information. + final List subdomains; + + /// Static information that should replace associated placeholders in the + /// [uriTemplates]. + /// + /// For example, this could be used to more easily apply API keys to + /// templates. + /// + /// Override [generateReplacementMap] to dynamically generate placeholders. + final Map additionalPlaceholders; + + /// Whether to invert Y axis numbering for tiles. + final bool tms; + + /// A tile source generator which generates tiles for slippy map tile servers + /// following the standard XYZ tile referencing system. + const XYZSourceGenerator({ + required this.uriTemplates, + this.subdomains = const [], + this.additionalPlaceholders = const {}, + this.tms = false, + }); + + @override + TileSource call(TileCoordinates coordinates, TileLayerOptions options) { + assert(uriTemplates.isNotEmpty, '`uriTemplates` must not be empty'); + + final replacementMap = generateReplacementMap(coordinates, options); + + String replacer(Match match) { + final value = replacementMap[match.group(1)!]; + if (value != null) return value; + throw ArgumentError('Missing value for placeholder: {${match.group(1)}}'); + } + + return TileSource( + uriTemplates[0].replaceAllMapped(templatePlaceholderElement, replacer), + fallbackUris: uriTemplates + .skip(1) + // Lazily generate fallback URIs as required + .map((t) => t.replaceAllMapped(templatePlaceholderElement, replacer)), + ); + } + + /// Generates the mapping of [uriTemplates] placeholders to replacements. + @visibleForOverriding + Map generateReplacementMap( + TileCoordinates coordinates, + TileLayerOptions options, + ) { + final zoom = (options.zoomOffset + + (options.zoomReverse + ? options.maxZoom - coordinates.z.toDouble() + : coordinates.z.toDouble())) + .round(); + + return { + 'x': coordinates.x.toString(), + 'y': (tms ? ((1 << zoom) - 1) - coordinates.y : coordinates.y).toString(), + 'z': zoom.toString(), + 's': subdomains.isEmpty + ? '' + : subdomains[(coordinates.x + coordinates.y) % subdomains.length], + // TODO: Retina mode + // We can easily implement server retina mode: simulated retina mode + // requires cooperation with renderer! + //'r': options.resolvedRetinaMode == RetinaMode.server ? '@2x' : '', + 'd': options.tileDimension.toString(), + ...additionalPlaceholders, + }; + } + + /// Regex that describes the format of placeholders in a `uriTemplate`. + /// + /// The regex used prior to v6 originated from leaflet.js, specifically from + /// commit [dc79b10683d2](https://github.com/Leaflet/Leaflet/commit/dc79b10683d232b9637cbe4d65567631f4fa5a0b). + /// Prior to that, a more permissive regex was used, starting from commit + /// [70339807ed6b](https://github.com/Leaflet/Leaflet/commit/70339807ed6bec630ee9c2e96a9cb8356fa6bd86). + /// It is never mentioned why this regex was used or changed in Leaflet. + /// This regex is more permissive of the characters it allows. + static final templatePlaceholderElement = RegExp('{([^{}]*)}'); + + @override + int get hashCode => Object.hash( + uriTemplates, + subdomains, + additionalPlaceholders, + tms, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is XYZSourceGenerator && + other.uriTemplates == uriTemplates && + other.subdomains == subdomains && + other.additionalPlaceholders == additionalPlaceholders && + other.tms == tms); +} diff --git a/lib/src/layer/modern_tile_layer/tile_layers/raster/tile_layer.dart b/lib/src/layer/modern_tile_layer/tile_layers/raster/tile_layer.dart new file mode 100644 index 000000000..9741ed1da --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_layers/raster/tile_layer.dart @@ -0,0 +1,115 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/base_tile_layer.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/options.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/source_generator.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/xyz.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/raster/tile_data.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/raster/tile_loader.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/tile_source.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; + +class RasterTileLayer extends StatefulWidget { + const RasterTileLayer({ + super.key, + this.options = const TileLayerOptions(), + required this.sourceGenerator, + required this.bytesFetcher, + }); + + RasterTileLayer.simple({ + super.key, + this.options = const TileLayerOptions(), + required String urlTemplate, + required String uaIdentifier, + }) : sourceGenerator = XYZSourceGenerator(uriTemplates: [urlTemplate]), + bytesFetcher = NetworkBytesFetcher(uaIdentifier: uaIdentifier); + + final TileLayerOptions options; + final SourceGenerator sourceGenerator; + final SourceBytesFetcher> bytesFetcher; + + @override + State createState() => _RasterTileLayerState(); +} + +class _RasterTileLayerState extends State { + @override + Widget build(BuildContext context) => BaseTileLayer( + options: widget.options, + tileLoader: RasterTileLoader( + sourceGenerator: const XYZSourceGenerator(uriTemplates: ['']), + bytesFetcher: widget.bytesFetcher, + ), + renderer: (context, layerKey, options, visibleTiles) => _RasterRenderer( + layerKey: layerKey, + options: options, + visibleTiles: visibleTiles, + ), + ); +} + +class _RasterRenderer extends StatefulWidget { + _RasterRenderer({ + required Object layerKey, + required this.options, + required this.visibleTiles, + }) : super(key: ValueKey(layerKey)); + + final TileLayerOptions options; + final Map<({TileCoordinates coordinates, Object layerKey}), RasterTileData> + visibleTiles; + + @override + State<_RasterRenderer> createState() => __RasterRendererState(); +} + +class __RasterRendererState extends State<_RasterRenderer> { + //final Map<({TileCoordinates coordinates, Object layerKey}), + // TileData> visibleTiles = {}; + + @override + void didUpdateWidget(covariant _RasterRenderer oldWidget) { + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size.infinite, + willChange: true, + painter: _RasterPainter( + options: widget.options, + visibleTiles: widget.visibleTiles, + //tiles: tiles..sort(renderOrder), + //tilePaint: widget.tilePaint, + //tileOverlayPainter: widget.tileOverlayPainter, + ), + ); + } +} + +class _RasterPainter extends CustomPainter { + final TileLayerOptions options; + final Map<({TileCoordinates coordinates, Object layerKey}), RasterTileData> + visibleTiles; + + _RasterPainter({ + super.repaint, + required this.options, + required this.visibleTiles, + }); + + @override + void paint(Canvas canvas, Size size) { + for (final MapEntry(key: (:coordinates, layerKey: _), value: tile) + in visibleTiles.entries) {} + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + // TODO: implement shouldRepaint + throw UnimplementedError(); + } +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/asset/asset.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/asset/asset.dart new file mode 100644 index 000000000..2183f68c8 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/asset/asset.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart'; + +/// A [SourceBytesFetcher] which fetches a URI from the app's shipped assets. +/// +/// {@macro fm.sbf.default.sourceConsumption} +/// +/// In normal usage, all tiles (or at least each individual lowest-level +/// directory) must be listed as normal in the pubspec. +// TODO: This a considerably different implementation - check performance +@immutable +class AssetBytesFetcher implements SourceBytesFetcher> { + /// Asset bundle to retrieve tiles from. + final AssetBundle? assetBundle; + + /// A [SourceBytesFetcher] which fetches from the app's shipped assets. + /// + /// By default, this uses the default [rootBundle]. If a different bundle is + /// required, either specify it manually, or use the + /// [AssetBytesFetcher.fromContext] constructor. + const AssetBytesFetcher({this.assetBundle}); + + /// A [SourceBytesFetcher] which fetches from the app's shipped assets. + /// + /// Gets the asset bundle from the [DefaultAssetBundle] depending on the + /// provided context. + AssetBytesFetcher.fromContext(BuildContext context) + : assetBundle = DefaultAssetBundle.of(context); + + @override + Future call({ + required Iterable source, + required Future abortSignal, + required BytesToResourceTransformer transformer, + BytesReceivedCallback? bytesLoadedCallback, + }) { + final bundle = assetBundle ?? rootBundle; + return fetchFromSourceIterable( + (uri, transformer, isFirst) => + bundle.load(uri).then(Uint8List.sublistView).then(transformer), + source: source, + transformer: transformer, + ); + } +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart new file mode 100644 index 000000000..81f7e8d54 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/raster/tile_loader.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/tile_loader.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/tile_source.dart'; +import 'package:logger/logger.dart'; + +/// Fetches a tile's bytes based on its source ([S]), transforming it into a +/// desired resource using a supplied [BytesToResourceTransformer]. +/// +/// Implementers should implement longer-term caching where necessary, or +/// delegate to a cacher. Note that [TileLoader]s may also perform caching of +/// the resulting resource, often in the short-term - such as the +/// [RasterTileLoader] using the Flutter [ImageCache]. +abstract interface class SourceBytesFetcher { + /// Fetches a tile's bytes based on its source ([S]), transforming it into a + /// desired resource ([R]) using a supplied [transformer]. + /// + /// The [abortSignal] completes when the tile is no longer required. If + /// possible, any ongoing work (such as an HTTP request) should be aborted. + /// If aborting and a result is unavailable, [TileAbortedException] should be + /// thrown. + /// + /// See [BytesToResourceTransformer] for more information about handling the + /// [transformer]. + /// + /// [bytesLoadedCallback] (if provided), may be called as/when bytes are + /// loaded (before [transformer] is called). See [BytesReceivedCallback] for + /// more information. + FutureOr call({ + required S source, + required Future abortSignal, + required BytesToResourceTransformer transformer, + BytesReceivedCallback? bytesLoadedCallback, + }); +} + +/// Exception thrown when a tile was loading but aborted early as it was no +/// longer required. +class TileAbortedException implements Exception { + /// Optional description of the tile. + final Object? source; + + /// Exception thrown when a tile was loading but aborted early as it was no + /// longer required. + const TileAbortedException({this.source}); + + @override + String toString() => 'TileAbortedException: $source'; +} + +/// Callback provided to a [SourceBytesFetcher] by a [TileLoader], which +/// converts fetched bytes into the desired [Resource]. +/// +/// This may throw if the bytes could not be correctly transformed, for example +/// because they were corrupted or otherwise undecodable. In this case, it is +/// the bytes fetcher's responsibility to catch the error and act accordingly, +/// potentially by returning another (for example, a fallback) resource and/or +/// disabling the long-term caching of this tile. +/// +/// --- +/// +/// Whilst it is the [SourceBytesFetcher]s responsibility to implement long-term +/// caching where necessary, other parts of the stack (such as the [TileLoader]) +/// may also perform short-term caching, which requires a key. +/// +/// If the resulting resource differs to what is expected and used as the key +/// - for example, in the case of a fallback being used whilst the only stable +/// key is the primary endpoint - then this must indicate that the resource may +/// not be reused under the key (i.e. not cached). This is done by setting +/// [allowReuse] `false`. +/// +/// For example, the [TileSource] object is suitable as a key - but where one +/// of its [TileSource.fallbackUris] was used, [allowReuse] must be set `false`. +/// +/// Implementers should make the default of [allowReuse] `true`. +typedef BytesToResourceTransformer + = FutureOr Function(Uint8List bytes, {bool allowReuse}); + +/// Provides utilities to [SourceBytesFetcher]s which consume [Iterable] +/// sources. +extension IterableSourceConsumer on SourceBytesFetcher> { + /// Consecutively execute a callback ([fetcher]) on each element of the + /// non-empty [source] in iteration order, until the result (as returned by + /// the [transformer]) is not an error. + /// + /// --- + /// + /// 'Fallbacks' are all elements of the source except the first (mandatory) + /// element. + /// + /// If any result is a [TileAbortedException], (further) fallbacks are not + /// attempted. If the first result was [TileAbortedException], it is rethrown. + /// + /// If all fallbacks fail or a fallback is aborted, then the error thrown by + /// the first element is thrown. + /// + /// For all fallbacks, the [transformer] is automatically modified to disable + /// re-use of the bytes. See [BytesToResourceTransformer] for more info. This + /// meets [TileSource]'s requirements. + /// + /// Emits a log for each fallback attempted. + @protected + Future fetchFromSourceIterable( + Future Function( + T element, + BytesToResourceTransformer transformer, + bool isFirst, + ) fetcher, { + required Iterable source, + required BytesToResourceTransformer transformer, + }) async { + final firstElement = source.firstOrNull ?? + (throw ArgumentError('must have at least one element', 'source')); + + try { + return await fetcher(firstElement, transformer, true); + } on TileAbortedException { + rethrow; // Don't try fallbacks when aborted + } on Exception { + // Lazily initialise logger + late final logger = Logger(printer: SimplePrinter()); + + // Iterate through fallbacks + for (final fallbackUri in source.skip(1)) { + if (kDebugMode) { + logger.w( + '[flutter_map] Attempting fallback URI ($fallbackUri) instead of ' + '$firstElement', + ); + } + + try { + return await fetcher( + fallbackUri, + (bytes, {allowReuse = true}) => + transformer(bytes, allowReuse: false), + false, + ); + } on TileAbortedException { + // Don't try any further fallbacks when aborted, but still throw + // the primary URI's exception instead of `TileAbortedException` + break; + } on Exception { + // Attempt further fallbacks + continue; + } + } + + // This means we always throw the exception from the primary URI, when + // either there are no fallbacks or they have all been exhausted + rethrow; + } + } +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/file/file_io.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/file/file_io.dart new file mode 100644 index 000000000..5269e8294 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/file/file_io.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart'; + +/// A [SourceBytesFetcher] which fetches a URI from the local filesystem. +/// +/// {@macro fm.sbf.default.sourceConsumption} +@immutable +class FileBytesFetcher implements SourceBytesFetcher> { + /// A [SourceBytesFetcher] which fetches from the local filesystem. + const FileBytesFetcher(); + + @override + Future call({ + required Iterable source, + required Future abortSignal, + required BytesToResourceTransformer transformer, + BytesReceivedCallback? bytesLoadedCallback, + }) => + fetchFromSourceIterable( + (uri, transformer, isFirst) => + File(uri).readAsBytes().then(transformer), + source: source, + transformer: transformer, + ); +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/file/file_stub.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/file/file_stub.dart new file mode 100644 index 000000000..6bd5cfd7b --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/file/file_stub.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart'; + +/// A [SourceBytesFetcher] which fetches from the local filesystem. +/// +/// {@macro fm.sbf.default.sourceConsumption} +@immutable +class FileBytesFetcher implements SourceBytesFetcher> { + /// A [SourceBytesFetcher] which fetches from the local filesystem. + const FileBytesFetcher(); + + @override + Future call({ + required Iterable source, + required Future abortSignal, + required BytesToResourceTransformer transformer, + BytesReceivedCallback? bytesLoadedCallback, + }) { + throw UnsupportedError( + '`FileBytesFetcher` is unsupported on non-native platforms', + ); + } +} diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/built_in_caching_provider.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/built_in_caching_provider.dart similarity index 85% rename from lib/src/layer/tile_layer/tile_provider/network/caching/built_in/built_in_caching_provider.dart rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/built_in_caching_provider.dart index e29b6fbf8..19f98c6c5 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/built_in_caching_provider.dart +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/built_in_caching_provider.dart @@ -1,29 +1,33 @@ import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/stub.dart' - if (dart.library.io) 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart' - if (dart.library.js_interop) 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/web/web.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/stub.dart' + if (dart.library.io) 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/source_fetchers/bytes_fetchers/network/caching/built_in/impl/native/native.dart' + if (dart.library.js_interop) 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/source_fetchers/bytes_fetchers/network/caching/built_in/impl/web/web.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart'; import 'package:uuid/data.dart'; import 'package:uuid/rng.dart'; import 'package:uuid/uuid.dart'; -/// Simple built-in map caching using an I/O storage mechanism, for native -/// (non-web) platforms only +/// Simple HTTP-based built-in map caching using an I/O storage mechanism, for +/// native (non-web) platforms only /// /// Stores tiles as files identified with keys, containing some metadata headers /// followed by the tile bytes, alongside a file used to track the size of the /// cache. /// +/// This is enabled by default in flutter_map, when using the +/// [NetworkBytesFetcher]. Consumers must support putting +/// [HttpControlledCachedTileMetadata] to use this provider. +/// /// Usually uses HTTP headers to determine tile freshness, although /// `overrideFreshAge` can override this. /// -/// This is enabled by default in flutter_map, when using the -/// [NetworkTileProvider] (or cancellable version). -/// /// It is safe to use all public methods when running on web - they will noop. /// /// For more information, see the online documentation. abstract interface class BuiltInMapCachingProvider - implements MapCachingProvider { + implements + MapCachingProvider, + PutTileAndMetadataCapability { /// If an instance exists, return it, otherwise create a new instance /// /// The provided configuration will only be respected if an instance does not @@ -148,4 +152,7 @@ abstract interface class BuiltInMapCachingProvider static String uuidTileKeyGenerator(String url) => _uuid.v5(Namespace.url.value, url); static final _uuid = Uuid(goptions: GlobalOptions(MathRNG())); + + @override + Future?> getTile(String url); } diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/README.md b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/README.md similarity index 100% rename from lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/README.md rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/README.md diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/native.dart similarity index 93% rename from lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/native.dart index f073aa3e0..6aceeb21b 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/native.dart @@ -6,7 +6,7 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -83,7 +83,7 @@ class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { sizeMonitorFilePath: sizeMonitorFilePath, maxCacheSize: maxCacheSize, ), - debugName: '[flutter_map: cache] Tile & Size Monitor Writer', + debugName: '[flutter_map: BIC] Tile & Size Monitor Writer', ); workerReceivePort.listen( @@ -111,7 +111,7 @@ class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { late final void Function( String path, - CachedMapTileMetadata metadata, + HttpControlledCachedTileMetadata metadata, Uint8List? tileBytes, ) _writeTileFile; late final void Function() @@ -133,7 +133,9 @@ class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { } @override - Future getTile(String url) async { + Future?> getTile( + String url, + ) async { final key = tileKeyGenerator(url); final tileFile = File( p.join(_cacheDirectoryPath ?? await _cacheDirectoryPathReady.future, key), @@ -210,7 +212,7 @@ class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { } return ( - metadata: CachedMapTileMetadata( + metadata: HttpControlledCachedTileMetadata( staleAt: staleAt, lastModified: lastModified, etag: etag, @@ -230,9 +232,9 @@ class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { } @override - Future putTile({ + Future putTileWithMetadata({ required String url, - required CachedMapTileMetadata metadata, + required HttpControlledCachedTileMetadata metadata, Uint8List? bytes, }) async { if (readOnly) return; @@ -246,7 +248,7 @@ class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { _writeTileFile( path, overrideFreshAge != null - ? CachedMapTileMetadata( + ? HttpControlledCachedTileMetadata( staleAt: DateTime.timestamp().add(overrideFreshAge!), lastModified: metadata.lastModified, etag: metadata.etag, diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/size_reducer.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/workers/size_reducer.dart similarity index 95% rename from lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/size_reducer.dart rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/workers/size_reducer.dart index b4f8e9391..7ef159f80 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/size_reducer.dart +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/workers/size_reducer.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/native.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart similarity index 95% rename from lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart index 7e2a67117..f7a433ac2 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart @@ -4,9 +4,9 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:typed_data'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/size_reducer.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/caching/tile_metadata.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/native.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/native/workers/size_reducer.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; @@ -124,7 +124,7 @@ Future tileAndSizeMonitorWriterWorker( sizeMonitorFilePath: sizeMonitorFilePath, minSizeToDelete: minSizeToDelete, ), - debugName: '[flutter_map: cache] Size Reducer', + debugName: '[flutter_map: BIC] Size Reducer', ); runSizeReducer( @@ -140,10 +140,10 @@ Future tileAndSizeMonitorWriterWorker( final allocInt64BufferTileWrite = Uint8List(8); final allocUint32BufferTileWrite = Uint8List(4); final allocUint16BufferTileWrite = Uint8List(2); - final asciiEncoder = const AsciiEncoder(); + const asciiEncoder = AsciiEncoder(); void writeTile({ required final String path, - required final CachedMapTileMetadata metadata, + required final HttpControlledCachedTileMetadata metadata, Uint8List? tileBytes, }) { final tileFile = File(path); @@ -312,7 +312,7 @@ Future tileAndSizeMonitorWriterWorker( if (val case ( :final String path, - :final CachedMapTileMetadata metadata, + :final HttpControlledCachedTileMetadata metadata, :final Uint8List? tileBytes, )) { writeTile(path: path, metadata: metadata, tileBytes: tileBytes); diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/stub.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/stub.dart similarity index 84% rename from lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/stub.dart rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/stub.dart index ff1e250e9..3208f3cca 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/stub.dart +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/stub.dart @@ -34,12 +34,14 @@ class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { external bool get isSupported; @override - external Future getTile(String url); + external Future?> getTile( + String url, + ); @override - external Future putTile({ + external Future putTileWithMetadata({ required String url, - required CachedMapTileMetadata metadata, + required HttpControlledCachedTileMetadata metadata, Uint8List? bytes, }); } diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/web/web.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/web/web.dart similarity index 91% rename from lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/web/web.dart rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/web/web.dart index 5d2fc3252..33bc501f7 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/web/web.dart +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/impl/web/web.dart @@ -3,7 +3,7 @@ import 'package:meta/meta.dart'; @internal class BuiltInMapCachingProviderImpl - with DisabledMapCachingProvider + with DisabledMapCachingProvider implements BuiltInMapCachingProvider { final String? cacheDirectory; final int? maxCacheSize; diff --git a/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/caching_provider.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/caching_provider.dart new file mode 100644 index 000000000..c4dfbf261 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/caching_provider.dart @@ -0,0 +1,90 @@ +import 'dart:typed_data'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart'; +import 'package:meta/meta.dart'; + +/// Provides tile caching facilities. +/// +/// A cached tile is considered to be at least bytes representing the tile +/// resource, usually paired with metadata about the tile resource. +/// +/// Implementations usually mix-in/implement at least one of [PutTileCapability] +/// and/or [PutTileAndMetadataCapability], to allow tiles to be added to the +/// cache by compatible external consumers. +/// +/// To be supported by the [NetworkBytesFetcher], at least one of the following +/// must be mixed-in/implemented: +/// * [PutTileCapability] +/// * [PutTileAndMetadataCapability] with a metadata type parameter of +/// [HttpControlledCachedTileMetadata] +abstract interface class MapCachingProvider { + /// Whether this caching provider is "currently supported": whether the + /// tile provider should attempt to use it, or fallback to a non-caching + /// alternative. + /// + /// Tile providers must not use any other members if this is `false`. Where + /// possible, other methods should gracefully throw if this is `false`. This + /// should not throw. + /// + /// If this is always `false`, consider mixing in or using + /// [DisabledMapCachingProvider] directly. + bool get isSupported; + + /// Retrieve a tile from the cache, if it exists. + /// + /// Returns `null` if the tile was not present in the cache. + /// + /// If the tile was present, but could not be correctly read (for example, due + /// to an unexpected corruption), this may throw [CachedMapTileReadFailure]. + /// Additionally, any returned tile image `bytes` are not guaranteed to form a + /// valid image - attempting to decode the bytes may also throw. + /// Tile providers should anticipate these exceptions and fallback to a + /// non-caching alternative, wherever possible repairing or replacing the tile + /// with a fresh & valid one. + /// + /// If this method throws an error/exception other than + /// [CachedMapTileReadFailure], consumers should rethrow the error. + /// + /// If the tile is available, the metadata at least gives an indication as to + /// whether the tile is 'stale'. The metadata may also be a more informative + /// subclass, such as [HttpControlledCachedTileMetadata]. + Future getTile(String url); +} + +/// Allows a [MapCachingProvider] to have tiles added externally, without +/// metadata. +abstract interface class PutTileCapability implements MapCachingProvider { + /// Add or update a tile in the cache. + /// + /// [bytes] is required if the tile is not already cached. The behaviour is + /// implementation specific if bytes are not supplied when required. + void putTile({ + required String url, + Uint8List? bytes, + }); +} + +/// Allows a [MapCachingProvider] to have tiles added externally, with +/// metadata. +abstract interface class PutTileAndMetadataCapability + implements MapCachingProvider { + /// Add or update a tile & its metadata in the cache + /// + /// [bytes] is required if the tile is not already cached. The behaviour is + /// implementation specific if bytes are not supplied when required. + void putTileWithMetadata({ + required String url, + required IM metadata, + Uint8List? bytes, + }); +} + +/// A tile's bytes and metadata returned from [MapCachingProvider.getTile] +/// +/// Depending on the caching provider, `metadata` may be a more specific subtype. +@optionalTypeArgs +typedef CachedMapTile = ({ + Uint8List bytes, + OM metadata, +}); diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/disabled/disabled_caching_provider.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/disabled/disabled_caching_provider.dart similarity index 58% rename from lib/src/layer/tile_layer/tile_provider/network/caching/disabled/disabled_caching_provider.dart rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/disabled/disabled_caching_provider.dart index cb947dbe4..6ae24ef71 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/disabled/disabled_caching_provider.dart +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/disabled/disabled_caching_provider.dart @@ -3,7 +3,11 @@ import 'dart:typed_data'; import 'package:flutter_map/flutter_map.dart'; /// Caching provider which disables built-in caching -mixin class DisabledMapCachingProvider implements MapCachingProvider { +mixin class DisabledMapCachingProvider + implements + MapCachingProvider, + PutTileCapability, + PutTileAndMetadataCapability { /// Disable built-in map caching const DisabledMapCachingProvider(); @@ -17,7 +21,14 @@ mixin class DisabledMapCachingProvider implements MapCachingProvider { @override Never putTile({ required String url, - required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) => + throw UnsupportedError('Must not be called if `isSupported` is `false`'); + + @override + Never putTileWithMetadata({ + required String url, + required IM metadata, Uint8List? bytes, }) => throw UnsupportedError('Must not be called if `isSupported` is `false`'); diff --git a/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_metadata.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_metadata.dart new file mode 100644 index 000000000..8ba3ddb20 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_metadata.dart @@ -0,0 +1,179 @@ +import 'dart:io' show HttpHeaders, HttpDate; // web safe! +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:logger/logger.dart'; + +/// Metadata about a tile cached with a [MapCachingProvider]. +/// +/// For output, implementers of a [MapCachingProvider] may: +/// * implement this interface +/// * use or extend [HttpControlledCachedTileMetadata], if the provider makes +/// use of HTTP headers +/// * construct this directly, if the provider does not consider HTTP caching +/// +/// For input, implementers of a [MapCachingProvider] may: +/// * accept no metadata/ignore any provided metadata +/// * accept a subclass implementation if metadata is useful +/// +/// Consumers of a [MapCachingProvider] should: +/// * (preferably) be accepting of many providers, and +/// * expect this interface to be returned as metadata +/// * provide whatever metadata is likely to be useful (such as +/// [HttpControlledCachedTileMetadata] to be compatible with the +/// [BuiltInMapCachingProvider]), and ensure that type safety is preserved +/// * or, tie to a specific provider implementation and specific metadata +/// implementation +@immutable +interface class CachedTileMetadata { + const CachedTileMetadata._({required this.isStale}); + + /// Whether to consider this tile as stale. + /// + /// If `true`, consumers should: + /// * attempt to update the tile + /// * only use the tile as a fallback + /// + /// The meaning & interpretation of `false` depends on the implementation of + /// the consumer and the caching provider. For example, it may indicate that + /// the tile should be used without deferring to a second source (network), or + /// the network may still be attempted anyway - and this may be set on either + /// implementation. + final bool isStale; + + /// Non-specific metadata indicating a stale tile. + /// + /// This method is likely only to be useful for [MapCachingProvider] + /// implementations as an output. + static const stale = CachedTileMetadata._(isStale: true); + + /// Non-specific metadata indicating a non-stale tile. + /// + /// This method is likely only to be useful for [MapCachingProvider] + /// implementations as an output. + static const fresh = CachedTileMetadata._(isStale: false); +} + +/// Implementation of [CachedTileMetadata] which uses properties commonly found +/// in the HTTP Caching specification. +/// +/// [isStale] is determined by whether the tile is 'stale', as determined +/// by [staleAt] (which may be calculated from multiple HTTP headers). +/// +/// [lastModified] & [etag] are common metadata components which caches may +/// choose to support, which makes HTTP Caching more efficient. +@immutable +base class HttpControlledCachedTileMetadata implements CachedTileMetadata { + /// Create new metadata based on properties commonly found in the HTTP Caching + /// specification. + /// + /// If constructing from a HTTP response, consider + /// [HttpControlledCachedTileMetadata.fromHttpHeaders] to automatically + /// calculate and parse these properties. + const HttpControlledCachedTileMetadata({ + required this.staleAt, + this.lastModified, + this.etag, + }); + + /// Create new metadata based off an HTTP response's headers. + /// + /// Where a response does not include enough information to calculate the + /// freshness age, [fallbackFreshnessAge] is used. This will emit a console + /// log in debug mode if [warnOnFallbackUsage] is is set. + /// + /// This may throw if the required headers were in an unexpected format. + factory HttpControlledCachedTileMetadata.fromHttpHeaders( + Map headers, { + Uri? warnOnFallbackUsage, + Duration fallbackFreshnessAge = const Duration(days: 7), + }) { + void warnFallbackUsage() { + if (kDebugMode && warnOnFallbackUsage != null) { + Logger(printer: SimplePrinter()).w( + '[flutter_map] Using fallback freshness age ($fallbackFreshnessAge) ' + 'for ${warnOnFallbackUsage.path}\n\tThis indicates the tile server ' + 'did not send enough information to calculate a freshness age. ' + "Optionally override in the caching provider's config.", + ); + } + } + + // There is no guarantee that this meets the HTTP specification - however, + // it was designed with it in mind + DateTime calculateStaleAt() { + final addToNow = DateTime.timestamp().add; + + if (headers[HttpHeaders.cacheControlHeader]?.toLowerCase() + case final cacheControl?) { + final maxAge = RegExp(r'max-age=(\d+)').firstMatch(cacheControl)?[1]; + + if (maxAge == null) { + if (headers[HttpHeaders.expiresHeader]?.toLowerCase() + case final expires?) { + return HttpDate.parse(expires); + } + + warnFallbackUsage(); + return addToNow(fallbackFreshnessAge); + } + + if (headers[HttpHeaders.ageHeader] case final currentAge?) { + return addToNow( + Duration(seconds: int.parse(maxAge) - int.parse(currentAge)), + ); + } + + final estimatedAge = max( + 0, + DateTime.timestamp() + .difference(HttpDate.parse(headers[HttpHeaders.dateHeader]!)) + .inSeconds, + ); + return addToNow(Duration(seconds: int.parse(maxAge) - estimatedAge)); + } + + warnFallbackUsage(); + return addToNow(fallbackFreshnessAge); + } + + final lastModified = headers[HttpHeaders.lastModifiedHeader]; + final etag = headers[HttpHeaders.etagHeader]; + + return HttpControlledCachedTileMetadata( + staleAt: calculateStaleAt(), + lastModified: lastModified != null ? HttpDate.parse(lastModified) : null, + etag: etag, + ); + } + + /// The calculated time at which this tile becomes stale (UTC) + /// + /// Consumers should refer to [isStale] to determine whether the tile is + /// stale. + /// + /// This may have been calculated based off an HTTP response's headers using + /// [HttpControlledCachedTileMetadata.fromHttpHeaders], or it may be custom. + final DateTime staleAt; + + /// If available, the value in [HttpHeaders.lastModifiedHeader] (UTC) + final DateTime? lastModified; + + /// If available, the value in [HttpHeaders.etagHeader] + final String? etag; + + @override + bool get isStale => DateTime.timestamp().isAfter(staleAt); + + @override + int get hashCode => Object.hash(staleAt, lastModified, etag); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HttpControlledCachedTileMetadata && + staleAt == other.staleAt && + lastModified == other.lastModified && + etag == other.etag); +} diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/tile_read_failure_exception.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_read_failure_exception.dart similarity index 100% rename from lib/src/layer/tile_layer/tile_provider/network/caching/tile_read_failure_exception.dart rename to lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_read_failure_exception.dart diff --git a/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/consolidate_response.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/consolidate_response.dart new file mode 100644 index 000000000..eacf725b4 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/consolidate_response.dart @@ -0,0 +1,83 @@ +// Adapted from Flutter (c 2014 BSD The Flutter Authors) method to work without +// `dart:io` using a `StreamedResponse` + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; + +/// Efficiently converts the response body of an [Response] into a +/// [Uint8List]. +/// +/// Assumes response has been uncompressed automatically. +/// +/// See [consolidateHttpClientResponseBytes] for more info. +@internal +Future consolidateStreamedResponseBytes( + StreamedResponse response, { + BytesReceivedCallback? onBytesReceived, +}) { + final completer = Completer.sync(); + final output = _OutputBuffer(); + + int? expectedContentLength = response.contentLength; + if (expectedContentLength == -1) expectedContentLength = null; + + int bytesReceived = 0; + late final StreamSubscription> subscription; + subscription = response.stream.listen( + (chunk) { + output.add(chunk); + if (onBytesReceived != null) { + bytesReceived += chunk.length; + try { + onBytesReceived(bytesReceived, expectedContentLength); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + subscription.cancel(); + return; + } + } + }, + onDone: () { + output.close(); + completer.complete(output.bytes); + }, + onError: completer.completeError, + cancelOnError: true, + ); + + return completer.future; +} + +class _OutputBuffer extends ByteConversionSinkBase { + List>? _chunks = >[]; + int _contentLength = 0; + Uint8List? _bytes; + + @override + void add(List chunk) { + assert(_bytes == null, '`_bytes` must be `null`'); + _chunks!.add(chunk); + _contentLength += chunk.length; + } + + @override + void close() { + if (_bytes != null) { + // We've already been closed; this is a no-op + return; + } + _bytes = Uint8List(_contentLength); + int offset = 0; + for (final List chunk in _chunks!) { + _bytes!.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + _chunks = null; + } + + Uint8List get bytes => _bytes!; +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart new file mode 100644 index 000000000..d11708e7b --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart @@ -0,0 +1,396 @@ +import 'dart:async'; +import 'dart:io' show HttpHeaders, HttpDate, HttpStatus; // web safe! + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/built_in/built_in_caching_provider.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/caching_provider.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/disabled/disabled_caching_provider.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_metadata.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/caching/tile_read_failure_exception.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/consolidate_response.dart'; +import 'package:http/http.dart'; +import 'package:http/retry.dart'; +import 'package:logger/logger.dart'; + +/// A [SourceBytesFetcher] which fetches a URI from the network using HTTP. +/// +/// {@template fm.sbf.default.sourceConsumption} +/// Consumes an [Iterable] of [String] URIs, which must not be empty and +/// iterates in an order. If the first URI cannot be used to fetch bytes, the +/// next URI is used as a fallback if available, and so on. +/// {@endtemplate} +/// +/// Supports caching, delegating to a [MapCachingProvider]. +/// * If a non-stale tile is available, it is used without using the network +/// * If a stale tile is available, it is updated if possible, otherwise the +/// behaviour depends on [fallbackToStaleCachedTiles] +@immutable +class NetworkBytesFetcher implements SourceBytesFetcher> { + /// HTTP headers to send with each request. + final Map headers; + + /// HTTP client used to make each request. + /// + /// It is much more efficient if a single client is used repeatedly, as it + /// can maintain an open socket connection to the server. + /// + /// Where possible, clients should support aborting of requests when the + /// response is no longer required. + final Client httpClient; + + /// Provider used to perform long-term tile caching. + /// + /// See online documentation for more information about built-in caching. + /// + /// If a cached tile is available and not stale, it will be used without + /// attempting the network. + /// + /// Defaults to [BuiltInMapCachingProvider]. Set to + /// [DisabledMapCachingProvider] to disable. + final MapCachingProvider? cachingProvider; + + /// Whether to use a potentially stale cached tile if it could not be + /// retrieved from the network. + /// + /// Only applicable if [cachingProvider] is in use. + /// + /// Defaults to `true`. + final bool fallbackToStaleCachedTiles; + + /// Whether to optimistically attempt to decode HTTP responses that have a + /// non-successful status code as an image. + /// + /// Some servers return useful information embedded in an image returned in + /// the HTTP body of a non-successful response, such as an instruction to use + /// an API key. This can make it easier to debug issues. + /// + /// Defaults to `true` in debug mode, `false` otherwise. + final bool attemptDecodeOfHttpErrorResponses; + + /// Whether to abort HTTP requests for tiles that will no longer be displayed. + /// + /// For example, tiles may be pruned from an intermediate zoom level during a + /// user's fast zoom. When disabled, the request for each tile that has been + /// pruned still needs to complete and be processed. When enabled, those + /// tiles' requests can be aborted before they are fully loaded. + /// + /// > [!TIP] + /// > This functionality replaces the 'flutter_map_cancellable_tile_provider' + /// > plugin package. + /// + /// This may have multiple advantages: + /// * It may improve tile loading speeds + /// * It may reduce the user's consumption of a metered network connection + /// * It may reduce the user's consumption of storage capacity in the + /// [cachingProvider] + /// * It may reduce unnecessary tile requests, reducing tile server costs + /// * It may negligibly improve app performance in general + /// + /// This is likely to be more effective on web platforms (where + /// `BrowserClient` is used) and with clients or servers with limited numbers + /// of simultaneous connections or slow traffic speeds, but is also likely to + /// have a positive effect everywhere. If an HTTP client is used which does + /// not support the standard method of request aborting, this has no effect. + /// + /// Defaults to `true`. It is recommended to enable this functionality, unless + /// you suspect it is causing problems; in this case, please report the issue + /// to flutter_map. + final bool abortObsoleteRequests; + + /// A [SourceBytesFetcher] which fetches from the network using HTTP. + /// + /// The string "flutter_map ([uaIdentifier])" is set as the 'User-Agent' HTTP + /// header on non-web platforms, if the UA header is not specified manually. + /// If not provided, the string "flutter_map (unknown)" is used. + /// [uaIdentifier] should uniquely identify your app or project - for example, + /// 'com.example.app'. + /// + /// > [!TIP] + /// > Setting a [uaIdentifier] (or a custom UA header) is strongly recommended + /// > for all projects. It helps the server differentiate your traffic from + /// > other flutter_map traffic. + /// > + /// > A useful UA header is required by the terms of service of many tile + /// > servers. flutter_map places some restrictions on projects if a UA header + /// > is left unset. + NetworkBytesFetcher({ + String? uaIdentifier, + Map? headers, + Client? httpClient, + this.cachingProvider, + this.fallbackToStaleCachedTiles = true, + this.attemptDecodeOfHttpErrorResponses = kDebugMode, + this.abortObsoleteRequests = true, + }) : headers = headers ?? {}, + httpClient = httpClient ?? RetryClient(Client()) { + if (!kIsWeb) { + this.headers.putIfAbsent( + HttpHeaders.userAgentHeader, + () => 'flutter_map ($uaIdentifier)', + ); + } + } + + @override + Future call({ + required Iterable source, + required Future abortSignal, + required BytesToResourceTransformer transformer, + BytesReceivedCallback? bytesLoadedCallback, + }) => + fetchFromSourceIterable( + (uri, transformer, isFirst) => fetchSingle( + uri: uri, + abortSignal: abortSignal, + transformer: transformer, + bytesLoadedCallback: bytesLoadedCallback, + ), + source: source, + transformer: transformer, + ); + + /// Fetch a single URI's resource + /// + /// This is used internally but exposed for convenience. + /// + /// This throws when an error is encountered attempting to access the + /// resource. + Future fetchSingle({ + required String uri, + required Future abortSignal, + required BytesToResourceTransformer transformer, + BytesReceivedCallback? bytesLoadedCallback, + }) async { + final parsedUri = Uri.parse(uri); + + // Create method to get bytes from server + Future<({Uint8List bytes, StreamedResponse response})> get({ + Map? additionalHeaders, + }) async { + final request = AbortableRequest( + 'GET', + parsedUri, + abortTrigger: abortObsoleteRequests ? abortSignal : null, + ); + + request.headers.addAll(headers); + if (additionalHeaders != null) request.headers.addAll(additionalHeaders); + + final response = await httpClient.send(request); + + final bytes = await consolidateStreamedResponseBytes( + response, + onBytesReceived: bytesLoadedCallback, + ); + + return (bytes: bytes, response: response); + } + + // Prepare caching provider & load cached tile if available + CachedMapTile? cachedTile; + final cachingProvider = + this.cachingProvider ?? BuiltInMapCachingProvider.getOrCreateInstance(); + if (cachingProvider.isSupported) { + try { + cachedTile = await cachingProvider.getTile(uri); + } on CachedMapTileReadFailure { + // This could occur due to a corrupt tile - we just try to overwrite it + // with fresh data + cachedTile = null; + } + // If any other error is thrown, fetching is stopped & rethrown + } + + // Create method to write response to cache when applicable + // Even when fetching a fallback, we can still use the long-term cache, as + // it safely associates it with the resolved URI. This is not possible for + // the short-term cache, as it would require the I/O work to occur before + // the short-term cache key could be resolved. + void cachePut({ + required Uint8List? bytes, + required Map headers, + }) { + if (!cachingProvider.isSupported) return; + + if (cachingProvider + case final PutTileAndMetadataCapability< + HttpControlledCachedTileMetadata> cachingProvider) { + late final HttpControlledCachedTileMetadata metadata; + try { + metadata = HttpControlledCachedTileMetadata.fromHttpHeaders( + headers, + warnOnFallbackUsage: parsedUri, + ); + } on Exception catch (e) { + if (kDebugMode) { + Logger(printer: SimplePrinter()).w( + '[flutter_map] Failed to cache ${parsedUri.path}: $e\n\tThis ' + 'may indicate a HTTP spec non-conformance issue with the tile ' + 'server. ', + ); + } + return; + } + + cachingProvider.putTileWithMetadata( + url: uri, + metadata: metadata, + bytes: bytes, + ); + } else if (cachingProvider case final PutTileCapability cachingProvider) { + cachingProvider.putTile(url: uri, bytes: bytes); + } else if (kDebugMode) { + Logger(printer: SimplePrinter()).w( + '[flutter_map] Caching provider incompatible with ' + '`NetworkBytesFetcher` for put operations', + ); + } + } + + // Create the exception exit method + // In the event that a tile cannot be fetched from the network, and a + // (stale) cached tile is available, and the behaviour is allowed, attempt + // to use the cached resource. This method is used on exit when a + // non-abortion exception occurs. Otherwise, it rethrows the original + // exception to the caller, which may attempt fallbacks. + Future fallbackToCachedTile(Object err, StackTrace stackTrace) async { + if (cachedTile == null || !fallbackToStaleCachedTiles) { + Error.throwWithStackTrace(err, stackTrace); + } + try { + final cachedResource = + await transformer(cachedTile.bytes, allowReuse: false); + if (kDebugMode) { + Logger(printer: SimplePrinter()).w( + '[flutter_map] Failed to fetch ${parsedUri.path} from network; ' + 'using (stale) cached tile', + ); + } + return cachedResource; + } on Exception { + Error.throwWithStackTrace(err, stackTrace); + } + } + + // Main logic + // All `transformer` calls should be awaited so errors may be handled + try { + bool forceFromServer = false; + if (cachedTile != null && !cachedTile.metadata.isStale) { + try { + // If we have a cached tile that's not stale, return it + return await transformer(cachedTile.bytes); + } on Exception { + // If the cached tile is corrupt, we proceed and get from the server + forceFromServer = true; + } + } + + // Otherwise, ask the server what's going on - supply any details we have + var (:bytes, :response) = await get( + additionalHeaders: forceFromServer + ? null + : { + if (cachedTile?.metadata + case HttpControlledCachedTileMetadata(:final lastModified?)) + HttpHeaders.ifModifiedSinceHeader: + HttpDate.format(lastModified), + if (cachedTile?.metadata + case HttpControlledCachedTileMetadata(:final etag?)) + HttpHeaders.ifNoneMatchHeader: etag, + }, + ); + + // Server says nothing's changed - but might return new useful headers + if (!forceFromServer && + cachedTile != null && + response.statusCode == HttpStatus.notModified) { + late final R transformedCacheBytes; + try { + transformedCacheBytes = await transformer(cachedTile.bytes); + } on Exception { + // If the cached tile is corrupt, we get fresh from the server without + // caching, then continue + forceFromServer = true; + (:bytes, :response) = await get(); + } + if (!forceFromServer) { + cachePut(bytes: null, headers: response.headers); + return transformedCacheBytes; + } + } + + // Server says the image has changed - store it new + if (response.statusCode == HttpStatus.ok) { + cachePut(bytes: bytes, headers: response.headers); + return await transformer(bytes); + } + + // It's likely an error at this point + // However, some servers may produce error responses with useful bodies, + // perhaps intentionally (such as an "API Key Required" message) + // Therefore, if there is a body, and the user allows it, we attempt to + // decode the body bytes as an image (although we don't cache if + // successful) + // Otherwise, we just throw early + if (!attemptDecodeOfHttpErrorResponses || bytes.isEmpty) { + throw NetworkImageLoadException( + statusCode: response.statusCode, + uri: parsedUri, + ); + } + + try { + return await transformer(bytes, allowReuse: false); + } on Exception catch (_, stackTrace) { + // If it throws, we don't want to throw the decode error, as that's not + // useful for users + // Instead, we throw an exception reporting the failed HTTP request, + // which is caught by the non-specific catch block below to initiate the + // retry/silence mechanisms if applicable + // We do retain the stack trace, so that it might be clear we attempted + // to decode it + // We piggyback off of an error meant for `NetworkImage` - it's the same + // as we need + Error.throwWithStackTrace( + NetworkImageLoadException( + statusCode: response.statusCode, + uri: parsedUri, + ), + stackTrace, + ); + } + } on RequestAbortedException catch (_, stackTrace) { + // This is a planned exception, we convert the error + + Error.throwWithStackTrace( + TileAbortedException(source: parsedUri), + stackTrace, + ); + } on ClientException catch (err, stackTrace) { + // This could be a wide range of issues, potentially ours, potentially + // network, etc. + + // Try to detect errors thrown from requests being aborted due to the + // client being closed + // This can occur when the map/tile layer is disposed early - in older + // versions, we used manual tracking to avoid disposing too early, but now + // we just attempt to catch (it's cleaner & easier) + if (err.message.contains('closed') || err.message.contains('cancel')) { + Error.throwWithStackTrace( + TileAbortedException(source: parsedUri), + stackTrace, + ); + } + + return await fallbackToCachedTile(err, stackTrace); + } on Exception catch (err, stackTrace) { + // We may also get exceptions otherwise, for example from failing to + // transform/decode bytes or `NetworkImageLoadException` + + return await fallbackToCachedTile(err, stackTrace); + } + } +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/raster/image_provider.dart b/lib/src/layer/modern_tile_layer/tile_loader/raster/image_provider.dart new file mode 100644 index 000000000..6687f65d9 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/raster/image_provider.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Similar to [MemoryImage], but requires a [key] to identify and cache the +/// image, and supports lazily getting the image bytes with chunk support. +class KeyedDelegatedImage extends ImageProvider { + /// Similar to [MemoryImage], but requires a [key] to identify and cache the + /// image, and supports lazily getting the image bytes with chunk support. + const KeyedDelegatedImage({ + required this.key, + required this.delegate, + this.scale = 1.0, + }); + + /// Identifier for this image. + /// + /// This is used (alongside [scale]) to identify this image in the image + /// cache. Therefore, two requirements must be met: + /// + /// * The same key must not be used for two different images + /// * The same image should always use the same key + final Object key; + + /// Callback which returns the codec to use as an image. + /// + /// Using the provided `chunkEvents` stream is optional, but may be used to + /// report image loading progress. + /// + /// The `decode` callback provides the logic to obtain the codec for the + /// image. It works on image bytes encoded in any of the following supported + /// image formats: + /// {@macro dart.ui.imageFormats} + /// + /// See also: + /// + /// * [PaintingBinding.instantiateImageCodecWithSize] + final Future Function( + KeyedDelegatedImage key, { + required StreamSink chunkEvents, + required ImageDecoderCallback decode, + }) delegate; + + /// The scale to place in the [ImageInfo] object of the image. + /// + /// See also: + /// + /// * [ImageInfo.scale], which gives more information on how this scale is + /// applied. + final double scale; + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + KeyedDelegatedImage key, + ImageDecoderCallback decode, + ) { + final chunkEvents = StreamController(); + + return MultiFrameImageStreamCompleter( + codec: delegate(key, chunkEvents: chunkEvents.sink, decode: decode) + ..whenComplete(chunkEvents.close), + chunkEvents: chunkEvents.stream, + scale: key.scale, + debugLabel: 'KeyedDelegatedImage($key)', + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is KeyedDelegatedImage && + other.key == key && + other.scale == scale); + + @override + int get hashCode => Object.hash(key, scale); + + @override + String toString() => + '${objectRuntimeType(this, 'KeyedDelegatedImage')}(key: $key, scale: ${scale.toStringAsFixed(1)})'; +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/raster/tile_data.dart b/lib/src/layer/modern_tile_layer/tile_loader/raster/tile_data.dart new file mode 100644 index 000000000..a1bd91a85 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/raster/tile_data.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/base_tile_data.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/raster/tile_loader.dart'; +import 'package:meta/meta.dart'; + +/// Raster tile data associated with a particular tile, used for communication +/// between the [RasterTileLoader] and the raster tile renderer. +/// +/// It is not usually necessary to consume this externally. +class RasterTileData implements BaseTileData { + /// Actual raster [ImageProvider] + final ImageProvider image; + + final void Function() _dispose; + + /// Raster tile data associated with a particular tile. + RasterTileData({required this.image, required void Function() dispose}) + : _dispose = dispose; + + bool _isDisposed = false; + @internal + @override + void dispose() { + _dispose(); + _isDisposed = true; + } + + DateTime? loadStartedTime; + + final _loadedTracker = Completer(); + @override + Future get whenLoaded => _loadedTracker.future; + + @override + bool get isLoaded => loaded != null; + ({ + DateTime time, + ImageInfo? successfulImageInfo, + ({Object exception, StackTrace? stackTrace})? failureInfo, + })? loaded; + + ImageStream? _imageStream; + late ImageStreamListener _imageStreamListener; + + void load() { + // TODO: Consider whether `load` can be called multiple times + if (_isDisposed) return; + + loadStartedTime = DateTime.now(); + + try { + final oldImageStream = _imageStream; + _imageStream = image.resolve(ImageConfiguration.empty); + + if (_imageStream!.key != oldImageStream?.key) { + oldImageStream?.removeListener(_imageStreamListener); + + _imageStreamListener = ImageStreamListener( + _onImageLoadSuccess, + onError: _onImageLoadError, + ); + _imageStream!.addListener(_imageStreamListener); + } + } catch (e, s) { + // Make sure all exceptions are handled - #444 / #536 + _onImageLoadError(e, s); + } + } + + void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) { + if (_isDisposed) return; + + final isPreviouslyLoaded = loaded != null; + + loaded = ( + time: DateTime.now(), + successfulImageInfo: imageInfo, + failureInfo: null + ); + _loadedTracker.complete(); + + _display(isPreviouslyLoaded); + } + + void _onImageLoadError(Object exception, StackTrace? stackTrace) { + if (_isDisposed) return; + + final isPreviouslyLoaded = loaded != null; + + loaded = ( + time: DateTime.now(), + successfulImageInfo: null, + failureInfo: (exception: exception, stackTrace: stackTrace), + ); + _loadedTracker.completeError(exception, stackTrace); + + // TODO: Was `if (errorImage != null) _display();`? + _display(isPreviouslyLoaded); + } + + void _display(bool isPreviouslyLoaded) { + /*if (loadError) { + assert( + errorImage != null, + 'A TileImage should not be displayed if loading errors and there is no ' + 'errorImage to show.', + ); + _readyToDisplay = true; + if (!_disposed) notifyListeners(); + return; + }*/ + + /*_tileDisplay.when( + instantaneous: (_) { + _readyToDisplay = true; + if (!_disposed) notifyListeners(); + }, + fadeIn: (fadeIn) { + final fadeStartOpacity = + previouslyLoaded ? fadeIn.reloadStartOpacity : fadeIn.startOpacity; + + if (fadeStartOpacity == 1.0) { + _readyToDisplay = true; + if (!_disposed) notifyListeners(); + } else { + _animationController!.reset(); + _animationController!.forward(from: fadeStartOpacity).then((_) { + _readyToDisplay = true; + if (!_disposed) notifyListeners(); + }); + } + }, + );*/ + } +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/raster/tile_loader.dart b/lib/src/layer/modern_tile_layer/tile_loader/raster/tile_loader.dart new file mode 100644 index 000000000..5774fe3bc --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/raster/tile_loader.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/options.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/source_generator.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/wms.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/xyz.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/raster/image_provider.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/raster/tile_data.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/tile_loader.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/tile_source.dart'; +import 'package:meta/meta.dart'; + +/// A tile loader implementation which: +/// +/// 1. delegates to a [SourceGenerator] to output the tile's 'source' ([S]) +/// 2. fetches the 'source's bytes by delegating to a [SourceBytesFetcher] +/// 3. outputs a [RasterTileData], containing the [ImageProvider] created by +/// decoding the tile's image bytes +/// +/// The source ([S]) is used as the short-term caching key for the +/// [ImageProvider] (in Flutter's [ImageCache]) - therefore, it must meet the +/// necessary conditions as described by [ImageProvider.obtainKey] +/// (particularly, it must be an object with a useful equality defined). The +/// [TileSource] generated by the [XYZSourceGenerator] & [WMSSourceGenerator] +/// meets this requirement, although it is not a requirement to use this. +@immutable +final class RasterTileLoader + implements TileLoader { + /// Generates a 'source' ([S]) for a tile, given its [TileCoordinates] & the + /// ambient [TileLayerOptions] + /// + /// For example, see [XYZSourceGenerator]. + final SourceGenerator sourceGenerator; + + /// The delegate which provides the bytes for the this tile, based on its + /// 'source' ([S]). + /// + /// This may not be called for every tile, if the tile was already present in + /// the ambient [ImageCache]. + /// + /// For example, see [NetworkBytesFetcher]. + final SourceBytesFetcher bytesFetcher; + + /// Tile loader which loads raster image tiles + const RasterTileLoader({ + required this.sourceGenerator, + required this.bytesFetcher, + }); + + @override + RasterTileData call(TileCoordinates coordinates, TileLayerOptions options) { + final source = sourceGenerator(coordinates, options); + + final abortTrigger = Completer(); + + Future imageDelegate( + KeyedDelegatedImage key, { + required StreamSink chunkEvents, + required ImageDecoderCallback decode, + }) async { + void evict() => scheduleMicrotask( + () => PaintingBinding.instance.imageCache.evict(key), + ); + + Future transformer(Uint8List bytes, {bool allowReuse = true}) { + if (!allowReuse) evict(); + return ImmutableBuffer.fromUint8List(bytes).then(decode); + } + + try { + return await bytesFetcher( + source: source, + abortSignal: abortTrigger.future, + transformer: transformer, + bytesLoadedCallback: (c, t) => chunkEvents.add( + ImageChunkEvent(cumulativeBytesLoaded: c, expectedTotalBytes: t), + ), + ); + } on TileAbortedException { + evict(); + return ImmutableBuffer.fromUint8List(transparentImage).then(decode); + } on Exception { + evict(); + rethrow; + } + } + + return RasterTileData( + image: KeyedDelegatedImage( + key: source, + delegate: imageDelegate, + ), + dispose: abortTrigger.complete, + )..load(); + } + + /// [Uint8List] that forms a fully transparent image. + static final transparentImage = Uint8List.fromList([ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + 0x42, + 0x60, + 0x82, + ]); +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/tile_loader.dart b/lib/src/layer/modern_tile_layer/tile_loader/tile_loader.dart new file mode 100644 index 000000000..cc507e534 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/tile_loader.dart @@ -0,0 +1,13 @@ +import 'package:flutter_map/src/layer/modern_tile_layer/base_tile_data.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/options.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:meta/meta.dart'; + +/// Responsible for generating a tile's data ([D]), given the [TileCoordinates] +/// and ambient [TileLayerOptions]. +@immutable +abstract interface class TileLoader { + /// Generate data ([D]) for the tile at [coordinates], with the ambient layer + /// [options]. + D call(TileCoordinates coordinates, TileLayerOptions options); +} diff --git a/lib/src/layer/modern_tile_layer/tile_loader/tile_source.dart b/lib/src/layer/modern_tile_layer/tile_loader/tile_source.dart new file mode 100644 index 000000000..6a5ce3001 --- /dev/null +++ b/lib/src/layer/modern_tile_layer/tile_loader/tile_source.dart @@ -0,0 +1,84 @@ +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/source_generator.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/source_generators/xyz.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/bytes_fetcher.dart'; +import 'package:flutter_map/src/layer/modern_tile_layer/tile_loader/bytes_fetchers/network/fetcher/network.dart'; +import 'package:meta/meta.dart'; + +/// Data class for communicating URIs returned by some [SourceGenerator] +/// implementations (such as [XYZSourceGenerator]). +/// +/// Carries a [primaryUri] and potentially multiple ordered [fallbackUris]. +/// When iterated, this will first yield the [primaryUri], followed by any +/// [fallbackUris] in order. +/// +/// This is suitable to be used directly as a short-term cache key*. This may be +/// consumed directly by some [SourceBytesFetcher] implementations (such as +/// [NetworkBytesFetcher]). +/// +/// > [!WARNING] +/// > The equality of these objects depends only on [primaryUri]. +/// > Therefore, where used as a short-term cache key, resources at +/// > [fallbackUris] **must not** automatically be re-used/cached under the +/// > [primaryUri]. +@immutable +class TileSource extends Iterable { + /// Primary URI of the tile. + final String primaryUri; + + /// Lazily generated URIs of the tile which may be used in the event that the + /// [primaryUri] cannot be used to retrieve the tile. + /// + /// This is not included in the equality of this object. See the documentation + /// on this class for more info. + /// + /// This may be empty or not provided. + final Iterable? fallbackUris; + + /// Construct a data class for communicating URIs returned by some + /// [SourceGenerator] implementations. + const TileSource(this.primaryUri, {this.fallbackUris}); + + @override + Iterator get iterator => + _TileSourceIterator(primaryUri, fallbackUris?.iterator); + + @override + int get hashCode => primaryUri.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TileSource && primaryUri == other.primaryUri); +} + +class _TileSourceIterator implements Iterator { + final String _primaryUri; + final Iterator? _fallbackUris; + + _TileSourceIterator(this._primaryUri, this._fallbackUris); + + String? _current; + bool _finished = false; + + @override + bool moveNext() { + if (_finished) return false; + + if (_current == null) { + _current = _primaryUri; + return true; + } + + if (_fallbackUris == null || !_fallbackUris.moveNext()) { + _current = null; + _finished = true; + return false; + } + + _current = _fallbackUris.current; + return true; + } + + @override + String get current => _current!; +} diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/caching_provider.dart b/lib/src/layer/tile_layer/tile_provider/network/caching/caching_provider.dart deleted file mode 100644 index 1335b2a88..000000000 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/caching_provider.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_map/flutter_map.dart'; - -/// Provides tile caching facilities to [TileProvider]s -/// -/// Some caching plugins may choose instead to provide a dedicated -/// [TileProvider], in which case the flutter_map-provided caching facilities -/// are irrelevant. -/// -/// The [CachedMapTileMetadata] object is used to store metadata alongside -/// cached tiles. Its intended purpose is primarily for caching based on HTTP -/// headers - however, this is not a requirement. -abstract interface class MapCachingProvider { - /// Whether this caching provider is "currently supported": whether the - /// tile provider should attempt to use it, or fallback to a non-caching - /// alternative - /// - /// Tile providers must not call [getTile] or [putTile] if this is `false`. - /// [getTile] and [putTile] should gracefully throw if this is `false`. - /// This should not throw. - /// - /// If this is always `false`, consider mixing in or using - /// [DisabledMapCachingProvider] directly. - bool get isSupported; - - /// Retrieve a tile from the cache, if it exists - /// - /// Returns `null` if the tile was not present in the cache. - /// - /// If the tile was present, but could not be correctly read (for example, due - /// to an unexpected corruption), this may throw [CachedMapTileReadFailure]. - /// Additionally, any returned tile image `bytes` are not guaranteed to form a - /// valid image - attempting to decode the bytes may also throw. - /// Tile providers should anticipate these exceptions and fallback to a - /// non-caching alternative, wherever possible repairing or replacing the tile - /// with a fresh & valid one. - Future getTile(String url); - - /// Add or update a tile in the cache - /// - /// [bytes] is required if the tile is not already cached. The behaviour is - /// implementation specific if bytes are not supplied when required. - Future putTile({ - required String url, - required CachedMapTileMetadata metadata, - Uint8List? bytes, - }); -} - -/// A tile's bytes and metadata returned from [MapCachingProvider.getTile] -typedef CachedMapTile = ({Uint8List bytes, CachedMapTileMetadata metadata}); diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/tile_metadata.dart b/lib/src/layer/tile_layer/tile_provider/network/caching/tile_metadata.dart deleted file mode 100644 index 937577914..000000000 --- a/lib/src/layer/tile_layer/tile_provider/network/caching/tile_metadata.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'dart:io' show HttpHeaders, HttpDate; // web safe! -import 'dart:math'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:logger/logger.dart'; - -/// Metadata about a tile cached with a [MapCachingProvider] -/// -/// Caching is usually determined with HTTP headers. However, if a specific -/// implementation chooses to, it can solely use [isStale] and set the other -/// properties to `null`. -/// -/// External usage of this class is not usually necessary. It is visible so -/// other tile providers may make use of it. -@immutable -class CachedMapTileMetadata { - /// Create new metadata - const CachedMapTileMetadata({ - required this.staleAt, - required this.lastModified, - required this.etag, - }); - - /// Create new metadata based off an HTTP response's headers - /// - /// Where a response does not include enough information to calculate the - /// freshness age, [fallbackFreshnessAge] is used. This will emit a console - /// log in debug mode if [warnOnFallbackUsage] is is set. - /// - /// This may throw if the required headers were in an unexpected format. - factory CachedMapTileMetadata.fromHttpHeaders( - Map headers, { - Uri? warnOnFallbackUsage, - Duration fallbackFreshnessAge = const Duration(days: 7), - }) { - void warnFallbackUsage() { - if (kDebugMode && warnOnFallbackUsage != null) { - Logger(printer: SimplePrinter()).w( - '[flutter_map cache] Using fallback freshness age ' - '($fallbackFreshnessAge) for ${warnOnFallbackUsage.path}\n' - '\tThis indicates the tile server did not send enough ' - 'information to calculate a freshness age. Optionally override ' - "in the caching provider's config.", - ); - } - } - - // There is no guarantee that this meets the HTTP specification - however, - // it was designed with it in mind - DateTime calculateStaleAt() { - final addToNow = DateTime.timestamp().add; - - if (headers[HttpHeaders.cacheControlHeader]?.toLowerCase() - case final cacheControl?) { - final maxAge = RegExp(r'max-age=(\d+)').firstMatch(cacheControl)?[1]; - - if (maxAge == null) { - if (headers[HttpHeaders.expiresHeader]?.toLowerCase() - case final expires?) { - return HttpDate.parse(expires); - } - - warnFallbackUsage(); - return addToNow(fallbackFreshnessAge); - } - - if (headers[HttpHeaders.ageHeader] case final currentAge?) { - return addToNow( - Duration(seconds: int.parse(maxAge) - int.parse(currentAge)), - ); - } - - final estimatedAge = max( - 0, - DateTime.timestamp() - .difference(HttpDate.parse(headers[HttpHeaders.dateHeader]!)) - .inSeconds, - ); - return addToNow(Duration(seconds: int.parse(maxAge) - estimatedAge)); - } - - warnFallbackUsage(); - return addToNow(fallbackFreshnessAge); - } - - final lastModified = headers[HttpHeaders.lastModifiedHeader]; - final etag = headers[HttpHeaders.etagHeader]; - - return CachedMapTileMetadata( - staleAt: calculateStaleAt(), - lastModified: lastModified != null ? HttpDate.parse(lastModified) : null, - etag: etag, - ); - } - - /// The calculated time at which this tile becomes stale (UTC) - /// - /// Tile providers should use [isStale] to check whether a tile is stale, - /// instead of manually comparing this to the current timestamp. - /// - /// This may have been calculated based off an HTTP response's headers using - /// [CachedMapTileMetadata.fromHttpHeaders], or it may be custom. - final DateTime staleAt; - - /// If available, the value in [HttpHeaders.lastModifiedHeader] (UTC) - final DateTime? lastModified; - - /// If available, the value in [HttpHeaders.etagHeader] - final String? etag; - - /// Whether this tile should be considered stale - /// - /// Usually this is implemented by storing the timestamp at which the tile - /// becomes stale, and comparing that to the current timestamp. - bool get isStale => DateTime.timestamp().isAfter(staleAt); - - @override - int get hashCode => Object.hash(staleAt, lastModified, etag); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is CachedMapTileMetadata && - staleAt == other.staleAt && - lastModified == other.lastModified && - etag == other.etag); -} diff --git a/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart b/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart index 4e7c931d8..3bfd20a65 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart @@ -10,6 +10,9 @@ import 'package:http/http.dart'; import 'package:logger/logger.dart'; import 'package:meta/meta.dart'; +// TODO: This does not match the modern implementation in fetcher/network.dart, +// and may be broken. + /// Dedicated [ImageProvider] to fetch tiles from the network /// /// Supports falling back to a secondary URL, if the primary URL fetch fails. @@ -179,28 +182,64 @@ class NetworkTileImageProvider extends ImageProvider { }) { if (useFallback || !cachingProvider.isSupported) return; - late final CachedMapTileMetadata metadata; - try { - metadata = CachedMapTileMetadata.fromHttpHeaders( - headers, - warnOnFallbackUsage: silenceExceptions ? null : uri, + if (cachingProvider + case final PutTileAndMetadataCapability< + HttpControlledCachedTileMetadata> cachingProvider) { + late final HttpControlledCachedTileMetadata metadata; + try { + metadata = HttpControlledCachedTileMetadata.fromHttpHeaders( + headers, + warnOnFallbackUsage: silenceExceptions ? null : uri, + ); + } on Exception catch (e) { + if (kDebugMode) { + Logger(printer: SimplePrinter()).w( + '[flutter_map] Failed to cache ${uri.path}: $e\n\tThis ' + 'may indicate a HTTP spec non-conformance issue with the tile ' + 'server. ', + ); + } + return; + } + + cachingProvider.putTileWithMetadata( + url: resolvedUrl, + metadata: metadata, + bytes: bytes, ); - } catch (e) { - if (kDebugMode && !silenceExceptions) { + } else if (cachingProvider case final PutTileCapability cachingProvider) { + cachingProvider.putTile(url: resolvedUrl, bytes: bytes); + } else if (kDebugMode && !silenceExceptions) { + Logger(printer: SimplePrinter()).w( + '[flutter_map] Caching provider incompatible with ' + '`NetworkBytesFetcher` for put operations', + ); + } + } + + // Create the exception exit method + // In the event that a tile cannot be fetched from the network, and a + // (stale) cached tile is available, and the behaviour is allowed, attempt + // to use the cached resource. This method is used on exit when a + // non-abortion exception occurs. Otherwise, it rethrows the original + // exception to the caller, which may attempt fallbacks. + /*Future fallbackToCachedTile(Object err, StackTrace stackTrace) async { + if (cachedTile == null) { + Error.throwWithStackTrace(err, stackTrace); + } + try { + final cachedResource = await decodeBytes(cachedTile.bytes); + if (kDebugMode) { Logger(printer: SimplePrinter()).w( - '[flutter_map cache] Failed to cache ${uri.path}: $e\n\tThis may ' - 'indicate a HTTP spec non-conformance issue with the tile server. ', + '[flutter_map] Failed to fetch ${uri.path} from network; ' + 'using (stale) cached tile', ); } - return; + return cachedResource; + } on Exception { + Error.throwWithStackTrace(err, stackTrace); } - - cachingProvider.putTile( - url: resolvedUrl, - metadata: metadata, - bytes: bytes, - ); - } + }*/ // Main logic // All `decodeBytes` calls should be awaited so errors may be handled @@ -221,10 +260,12 @@ class NetworkTileImageProvider extends ImageProvider { additionalHeaders: forceFromServer ? null : { - if (cachedTile?.metadata.lastModified case final lastModified?) + if (cachedTile?.metadata + case HttpControlledCachedTileMetadata(:final lastModified?)) HttpHeaders.ifModifiedSinceHeader: HttpDate.format(lastModified), - if (cachedTile?.metadata.etag case final etag?) + if (cachedTile?.metadata + case HttpControlledCachedTileMetadata(:final etag?)) HttpHeaders.ifNoneMatchHeader: etag, }, );