diff --git a/example/lib/main.dart b/example/lib/main.dart index 05d447137..969ef5af1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map_example/pages/abort_obsolete_requests.dart'; import 'package:flutter_map_example/pages/animated_map_controller.dart'; import 'package:flutter_map_example/pages/bundled_offline_map.dart'; -import 'package:flutter_map_example/pages/cancellable_tile_provider.dart'; import 'package:flutter_map_example/pages/circle.dart'; import 'package:flutter_map_example/pages/debouncing_tile_update_transformer.dart'; import 'package:flutter_map_example/pages/epsg3996_crs.dart'; @@ -52,8 +52,8 @@ class MyApp extends StatelessWidget { ), home: const HomePage(), routes: { - CancellableTileProviderPage.route: (context) => - const CancellableTileProviderPage(), + AbortObsoleteRequestsPage.route: (context) => + const AbortObsoleteRequestsPage(), PolylinePage.route: (context) => const PolylinePage(), SingleWorldPolysPage.route: (context) => const SingleWorldPolysPage(), PolylinePerfStressPage.route: (context) => diff --git a/example/lib/misc/tile_providers.dart b/example/lib/misc/tile_providers.dart index 8c6db0b27..7c7f2aee1 100644 --- a/example/lib/misc/tile_providers.dart +++ b/example/lib/misc/tile_providers.dart @@ -1,10 +1,11 @@ import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; +import 'package:http/http.dart'; +import 'package:http/retry.dart'; + +final httpClient = RetryClient(Client()); TileLayer get openStreetMapTileLayer => TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'dev.fleaflet.flutter_map.example', - // Use the recommended flutter_map_cancellable_tile_provider package to - // support the cancellation of loading tiles. - tileProvider: CancellableNetworkTileProvider(), + tileProvider: NetworkTileProvider(httpClient: httpClient), ); diff --git a/example/lib/pages/abort_obsolete_requests.dart b/example/lib/pages/abort_obsolete_requests.dart new file mode 100644 index 000000000..f96d8f97a --- /dev/null +++ b/example/lib/pages/abort_obsolete_requests.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; +import 'package:flutter_map_example/widgets/notice_banner.dart'; +import 'package:latlong2/latlong.dart'; + +class AbortObsoleteRequestsPage extends StatefulWidget { + static const String route = '/abort_obsolete_requests_page'; + + const AbortObsoleteRequestsPage({super.key}); + + @override + State createState() => + _AbortUnnecessaryRequestsPage(); +} + +class _AbortUnnecessaryRequestsPage extends State { + bool _abortingEnabled = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Abort Obsolete Requests')), + drawer: const MenuDrawer(AbortObsoleteRequestsPage.route), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: Switch.adaptive( + value: _abortingEnabled, + onChanged: (value) => setState(() => _abortingEnabled = value), + ), + ), + ), + const NoticeBanner.recommendation( + text: 'Since v8.2.0, in-flight HTTP requests for tiles which are ' + 'no longer displayed are aborted by default.', + url: 'https://docs.fleaflet.dev/layers/tile-layer/tile-providers', + sizeTransition: 870, + ), + Expanded( + child: FlutterMap( + options: MapOptions( + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 5, + cameraConstraint: CameraConstraint.contain( + bounds: LatLngBounds( + const LatLng(-90, -180), + const LatLng(90, 180), + ), + ), + ), + children: [ + TileLayer( + key: ValueKey(_abortingEnabled), + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'dev.fleaflet.flutter_map.example', + tileProvider: NetworkTileProvider( + abortObsoleteRequests: _abortingEnabled, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/pages/animated_map_controller.dart b/example/lib/pages/animated_map_controller.dart index b4cc4fdb6..97a6f7e61 100644 --- a/example/lib/pages/animated_map_controller.dart +++ b/example/lib/pages/animated_map_controller.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; @@ -182,7 +181,6 @@ class AnimatedMapControllerPageState extends State urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'dev.fleaflet.flutter_map.example', - tileProvider: CancellableNetworkTileProvider(), tileUpdateTransformer: _animatedMoveTileUpdateTransformer, ), const MarkerLayer(markers: _markers), diff --git a/example/lib/pages/cancellable_tile_provider.dart b/example/lib/pages/cancellable_tile_provider.dart deleted file mode 100644 index aac67c461..000000000 --- a/example/lib/pages/cancellable_tile_provider.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; -import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; -import 'package:flutter_map_example/widgets/notice_banner.dart'; -import 'package:latlong2/latlong.dart'; - -class CancellableTileProviderPage extends StatefulWidget { - static const String route = '/cancellable_tile_provider_page'; - - const CancellableTileProviderPage({super.key}); - - @override - State createState() => - _CancellableTileProviderPageState(); -} - -class _CancellableTileProviderPageState - extends State { - bool _providerEnabled = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Cancellable Tile Provider')), - drawer: const MenuDrawer(CancellableTileProviderPage.route), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: SwitchListTile.adaptive( - title: const Text('Use CancellableNetworkTileProvider'), - value: _providerEnabled, - onChanged: (value) => setState(() => _providerEnabled = value), - ), - ), - ), - const NoticeBanner.recommendation( - text: - 'This tile provider cancels unnecessary HTTP requests, which can help performance (especially on the web)', - url: - 'https://docs.fleaflet.dev/layers/tile-layer/tile-providers#cancellablenetworktileprovider', - sizeTransition: 905, - ), - Expanded( - child: FlutterMap( - options: MapOptions( - initialCenter: const LatLng(51.5, -0.09), - initialZoom: 5, - cameraConstraint: CameraConstraint.contain( - bounds: LatLngBounds( - const LatLng(-90, -180), - const LatLng(90, 180), - ), - ), - ), - children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'dev.fleaflet.flutter_map.example', - tileProvider: _providerEnabled - ? CancellableNetworkTileProvider() - : null, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/example/lib/pages/fallback_url_page.dart b/example/lib/pages/fallback_url_page.dart index 49289772c..908a2e6fe 100644 --- a/example/lib/pages/fallback_url_page.dart +++ b/example/lib/pages/fallback_url_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:flutter_map_example/widgets/notice_banner.dart'; import 'package:latlong2/latlong.dart'; @@ -44,7 +43,6 @@ class FallbackUrlPage extends StatelessWidget { 'https://not-a-real-provider-url.local/{z}/{x}/{y}.png', fallbackUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'dev.fleaflet.flutter_map.example', - tileProvider: CancellableNetworkTileProvider(), ), ], ), diff --git a/example/lib/pages/reset_tile_layer.dart b/example/lib/pages/reset_tile_layer.dart index e30abb7eb..8ebda37bf 100644 --- a/example/lib/pages/reset_tile_layer.dart +++ b/example/lib/pages/reset_tile_layer.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; @@ -63,7 +62,6 @@ class ResetTileLayerPageState extends State { urlTemplate: layerToggle ? layer1 : layer2, subdomains: layerToggle ? const [] : const ['a', 'b', 'c'], userAgentPackageName: 'dev.fleaflet.flutter_map.example', - tileProvider: CancellableNetworkTileProvider(), ), const MarkerLayer( markers: [ diff --git a/example/lib/pages/tile_builder.dart b/example/lib/pages/tile_builder.dart index 25ddf4dc4..1054dd732 100644 --- a/example/lib/pages/tile_builder.dart +++ b/example/lib/pages/tile_builder.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; @@ -120,7 +119,6 @@ class TileBuilderPageState extends State { urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'dev.fleaflet.flutter_map.example', - tileProvider: CancellableNetworkTileProvider(), tileBuilder: tileBuilder, ), ), diff --git a/example/lib/pages/tile_loading_error_handle.dart b/example/lib/pages/tile_loading_error_handle.dart index 4b328d0ea..5a34f8af2 100644 --- a/example/lib/pages/tile_loading_error_handle.dart +++ b/example/lib/pages/tile_loading_error_handle.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; @@ -67,7 +66,7 @@ class TileLoadingErrorHandleState extends State { // or use the recommended tile provider tileProvider: _simulateTileLoadErrors ? _SimulateErrorsTileProvider() - : CancellableNetworkTileProvider(), + : NetworkTileProvider(), ), ], ); diff --git a/example/lib/widgets/drawer/menu_drawer.dart b/example/lib/widgets/drawer/menu_drawer.dart index f48e7dc59..b150476e9 100644 --- a/example/lib/widgets/drawer/menu_drawer.dart +++ b/example/lib/widgets/drawer/menu_drawer.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_map_example/pages/abort_obsolete_requests.dart'; import 'package:flutter_map_example/pages/animated_map_controller.dart'; import 'package:flutter_map_example/pages/bundled_offline_map.dart'; -import 'package:flutter_map_example/pages/cancellable_tile_provider.dart'; import 'package:flutter_map_example/pages/circle.dart'; import 'package:flutter_map_example/pages/debouncing_tile_update_transformer.dart'; import 'package:flutter_map_example/pages/epsg3996_crs.dart'; @@ -47,7 +47,13 @@ class MenuDrawer extends StatelessWidget { return Drawer( child: ListView( children: [ - DrawerHeader( + Container( + padding: const EdgeInsets.fromLTRB(16, 32, 16, 16) + .add(EdgeInsets.only(top: MediaQuery.paddingOf(context).top)), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + border: Border(bottom: Divider.createBorderSide(context)), + ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -65,11 +71,12 @@ class MenuDrawer extends StatelessWidget { textAlign: TextAlign.center, style: TextStyle(fontSize: 14), ), + const SizedBox(height: 8), if (kIsWeb) - const Text( + Text( _isWASM ? 'Running with WASM' : 'Running without WASM', textAlign: TextAlign.center, - style: TextStyle(fontSize: 14), + style: Theme.of(context).textTheme.bodySmall, ), ], ), @@ -155,8 +162,8 @@ class MenuDrawer extends StatelessWidget { currentRoute: currentRoute, ), MenuItemWidget( - caption: 'Cancellable Tile Provider', - routeName: CancellableTileProviderPage.route, + caption: 'Abort Obsolete Requests', + routeName: AbortObsoleteRequestsPage.route, currentRoute: currentRoute, ), MenuItemWidget( diff --git a/example/pubspec.lock b/example/pubspec.lock index c0d8ff6cc..9ddf3867b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -41,30 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - dart_earcut: - dependency: transitive - description: - name: dart_earcut - sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b - url: "https://pub.dev" - source: hosted - version: "1.2.0" - dio: + crypto: dependency: transitive description: - name: dio - sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "5.8.0+1" - dio_web_adapter: + version: "3.0.6" + dart_earcut: dependency: transitive description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "1.2.0" fake_async: dependency: transitive description: @@ -89,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -109,14 +109,6 @@ packages: relative: true source: path version: "8.1.1" - flutter_map_cancellable_tile_provider: - dependency: "direct main" - description: - name: flutter_map_cancellable_tile_provider - sha256: "801760c104a3cfd9268cda7c9b1241223247e8182613a7e060ef4ffc0d825ac8" - url: "https://pub.dev" - source: hosted - version: "3.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -139,10 +131,10 @@ packages: dependency: "direct main" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: "559ab0d950643c9a04512f4c4bd1c435dcb5af1aa9c848f3bf5867347044328a" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0-beta" http_parser: dependency: transitive description: @@ -171,26 +163,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -211,10 +203,10 @@ packages: dependency: transitive description: name: logger - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" matcher: dependency: transitive description: @@ -255,6 +247,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" + source: hosted + version: "2.2.17" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -315,18 +331,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a" + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" url: "https://pub.dev" source: hosted - version: "2.4.7" + version: "2.4.10" shared_preferences_foundation: dependency: transitive description: @@ -380,6 +396,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -416,10 +440,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" typed_data: dependency: transitive description: @@ -448,18 +472,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" url: "https://pub.dev" source: hosted - version: "6.3.14" + version: "6.3.16" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" url_launcher_linux: dependency: transitive description: @@ -488,10 +512,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -500,22 +524,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: - dependency: "direct main" + dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.0.2" web: dependency: transitive description: @@ -541,5 +573,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.8.0 <4.0.0" flutter: ">=3.27.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index a52b6d312..a8011e6b1 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,7 +11,6 @@ dependencies: flutter: sdk: flutter flutter_map: - flutter_map_cancellable_tile_provider: ^3.1.0 flutter_web_plugins: sdk: flutter http: ^1.2.2 @@ -19,7 +18,6 @@ dependencies: proj4dart: ^2.1.0 shared_preferences: ^2.3.4 url_launcher: ^6.3.1 - vector_math: ^2.1.4 dependency_overrides: flutter_map: diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index acb68995f..ad1e5eae8 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -43,11 +43,16 @@ export 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_display.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; +export 'package:flutter_map/src/layer/tile_layer/tile_provider/asset/provider.dart'; 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_providers/tile_provider_stub.dart' - if (dart.library.io) 'package:flutter_map/src/layer/tile_layer/tile_provider/file_providers/tile_provider_io.dart'; -export 'package:flutter_map/src/layer/tile_layer/tile_provider/network_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'; export 'package:flutter_map/src/map/camera/camera.dart'; diff --git a/lib/src/geo/latlng_bounds.dart b/lib/src/geo/latlng_bounds.dart index e2994f703..55e35f508 100644 --- a/lib/src/geo/latlng_bounds.dart +++ b/lib/src/geo/latlng_bounds.dart @@ -1,7 +1,7 @@ import 'dart:math'; +import 'package:flutter_map/src/misc/deg_rad_conversions.dart'; import 'package:latlong2/latlong.dart'; -import 'package:vector_math/vector_math_64.dart'; /// Data structure representing rectangular bounding box constrained by its /// northwest and southeast corners diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index f0d00ec11..ab9f8f07f 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -5,9 +5,9 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/misc/deg_rad_conversions.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:latlong2/latlong.dart'; -import 'package:vector_math/vector_math_64.dart'; part 'package:flutter_map/src/gestures/compound_animations.dart'; diff --git a/lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/asset/provider.dart similarity index 100% rename from lib/src/layer/tile_layer/tile_provider/asset_tile_provider.dart rename to lib/src/layer/tile_layer/tile_provider/asset/provider.dart diff --git a/lib/src/layer/tile_layer/tile_provider/file_providers/tile_provider_io.dart b/lib/src/layer/tile_layer/tile_provider/file/native_tile_provider.dart similarity index 100% rename from lib/src/layer/tile_layer/tile_provider/file_providers/tile_provider_io.dart rename to lib/src/layer/tile_layer/tile_provider/file/native_tile_provider.dart diff --git a/lib/src/layer/tile_layer/tile_provider/file_providers/tile_provider_stub.dart b/lib/src/layer/tile_layer/tile_provider/file/stub_tile_provider.dart similarity index 85% rename from lib/src/layer/tile_layer/tile_provider/file_providers/tile_provider_stub.dart rename to lib/src/layer/tile_layer/tile_provider/file/stub_tile_provider.dart index d6d92121b..9d25e3310 100644 --- a/lib/src/layer/tile_layer/tile_provider/file_providers/tile_provider_stub.dart +++ b/lib/src/layer/tile_layer/tile_provider/file/stub_tile_provider.dart @@ -21,5 +21,7 @@ class FileTileProvider extends TileProvider { @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => throw UnsupportedError( - 'The current platform does not have access to IO (the local filesystem), and therefore does not support `FileTileProvider`'); + 'The current platform does not have access to IO (the local ' + 'filesystem), and therefore does not support `FileTileProvider`', + ); } diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/built_in_caching_provider.dart b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/built_in_caching_provider.dart new file mode 100644 index 000000000..e29b6fbf8 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/built_in_caching_provider.dart @@ -0,0 +1,151 @@ +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: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 +/// +/// 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. +/// +/// 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 { + /// If an instance exists, return it, otherwise create a new instance + /// + /// The provided configuration will only be respected if an instance does not + /// already exist. + /// + /// See individual properties for more information about configuration. + factory BuiltInMapCachingProvider.getOrCreateInstance({ + /// Path to the directory to use to store cached tiles & other related files + /// + /// The provider actually uses the 'fm_cache' directory created as a child + /// of the path specified here. + /// + /// The program must have rights/permissions to access the path. + /// + /// The path does not have to exist, it will be recursively created if + /// missing. + /// + /// All files and directories within the path will be liable to deletion by + /// the size reducer. + /// + /// Defaults to a platform provided cache directory, which may be cleared by + /// the OS at any time. + String? cacheDirectory, + + /// Maximum total size of cached tiles, in bytes + /// + /// This is applied only when the instance is created, by running the size + /// reducer. This runs in the background (and so does not delay reads or + /// writes). The cache size may exceed this limit while the program is + /// running. + /// + /// Disabling the size limit may improve write performance. + /// + /// Defaults to 1 GB. Set to `null` to disable. + int? maxCacheSize = 1_000_000_000, + + /// Function to convert a tile's URL to a key used to uniquely identify the + /// tile + /// + /// Where parts of the URL are volatile or do not represent the tile's + /// contents/image - for example, API keys contained with the query + /// parameters - this should be modified to remove the volatile portions. + /// + /// Keys must be usable as filenames on all intended platform filesystems. + /// The callback should not throw. + /// + /// Defaults to using [uuidTileKeyGenerator], which custom implementations + /// may utilise. + String Function(String url)? tileKeyGenerator, + + /// Override the duration of time a tile is considered fresh for + /// + /// Defaults to `null`: use duration calculated from each tile's HTTP + /// headers. + Duration? overrideFreshAge, + + /// Prevent any tiles from being added or updated + /// + /// Does not disable the size reducer if the cache size is larger than + /// `maxCacheSize`. + /// + /// Defaults to `false`. + bool readOnly = false, + }) { + assert( + maxCacheSize == null || maxCacheSize > 0, + '`maxCacheSize` must be greater than 0 or disabled', + ); + assert( + overrideFreshAge == null || overrideFreshAge > Duration.zero, + '`overrideFreshAge` must be greater than 0 or disabled', + ); + return _instance ??= BuiltInMapCachingProviderImpl.create( + cacheDirectory: cacheDirectory, + maxCacheSize: maxCacheSize, + overrideFreshAge: overrideFreshAge, + tileKeyGenerator: tileKeyGenerator ?? uuidTileKeyGenerator, + readOnly: readOnly, + resetSingleton: () => _instance = null, + ); + } + + static BuiltInMapCachingProviderImpl? _instance; + + /// Destroy this caching provider instance + /// + /// This means that all workers will be terminated and caching will be + /// unavailable until the next time + /// [BuiltInMapCachingProvider.getOrCreateInstance] is called (which may be + /// on the next tile load by default). + /// + /// If [deleteCache] is `true` (defaults to `false`), then the entire + /// `cacheDirectory` and its contents will be deleted. + /// + /// Completes when fully uninitialised. It is not necessary to wait for this + /// to complete before calling [BuiltInMapCachingProvider.getOrCreateInstance] + /// again (to create a new instance). + /// + /// --- + /// + /// It is usually safe to let the caching provider 'naturally' terminate with + /// the program when the system terminates the program after the app is + /// closed. Therefore, this method is not required to be called at the end of + /// the app's lifecycle. + /// + /// This method is provided to: + /// * allow cache provider's configuration to be changed + /// * allow the cache to be deleted from within the app + /// * facilitate testing + /// + /// Note that the cache may also be deleted when the program is not running + /// by deleteting the `cacheDirectory` - for example, by the user choosing to + /// clear the app's cache through the operating system (by default). + Future destroy({bool deleteCache = false}); + + /// Default `tileKeyGenerator` which generates v5 UUIDs from input strings + /// + /// May be utilised in custom `tileKeyGenerator` implementations. + /// + /// See [BuiltInMapCachingProvider.getOrCreateInstance]'s parameter for more + /// info. + static String uuidTileKeyGenerator(String url) => + _uuid.v5(Namespace.url.value, url); + static final _uuid = Uuid(goptions: GlobalOptions(MathRNG())); +} diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/README.md b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/README.md new file mode 100644 index 000000000..c9e539884 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/README.md @@ -0,0 +1,51 @@ +# `BuiltInMapCachingProvider` storage spec + +The `BuiltInMapCachingProvider`, referred to as just 'built-in caching', is implemented using the filesystem for storage on native platforms. + +The filesystem is used over alternatives such as databases because: + +* Cached tiles can be read immediately without any preprocessing step; writing to the cache does need to potentially wait for an 'initialisation' +* It is lightweight and adds only additional packages to a shipped app, not binaries + +Cached tiles & their metadata are stored as individual keyed files. An additional file is used to improve the efficiency of tracking and reducing the cache size, called the 'size monitor'. + +## Tiles (format v1) + +Tiles are stored in files, where the filename is the output of the supplied `cacheKeyGenerator` given the tile's URL. This defaults to a v5 UUID. Files have no extension. + +Also stored alongside tiles is metadata used to perform caching, namely: + +* `staleAt`: The calculated time at which the tile becomes 'stale' +* (optionally) `lastModified`: The time at which the tile was last modified on the server, based on the HTTP header +* (optionally) `etag`: A unique string identifier for the current version of that tile, using the 'etag' HTTP header + +The file format is as follows: + +1. The header containing the tile metadata +2. The tile image bytes (as responded by the server), no longer than 4,294,967,295 bytes + +The format of the header is as follows: + +1. (position 0) 6-byte ASCII encoded string: file format identifier "FMBICT" ("FlutterMapBuiltInCacheTile") +2. (position 6) 2-byte unsigned integer (Uint16): the format version (1) +3. (position 8) 8-byte signed integer (Int64): the `staleAt` timestamp, represented in milliseconds since the Unix epoch in the UTC timezone +4. (position 16) 8-byte signed integer (Int64) + * Where provided, the `lastModified` timestamp, represented in milliseconds since the Unix epoch in the UTC timezone, which must not be 0 + * Where not provided, the integer '0' +5. (position 24) 2-byte unsigned integer (Uint16) + * Where provided, the length of the ASCII encoded `etag` in bytes + * Where not provided, the integer '0' +6. (position 26) Variable number of bytes + * Where provided, the ASCII encoded `etag` (where each character is 7 bits but stored as 1 byte) with no greater than 65535 bytes + * Where not provided, no bytes +7. (position 26 + 2-byte value read from position 24) 4-byte unsigned integer (Uint32): the length of the tile image bytes + +## Size monitor + +Contains an 8-byte unsigned integer (Uint64), representing the size of all tiles (including metadata) stored in the cache in bytes. + +This size monitor should stay in sync with the actual size of the cache - as calculating the cache size using I/O operations is expensive and slow. Therefore, if it might go out of sync with reality for any reason (such as a detected read failure, indicating a corrupted tile likely of a different length to what is accounted for in the size monitor), then it must be disabled. Since it is only used on startup, it is recalculated using the expensive method on the next startup. + +Whilst it is being calculated (which should happen on the first initialisation of the cache, or when required as above), writes must be delayed. Reads can still occur. + +Named 'sizeMonitor.bin'. Does not contain an indentifier/signature. diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart new file mode 100644 index 000000000..f073aa3e0 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/native.dart @@ -0,0 +1,258 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +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:meta/meta.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +@internal +class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { + static const sizeMonitorFileName = 'sizeMonitor.bin'; + static const tileFileFormatSignature = [70, 77, 66, 73, 67, 84]; + + final String? cacheDirectory; + final int? maxCacheSize; + final String Function(String url) tileKeyGenerator; + final Duration? overrideFreshAge; + final bool readOnly; + + final void Function() resetSingleton; + + @internal + BuiltInMapCachingProviderImpl.create({ + required this.cacheDirectory, + required this.maxCacheSize, + required this.overrideFreshAge, + required this.tileKeyGenerator, + required this.readOnly, + required this.resetSingleton, + }) { + Future Function()? killWorker; + bool earlyUninitialiseRequested = false; + _killWorker = () { + if (killWorker != null) return killWorker!(); + earlyUninitialiseRequested = true; + return _cacheDirectoryPathReady.future; + }; + + () async { + final cacheDirectoryPath = p.join( + cacheDirectory ?? (await getApplicationCacheDirectory()).absolute.path, + 'fm_cache', + ); + await Directory(cacheDirectoryPath).create(recursive: true); + + final sizeMonitorFilePath = + p.join(cacheDirectoryPath, sizeMonitorFileName); + + _cacheDirectoryPath = cacheDirectoryPath; + _cacheDirectoryPathReady.complete(cacheDirectoryPath); + + if (earlyUninitialiseRequested) return; + + SendPort? workerPort; + final workerPortReady = Completer(); + + // We can't send messages until the worker has set-up all the size + // monitoring (and potentially run the reducer) if necessary + // Reading does not depend on this. + void sendMessageToWorker(Object? message) { + if (workerPort != null) return workerPort!.send(message); + workerPortReady.future.then((port) => port.send(message)); + } + + _writeTileFile = (path, metadata, tileBytes) => sendMessageToWorker( + (path: path, metadata: metadata, tileBytes: tileBytes), + ); + _reportReadFailure = () => sendMessageToWorker(false); + + final workerReceivePort = ReceivePort(); + final workerExited = Completer(); + + await Isolate.spawn( + tileAndSizeMonitorWriterWorker, + ( + port: workerReceivePort.sendPort, + cacheDirectoryPath: cacheDirectoryPath, + sizeMonitorFilePath: sizeMonitorFilePath, + maxCacheSize: maxCacheSize, + ), + debugName: '[flutter_map: cache] Tile & Size Monitor Writer', + ); + + workerReceivePort.listen( + (response) { + if (response is SendPort && workerPort == null) { + return workerPortReady.complete(workerPort = response); + } + if (response == null) { + return workerReceivePort.close(); + } + + throw UnsupportedError('Response was in unknown format'); + }, + onDone: workerExited.complete, + ); + killWorker = () { + sendMessageToWorker(null); + return workerExited.future; + }; + }(); + } + + String? _cacheDirectoryPath; // ~cached version of below for instant access + final _cacheDirectoryPathReady = Completer(); + + late final void Function( + String path, + CachedMapTileMetadata metadata, + Uint8List? tileBytes, + ) _writeTileFile; + late final void Function() + _reportReadFailure; // See `disableSizeMonitor` in worker + late final Future Function() _killWorker; + + final _asciiDecoder = const AsciiDecoder(); + + @override + bool get isSupported => true; + + @override + Future destroy({bool deleteCache = false}) async { + resetSingleton(); + await _killWorker(); + if (deleteCache) { + await Directory(_cacheDirectoryPath!).delete(recursive: true); + } + } + + @override + Future getTile(String url) async { + final key = tileKeyGenerator(url); + final tileFile = File( + p.join(_cacheDirectoryPath ?? await _cacheDirectoryPathReady.future, key), + ); + + if (!await tileFile.exists()) return null; + + try { + final bytes = await tileFile.readAsBytes(); + + if (bytes.lengthInBytes < 30) { + throw CachedMapTileReadFailure( + url: url, + description: 'file was shorter than the min. expected size (found ' + '${bytes.lengthInBytes} bytes, expected >= 30 bytes)', + ); + } + + final formatSignature = bytes.buffer.asUint8List(0, 6); + + if (formatSignature[0] != tileFileFormatSignature[0] || + formatSignature[1] != tileFileFormatSignature[1] || + formatSignature[2] != tileFileFormatSignature[2] || + formatSignature[3] != tileFileFormatSignature[3] || + formatSignature[4] != tileFileFormatSignature[4] || + formatSignature[5] != tileFileFormatSignature[5]) { + throw CachedMapTileReadFailure( + url: url, + description: + 'file did not contain expected format signature at start (found ' + '$formatSignature, expected $tileFileFormatSignature)', + ); + } + + final version = bytes.buffer.asUint16List(6, 1)[0]; + + if (version != 1) { + throw CachedMapTileReadFailure( + url: url, + description: + 'cache file was of a different version (found v$version, ' + 'expected v1)', + ); + } + + final timestamps = bytes.buffer.asInt64List(8, 2); + final staleAt = + DateTime.fromMillisecondsSinceEpoch(timestamps[0], isUtc: true); + final lastModified = timestamps[1] == 0 + ? null + : DateTime.fromMillisecondsSinceEpoch(timestamps[1], isUtc: true); + + final etagLength = bytes.buffer.asUint16List(24, 1)[0]; + final String? etag; + if (etagLength == 0) { + etag = null; + } else { + final etagBytes = Uint8List.sublistView(bytes, 26, 26 + etagLength); + etag = _asciiDecoder.convert(etagBytes); + } + + final tileBytesExpectedLength = // Perform an unaligned read + bytes.buffer.asByteData(26 + etagLength, 4).getUint32(0, Endian.host); + // We read the remainder of the file, rather than just reading the + // specified number of bytes + final tileBytes = Uint8List.sublistView(bytes, 26 + etagLength + 4); + if (tileBytes.lengthInBytes != tileBytesExpectedLength) { + throw CachedMapTileReadFailure( + url: url, + description: 'tile image bytes were not of expected length (found ' + '${tileBytes.lengthInBytes} bytes, expected ' + '$tileBytesExpectedLength bytes)', + ); + } + + return ( + metadata: CachedMapTileMetadata( + staleAt: staleAt, + lastModified: lastModified, + etag: etag, + ), + bytes: tileBytes, + ); + } on CachedMapTileReadFailure { + _reportReadFailure(); + rethrow; + } catch (error, stackTrace) { + _reportReadFailure(); + Error.throwWithStackTrace( + CachedMapTileReadFailure(url: url, originalError: error), + stackTrace, + ); + } + } + + @override + Future putTile({ + required String url, + required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) async { + if (readOnly) return; + + final key = tileKeyGenerator(url); + final path = p.join( + _cacheDirectoryPath ?? await _cacheDirectoryPathReady.future, + key, + ); + + _writeTileFile( + path, + overrideFreshAge != null + ? CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(overrideFreshAge!), + lastModified: metadata.lastModified, + etag: metadata.etag, + ) + : metadata, + bytes, + ); + } +} diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/size_reducer.dart b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/size_reducer.dart new file mode 100644 index 000000000..b4f8e9391 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/size_reducer.dart @@ -0,0 +1,91 @@ +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:meta/meta.dart'; +import 'package:path/path.dart' as p; + +/// Remove tile files from the cache directory until at least [minSizeToDelete] +/// bytes have been deleted. +/// +/// Removes the least recently accessed tiles first. Tries to remove as few +/// tiles as possible (largest first if last accessed at same time). +/// +/// Returns the number of bytes actually deleted. +@internal +Future sizeReducerWorker({ + required String cacheDirectoryPath, + required String sizeMonitorFilePath, + required int minSizeToDelete, +}) async { + final cacheDirectory = Directory(cacheDirectoryPath); + + final tiles = await Future.wait( + cacheDirectory.listSync().whereType().where((f) { + final uuid = p.basename(f.absolute.path); + return uuid != BuiltInMapCachingProviderImpl.sizeMonitorFileName; + }).map((f) async { + // `stat.accessed` may be unstable on some OSs, but seems to work enough? + final stat = await f.stat(); + + return _SizeReducerTile( + path: f.absolute.path, + size: stat.size, + sortKey: stat.accessed, + ); + }), + ); + + int compareSortKeys(_SizeReducerTile a, _SizeReducerTile b) => + a.sortKey.compareTo(b.sortKey); + int compareInverseSizes(_SizeReducerTile a, _SizeReducerTile b) => + b.size.compareTo(a.size); + tiles.sort(compareSortKeys.then(compareInverseSizes)); + + int i = 0; + int deletedSize = 0; + final deletionOperations = () sync* { + while (deletedSize < minSizeToDelete && i < tiles.length) { + final tile = tiles[i++]; + deletedSize += tile.size; + yield File(tile.path).delete().then((_) {}, onError: (_) { + // We might not be able to delete the tile if its being read/just been + // read, because "another process" has obtained a lock on the tile. + // (jaffaketchup) (it's difficult to prove whether this is the case, but + // it makes sense) + // This could be seen as a useful feature: the tiles which the user sees + // when they start the app remain cached. + // In reality, this is unlikely to occur unless the size limit is really + // small (since other older tiles will be deleted first, which shouldn't + // be locked). + // This silences the error. + }); + } + }() + .toList(growable: false); + + await Future.wait(deletionOperations); + + return deletedSize; +} + +@immutable +class _SizeReducerTile { + final String path; // We assume the path is unique for equality purposes + final int size; + final DateTime sortKey; + + const _SizeReducerTile({ + required this.path, + required this.size, + required this.sortKey, + }); + + @override + int get hashCode => path.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is _SizeReducerTile && other.path == path); +} 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/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart new file mode 100644 index 000000000..7e2a67117 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/native/workers/tile_and_size_monitor_writer.dart @@ -0,0 +1,330 @@ +import 'dart:async'; +import 'dart:convert'; +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:meta/meta.dart'; +import 'package:path/path.dart' as p; + +/// Isolate worker which writes tile files, maintains the size monitor, and +/// (if necessary) starts the size reducer. +/// +/// Follows the storage spec described in README.md. +@internal +Future tileAndSizeMonitorWriterWorker( + ({ + SendPort port, + String cacheDirectoryPath, + String sizeMonitorFilePath, + int? maxCacheSize, + }) input, +) async { + //! SIZE MONITOR HANDLING + + final sizeMonitorFile = File(input.sizeMonitorFilePath); + RandomAccessFile? sizeMonitor; + late int currentSize; + + final allocUint64BufferSizeMonitor = Uint8List(8); + void updateSizeMonitor(int deltaSize) { + if (sizeMonitor == null) return; + currentSize += deltaSize; + sizeMonitor! + ..setPositionSync(0) + ..writeFromSync( + allocUint64BufferSizeMonitor..buffer.asUint64List()[0] = currentSize, + ) + ..flushSync(); + } + + // This is called when a read failure occurs (potentially during writing), + // usually due to corruption of a tile. + // In this case, the size monitor cannot be made accurate without regenerating + // it. (If a tile is truncated to 10u, we can write a fresh 50u over it, + // but we cannot know that originally 40u were lost). + // We don't need the monitor until the next initialisation, where we need to + // run the size reducer, however - so we just delete it and forget about it. + void disableSizeMonitor() { + if (sizeMonitor == null) return; + sizeMonitor!.closeSync(); + sizeMonitorFile.deleteSync(); + sizeMonitor = null; + } + + // We try to open and read the size monitor, if we have a size limit. + // If it's available, we can begin writing immediately. + // Otherwise, we need to wait for it to be regenerated, which takes some time. + // This is only neccessary for a brand new cache, an existing cache with a + // newly imposed `maxCacheSize` when there was previously none, or a corrupted + // cache (where the size monitor is missing, potentially due to a read + // failure). + // We can run the size reducer in another isolate afterwards, as it returns a + // relative change to the current cache size. Writes don't need to wait for + // that expensive process. + if (input.maxCacheSize case final maxCacheSize?) { + Future regenerateSizeMonitor() async { + int calculatedSize = 0; + int waitingForSize = 0; + bool finishedListing = false; + final finishedCalculating = Completer(); + + await for (final file in Directory(input.cacheDirectoryPath).list()) { + if (file is! File || + p.basename(file.absolute.path) == + BuiltInMapCachingProviderImpl.sizeMonitorFileName) { + continue; + } + waitingForSize++; + file.length().then((size) { + calculatedSize += size; + waitingForSize--; + if (finishedListing && waitingForSize == 0) { + finishedCalculating.complete(); + } + }); + } + + finishedListing = true; + if (waitingForSize != 0) await finishedCalculating.future; + + sizeMonitor! + ..setPositionSync(0) + ..writeFromSync(Uint8List(8)..buffer.asUint64List()[0] = calculatedSize) + ..flushSync(); + + currentSize = calculatedSize; + } + + final sizeMonitorInitiallyExists = sizeMonitorFile.existsSync(); + sizeMonitor = sizeMonitorFile.openSync(mode: FileMode.append) + ..setPositionSync(0); + if (sizeMonitorInitiallyExists) { + try { + currentSize = sizeMonitor!.readSync(8).buffer.asUint64List()[0]; + } catch (_) { + await regenerateSizeMonitor(); + } + } else { + await regenerateSizeMonitor(); + } + + if (currentSize > maxCacheSize) { + Future runSizeReducer({ + required String cacheDirectoryPath, + required String sizeMonitorFilePath, + required int minSizeToDelete, + }) => + Isolate.run( + () => sizeReducerWorker( + cacheDirectoryPath: cacheDirectoryPath, + sizeMonitorFilePath: sizeMonitorFilePath, + minSizeToDelete: minSizeToDelete, + ), + debugName: '[flutter_map: cache] Size Reducer', + ); + + runSizeReducer( + cacheDirectoryPath: input.cacheDirectoryPath, + sizeMonitorFilePath: input.sizeMonitorFilePath, + minSizeToDelete: currentSize - maxCacheSize, + ).then((deletedSize) => updateSizeMonitor(-deletedSize)); + } + } + + //! TILE WRITING + + final allocInt64BufferTileWrite = Uint8List(8); + final allocUint32BufferTileWrite = Uint8List(4); + final allocUint16BufferTileWrite = Uint8List(2); + final asciiEncoder = const AsciiEncoder(); + void writeTile({ + required final String path, + required final CachedMapTileMetadata metadata, + Uint8List? tileBytes, + }) { + final tileFile = File(path); + final initialTileFileExists = tileFile.existsSync(); + final initialTileFileLength = + initialTileFileExists ? tileFile.lengthSync() : 0; + + if (!initialTileFileExists && tileBytes == null) { + // This should only be caused by the size reducer deleting the tile after + // we sent it's info to the server, and it returned Not Modified correctly + return; + } + + if (tileBytes != null && tileBytes.lengthInBytes > 0xFFFFFFFF) { + // These bytes are too big to have a length stored in a Uint32 + // In reality, this is unlikely + return; + } + + final RandomAccessFile ram; + try { + ram = tileFile.openSync(mode: FileMode.append); + } on FileSystemException { + return; + } + + ram + // We start writing to the start of the file, where we store our header + // info + ..setPositionSync(0) + // Identify the file format in 6 bytes + ..writeFromSync(BuiltInMapCachingProviderImpl.tileFileFormatSignature) + // Identify the format version (v1) in 2 bytes + ..writeFromSync(allocUint16BufferTileWrite..buffer.asUint16List()[0] = 1) + // We store the stale-at header in 8 signed bytes... + ..writeFromSync( + allocInt64BufferTileWrite + ..buffer.asInt64List()[0] = metadata.staleAt.millisecondsSinceEpoch, + ) + // ...followed by the last-modified header in 8 signed bytes, or '0' if + // null + ..writeFromSync( + allocInt64BufferTileWrite + ..buffer.asInt64List()[0] = + metadata.lastModified?.millisecondsSinceEpoch ?? 0, + ); + + // We need to read the old etag length to compare their lengths + int? initialEtagLength; + if (initialTileFileExists) { + try { + initialEtagLength = ram.readSync(2).buffer.asUint16List()[0]; + } catch (_) { + // This implies the tile was corrupted on the previous write (the + // write was terminated unexpectedly) + // However, this shouldn't be possible in practise, since that should've + // been caught on read, which should occur before every write, causing + // a fresh overwrite with new bytes + // We try to handle it anyway by emptying the tile completely so it is + // auto-repaired on the next read + ram + ..truncateSync(0) + ..closeSync(); + disableSizeMonitor(); + return; + } + + ram.setPositionSync(24); // we need to go back to the start of the length + } + + final int etagLength; + late final Uint8List etagBytes; // left unset if etagLength = 0 + if (metadata.etag == null) { + // We don't have an etag, so we write 2 unsigned bytes indicating the etag + // length is 0 + ram.writeFromSync( + allocUint16BufferTileWrite..buffer.asUint16List()[0] = etagLength = 0, + ); + } else { + etagBytes = asciiEncoder.convert(metadata.etag!); + // We store the etag length in 2 signed bytes (unless it is too large)... + ram.writeFromSync( + allocUint16BufferTileWrite + ..buffer.asUint16List()[0] = etagLength = + (etagBytes.lengthInBytes > 0xFFFF ? 0 : etagBytes.lengthInBytes), + ); + } + + if (initialEtagLength != etagLength && tileBytes == null) { + // This is annoying - even if the tile bytes haven't changed, we need to + // rewrite them so they are in the right place + // To do this, we have to read the remainder of the file, skipping over + // the etag as it has not yet changed, and make it as if they were new + // bytes + ram.setPositionSync(26 + initialEtagLength!); + + final int initialTileBytesLength; + try { + initialTileBytesLength = ram.readSync(4).buffer.asUint32List()[0]; + } catch (_) { + // This implies the tile was corrupted on the previous write (the + // write was terminated unexpectedly) + ram + ..truncateSync(0) + ..closeSync(); + disableSizeMonitor(); + return; + } + + tileBytes = ram.readSync(initialTileBytesLength); + if (tileBytes.lengthInBytes != initialTileBytesLength) { + // This implies the tile was corrupted on the previous write (the + // write was terminated unexpectedly whilst writing tile bytes) + ram + ..truncateSync(0) + ..closeSync(); + disableSizeMonitor(); + return; + } + + ram.setPositionSync(26); + } + + if (etagLength != 0) { + // ...followed by the etag itself + ram.writeFromSync(etagBytes); + } + + if (tileBytes == null) { + // If there were no updates to the tile bytes, that also implies there + // were no changes to the length of the etag, so we don't need to do + // any size updates + ram.closeSync(); + return; + } + + // We store the length of the tile bytes in 4 unsigned bytes... + ram.writeFromSync( + allocUint32BufferTileWrite + ..buffer.asUint32List()[0] = tileBytes.lengthInBytes, + ); + + // ...followed by the tile bytes + ram.writeFromSync(tileBytes); + final finalPosition = ram.positionSync(); + ram + // We truncate the tile in case the bytes have been moved forward or are + // shorter than previously + ..truncateSync(finalPosition) + ..closeSync(); + + // Then update the size monitor + if (finalPosition - initialTileFileLength case final deltaSize + when deltaSize != 0) { + updateSizeMonitor(deltaSize); + } + } + + //! COMMS HANDLING + + // Now we're ready to recieve commands + final receivePort = ReceivePort(); + input.port.send(receivePort.sendPort); + + await for (final val in receivePort) { + if (val + case ( + :final String path, + :final CachedMapTileMetadata metadata, + :final Uint8List? tileBytes, + )) { + writeTile(path: path, metadata: metadata, tileBytes: tileBytes); + } else if (val == false) { + disableSizeMonitor(); + } else if (val == null) { + receivePort.close(); + } else { + throw UnsupportedError('Command was in unknown format'); + } + } + + sizeMonitor?.closeSync(); + Isolate.exit(input.port); +} diff --git a/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/stub.dart b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/stub.dart new file mode 100644 index 000000000..ff1e250e9 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/stub.dart @@ -0,0 +1,45 @@ +import 'dart:typed_data'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:meta/meta.dart'; + +/// Internal stub implementation of [BuiltInMapCachingProvider] +/// +/// Implemented based on platform in `native/` and `web/`. These must follow +/// the same structure as this stub. +@internal +class BuiltInMapCachingProviderImpl implements BuiltInMapCachingProvider { + final String? cacheDirectory; + final int? maxCacheSize; + final String Function(String url) tileKeyGenerator; + final Duration? overrideFreshAge; + final bool readOnly; + + final void Function() resetSingleton; + + @internal + const BuiltInMapCachingProviderImpl.create({ + required this.cacheDirectory, + required this.maxCacheSize, + required this.overrideFreshAge, + required this.tileKeyGenerator, + required this.readOnly, + required this.resetSingleton, + }); + + @override + external Future destroy({bool deleteCache = false}); + + @override + external bool get isSupported; + + @override + external Future getTile(String url); + + @override + external Future putTile({ + required String url, + required CachedMapTileMetadata 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/tile_layer/tile_provider/network/caching/built_in/impl/web/web.dart new file mode 100644 index 000000000..5d2fc3252 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/built_in/impl/web/web.dart @@ -0,0 +1,28 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:meta/meta.dart'; + +@internal +class BuiltInMapCachingProviderImpl + with DisabledMapCachingProvider + implements BuiltInMapCachingProvider { + final String? cacheDirectory; + final int? maxCacheSize; + final String Function(String url) tileKeyGenerator; + final Duration? overrideFreshAge; + final bool readOnly; + + final void Function() resetSingleton; + + @internal + const BuiltInMapCachingProviderImpl.create({ + required this.cacheDirectory, + required this.maxCacheSize, + required this.overrideFreshAge, + required this.tileKeyGenerator, + required this.readOnly, + required this.resetSingleton, + }); + + @override + Future destroy({bool deleteCache = false}) => Future.value(); +} 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 new file mode 100644 index 000000000..1335b2a88 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/caching_provider.dart @@ -0,0 +1,52 @@ +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/disabled/disabled_caching_provider.dart b/lib/src/layer/tile_layer/tile_provider/network/caching/disabled/disabled_caching_provider.dart new file mode 100644 index 000000000..cb947dbe4 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/disabled/disabled_caching_provider.dart @@ -0,0 +1,24 @@ +import 'dart:typed_data'; + +import 'package:flutter_map/flutter_map.dart'; + +/// Caching provider which disables built-in caching +mixin class DisabledMapCachingProvider implements MapCachingProvider { + /// Disable built-in map caching + const DisabledMapCachingProvider(); + + @override + bool get isSupported => false; + + @override + Never getTile(String url) => + throw UnsupportedError('Must not be called if `isSupported` is `false`'); + + @override + Never putTile({ + required String url, + required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) => + throw UnsupportedError('Must not be called if `isSupported` is `false`'); +} 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 new file mode 100644 index 000000000..858c65fd2 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/tile_metadata.dart @@ -0,0 +1,109 @@ +import 'dart:io' show HttpHeaders, HttpDate; // web safe! +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:meta/meta.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. + factory CachedMapTileMetadata.fromHttpHeaders( + Map headers, { + Duration fallbackFreshnessAge = const Duration(days: 7), + }) { + // 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); + } + + 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)); + } + + 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/caching/tile_read_failure_exception.dart b/lib/src/layer/tile_layer/tile_provider/network/caching/tile_read_failure_exception.dart new file mode 100644 index 000000000..40c071cc2 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/caching/tile_read_failure_exception.dart @@ -0,0 +1,44 @@ +/// Indicates that the tile with the given URL was present in the cache, but +/// could not be correctly read +/// +/// This may be due to an unexpected corruption. It should not be thrown when +/// the tile was written correctly. +/// +/// Tile providers should catch this exception. Wherever possible, they should +/// repair or replace the tile with a fresh & valid one. +/// +/// The absence of this exception does not necessarily mean that the returned +/// tile image bytes are valid, only that all the correctly written information +/// was successfully read. +/// +/// This exception is not usually for external consumption, except for tile +/// provider implementations. +class CachedMapTileReadFailure implements Exception { + /// Create an exception which indicates the tile with the given URL was + /// present in the cache, but could not be correctly read + /// + /// Usually, one of [description] or [originalError] should be provided. + const CachedMapTileReadFailure({ + required this.url, + this.description, + this.originalError, + }); + + /// URL of the failed tile + final String url; + + /// An optional description of the read failure which caused this to be thrown + /// + /// Usually, one of [description] or [originalError] should be provided. + final String? description; + + /// If available, the original error/exception which caused this to be thrown + /// (if not thrown manually) + /// + /// Usually, one of [description] or [originalError] should be provided. + final Object? originalError; + + @override + String toString() => + 'Failed to read cached tile for $url: ${description ?? originalError}'; +} diff --git a/lib/src/layer/tile_layer/tile_provider/network/image_provider/consolidate_response.dart b/lib/src/layer/tile_layer/tile_provider/network/image_provider/consolidate_response.dart new file mode 100644 index 000000000..eacf725b4 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/image_provider/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/tile_layer/tile_provider/network/image_provider/image_provider.dart b/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart new file mode 100644 index 000000000..515059d14 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart @@ -0,0 +1,325 @@ +import 'dart:async'; +import 'dart:io' show HttpHeaders, HttpDate, HttpStatus; // this is web safe! +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/image_provider/consolidate_response.dart'; +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; + +/// Dedicated [ImageProvider] to fetch tiles from the network +/// +/// Supports falling back to a secondary URL, if the primary URL fetch fails. +/// Note that specifying a [fallbackUrl] will prevent this image provider from +/// being cached in memory. +@immutable +@internal +class NetworkTileImageProvider extends ImageProvider { + /// The URL to fetch the tile from (GET request) + final String url; + + /// The URL to fetch the tile from (GET request), in the event the original + /// [url] request fails + /// + /// If this is non-null, [operator==] will always return `false` (except if + /// the two objects are [identical]). Therefore, if this is non-null, this + /// image provider will not be cached in memory. + /// + /// If the fallback is used, it will not be cached with the [cachingProvider]. + final String? fallbackUrl; + + /// The headers to include with the tile fetch request + /// + /// Not included in [operator==]. + final Map headers; + + /// The HTTP client to use to make network requests + /// + /// Not included in [operator==]. + final Client httpClient; + + /// Completes when the tile request should be aborted + /// + /// Not included in [operator==]. + final Future? abortTrigger; + + /// Whether to ignore exceptions and errors that occur whilst fetching tiles + /// over the network, and just return a transparent tile + /// + /// Not included in [operator==]. + final bool silenceExceptions; + + /// Whether to optimistically attempt to decode HTTP responses that have a + /// non-successful status code as an image + /// + /// Not included in [operator==]. + final bool attemptDecodeOfHttpErrorResponses; + + /// Caching provider used to get cached tiles + /// + /// See online documentation for more information about built-in caching. + /// + /// Defaults to [BuiltInMapCachingProvider]. Set to + /// [DisabledMapCachingProvider] to disable. + /// + /// Not included in [operator==]. + final MapCachingProvider? cachingProvider; + + /// Create a dedicated [ImageProvider] to fetch tiles from the network + /// + /// Supports falling back to a secondary URL, if the primary URL fetch fails. + /// Note that specifying a [fallbackUrl] will prevent this image provider from + /// being cached. + const NetworkTileImageProvider({ + required this.url, + required this.fallbackUrl, + required this.headers, + required this.httpClient, + required this.abortTrigger, + required this.silenceExceptions, + required this.attemptDecodeOfHttpErrorResponses, + required this.cachingProvider, + }); + + @override + ImageStreamCompleter loadImage( + NetworkTileImageProvider key, + ImageDecoderCallback decode, + ) { + final chunkEvents = StreamController(); + + return MultiFrameImageStreamCompleter( + codec: _loadImage(key, chunkEvents.sink, decode) + ..then( + (_) => unawaited(chunkEvents.close()), + onError: (_) => unawaited(chunkEvents.close()), + ), + chunkEvents: chunkEvents.stream, + scale: 1, + debugLabel: key.url, + informationCollector: () => [ + DiagnosticsProperty('URL', url), + DiagnosticsProperty('Fallback URL', fallbackUrl), + DiagnosticsProperty('Current provider', key), + ], + ); + } + + Future _loadImage( + NetworkTileImageProvider key, + StreamSink chunkEvents, + ImageDecoderCallback decode, { + bool useFallback = false, + }) async { + // Create utility methods + void evict() => + scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); + Future decodeBytes(Uint8List bytes) => + ImmutableBuffer.fromUint8List(bytes).then(decode); + + // Resolve URIs + final resolvedUrl = useFallback ? fallbackUrl ?? '' : url; + final Uri uri; + try { + uri = Uri.parse(resolvedUrl); + } on FormatException { + evict(); + chunkEvents.close(); + rethrow; + } + + // Create method to get bytes from server + Future<({Uint8List bytes, StreamedResponse response})> get({ + Map? additionalHeaders, + }) async { + final request = AbortableRequest('GET', uri, abortTrigger: abortTrigger); + + request.headers.addAll(headers); + if (additionalHeaders != null) request.headers.addAll(additionalHeaders); + + final response = await httpClient.send(request); + + final bytes = await consolidateStreamedResponseBytes( + response, + onBytesReceived: (cumulative, total) => chunkEvents.add( + ImageChunkEvent( + cumulativeBytesLoaded: cumulative, + expectedTotalBytes: total, + ), + ), + ); + + 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(resolvedUrl); + } on CachedMapTileReadFailure { + // This could occur due to a corrupt tile - we just try to overwrite it + // with fresh data + cachedTile = null; + } + } + + // Create method to write response to cache when applicable + void cachePut({ + required Uint8List? bytes, + required Map headers, + }) { + if (useFallback || !cachingProvider.isSupported) return; + cachingProvider.putTile( + url: resolvedUrl, + metadata: CachedMapTileMetadata.fromHttpHeaders(headers), + bytes: bytes, + ); + } + + // Main logic + // All `decodeBytes` 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 decodeBytes(cachedTile.bytes); + } catch (_) { + // 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.lastModified case final lastModified?) + HttpHeaders.ifModifiedSinceHeader: + HttpDate.format(lastModified), + if (cachedTile?.metadata.etag case 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 Codec decodedCacheBytes; + try { + decodedCacheBytes = await decodeBytes(cachedTile.bytes); + } catch (_) { + // 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 decodedCacheBytes; + } + } + + // Server says the image has changed - store it new + if (response.statusCode == HttpStatus.ok) { + cachePut(bytes: bytes, headers: response.headers); + return await decodeBytes(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: uri, + ); + } + evict(); + try { + return await decodeBytes(bytes); + } 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: uri, + ), + stackTrace, + ); + } + } on RequestAbortedException { + // This is a planned exception, we just quit silently + + evict(); + return await decodeBytes(TileProvider.transparentImage); + } on ClientException catch (err) { + // This could be a wide range of issues, potentially ours, potentially + // network, etc. + + evict(); + + // 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')) { + return await decodeBytes(TileProvider.transparentImage); + } + + if (useFallback || fallbackUrl == null) { + if (!silenceExceptions) rethrow; + return await decodeBytes(TileProvider.transparentImage); + } + return _loadImage(key, chunkEvents, decode, useFallback: true); + } catch (_) { + // Non-specific catch to catch decoding errors, the manually thrown HTTP + // exception, etc. + + evict(); + + if (useFallback || fallbackUrl == null) { + if (!silenceExceptions) rethrow; + return await decodeBytes(TileProvider.transparentImage); + } + return _loadImage(key, chunkEvents, decode, useFallback: true); + } + } + + @override + SynchronousFuture obtainKey( + ImageConfiguration configuration, + ) => + SynchronousFuture(this); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is NetworkTileImageProvider && + fallbackUrl == null && + other.fallbackUrl == null && + url == other.url); + + @override + int get hashCode => Object.hash(url, fallbackUrl); +} diff --git a/lib/src/layer/tile_layer/tile_provider/network/tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/network/tile_provider.dart new file mode 100644 index 000000000..3a678edc3 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_provider/network/tile_provider.dart @@ -0,0 +1,133 @@ +import 'dart:async'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart'; +import 'package:http/http.dart'; +import 'package:http/retry.dart'; + +/// [TileProvider] to fetch tiles from the network. +/// +/// By default, a [RetryClient] is used to retry failed requests. 'dart:http' +/// or 'dart:io' might be needed to override this. +/// +/// On the web, the 'User-Agent' header cannot be changed as specified in +/// [TileLayer.tileProvider]'s documentation, due to a Dart/browser limitation. +/// +/// Does not support cancellation of tile loading via +/// [TileProvider.getImageWithCancelLoadingSupport], as abortion of in-flight +/// HTTP requests on the web is +/// [not yet supported in Dart](https://github.com/dart-lang/http/issues/424). +class NetworkTileProvider extends TileProvider { + /// [TileProvider] to fetch tiles from the network. + /// + /// By default, a [RetryClient] is used to retry failed requests. 'dart:http' + /// or 'dart:io' might be needed to override this. + /// + /// On the web, the 'User-Agent' header cannot be changed, as specified in + /// [TileLayer.tileProvider]'s documentation, due to a Dart/browser limitation. + /// + /// Does not support cancellation of tile loading via + /// [TileProvider.getImageWithCancelLoadingSupport], as abortion of in-flight + /// HTTP requests on the web is + /// [not yet supported in Dart](https://github.com/dart-lang/http/issues/424). + NetworkTileProvider({ + super.headers, + Client? httpClient, + this.silenceExceptions = false, + this.attemptDecodeOfHttpErrorResponses = true, + this.abortObsoleteRequests = true, + this.cachingProvider, + }) : _isInternallyCreatedClient = httpClient == null, + _httpClient = httpClient ?? RetryClient(Client()); + + /// Whether to ignore exceptions and errors that occur whilst fetching tiles + /// over the network, and just return a transparent tile. + /// + /// Defaults to `false`. + final bool silenceExceptions; + + /// Whether to optimistically attempt to decode HTTP responses that have a + /// non-successful status code as an image + /// + /// If the decode is unsuccessful, the behaviour depends on + /// [silenceExceptions]. + /// + /// Defaults to `true`. + 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; + + /// Caching provider used to get cached tiles. + /// + /// See online documentation for more information about built-in caching. + /// + /// Defaults to [BuiltInMapCachingProvider]. Set to + /// [DisabledMapCachingProvider] to disable. + final MapCachingProvider? cachingProvider; + + /// Long living client used to make all tile requests by + /// [NetworkTileImageProvider] for the duration that this provider is + /// alive + /// + /// Not automatically closed if created externally and passed as an argument + /// during construction. + final Client _httpClient; + + /// Whether [_httpClient] was created on construction (and not passed in) + final bool _isInternallyCreatedClient; + + @override + bool get supportsCancelLoading => true; + + @override + ImageProvider getImageWithCancelLoadingSupport( + TileCoordinates coordinates, + TileLayer options, + Future cancelLoading, + ) => + NetworkTileImageProvider( + url: getTileUrl(coordinates, options), + fallbackUrl: getTileFallbackUrl(coordinates, options), + headers: headers, + httpClient: _httpClient, + abortTrigger: abortObsoleteRequests ? cancelLoading : null, + silenceExceptions: silenceExceptions, + attemptDecodeOfHttpErrorResponses: attemptDecodeOfHttpErrorResponses, + cachingProvider: cachingProvider, + ); + + @override + Future dispose() async { + if (_isInternallyCreatedClient) _httpClient.close(); + super.dispose(); + } +} diff --git a/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart b/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart deleted file mode 100644 index 0a2a3d02c..000000000 --- a/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:http/http.dart'; - -/// Dedicated [ImageProvider] to fetch tiles from the network -/// -/// Supports falling back to a secondary URL, if the primary URL fetch fails. -/// Note that specifying a [fallbackUrl] will prevent this image provider from -/// being cached. -@immutable -class MapNetworkImageProvider extends ImageProvider { - /// The URL to fetch the tile from (GET request) - final String url; - - /// The URL to fetch the tile from (GET request), in the event the original - /// [url] request fails - /// - /// If this is non-null, [operator==] will always return `false` (except if - /// the two objects are [identical]). Therefore, if this is non-null, this - /// image provider will not be cached in memory. - final String? fallbackUrl; - - /// The headers to include with the tile fetch request - /// - /// Not included in [operator==]. - final Map headers; - - /// The HTTP client to use to make network requests - /// - /// Not included in [operator==]. - final Client httpClient; - - /// Whether to ignore exceptions and errors that occur whilst fetching tiles - /// over the network, and just return a transparent tile - final bool silenceExceptions; - - /// Function invoked when the image starts loading (not from cache) - /// - /// Used with [finishedLoadingBytes] to safely dispose of the [httpClient] only - /// after all tiles have loaded. - final void Function() startedLoading; - - /// Function invoked when the image completes loading bytes from the network - /// - /// Used with [finishedLoadingBytes] to safely dispose of the [httpClient] only - /// after all tiles have loaded. - final void Function() finishedLoadingBytes; - - /// Create a dedicated [ImageProvider] to fetch tiles from the network - /// - /// Supports falling back to a secondary URL, if the primary URL fetch fails. - /// Note that specifying a [fallbackUrl] will prevent this image provider from - /// being cached. - const MapNetworkImageProvider({ - required this.url, - required this.fallbackUrl, - required this.headers, - required this.httpClient, - required this.silenceExceptions, - required this.startedLoading, - required this.finishedLoadingBytes, - }); - - @override - ImageStreamCompleter loadImage( - MapNetworkImageProvider key, - ImageDecoderCallback decode, - ) => - MultiFrameImageStreamCompleter( - codec: _load(key, decode), - scale: 1, - debugLabel: url, - informationCollector: () => [ - DiagnosticsProperty('URL', url), - DiagnosticsProperty('Fallback URL', fallbackUrl), - DiagnosticsProperty('Current provider', key), - ], - ); - - Future _load( - MapNetworkImageProvider key, - ImageDecoderCallback decode, { - bool useFallback = false, - }) { - startedLoading(); - - return httpClient - .readBytes( - Uri.parse(useFallback ? fallbackUrl ?? '' : url), - headers: headers, - ) - .whenComplete(finishedLoadingBytes) - .then(ImmutableBuffer.fromUint8List) - .then(decode) - .onError((err, stack) { - scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); - if (useFallback || fallbackUrl == null) { - if (!silenceExceptions) throw err; - return ImmutableBuffer.fromUint8List(TileProvider.transparentImage) - .then(decode); - } - return _load(key, decode, useFallback: true); - }); - } - - @override - SynchronousFuture obtainKey( - ImageConfiguration configuration, - ) => - SynchronousFuture(this); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is MapNetworkImageProvider && - fallbackUrl == null && - url == other.url); - - @override - int get hashCode => - Object.hashAll([url, if (fallbackUrl != null) fallbackUrl]); -} diff --git a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart deleted file mode 100644 index 8b9fd8ba8..000000000 --- a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; - -import 'package:flutter/rendering.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_image_provider.dart'; -import 'package:http/http.dart'; -import 'package:http/retry.dart'; - -/// [TileProvider] to fetch tiles from the network -/// -/// By default, a [RetryClient] is used to retry failed requests. 'dart:http' -/// or 'dart:io' might be needed to override this. -/// -/// On the web, the 'User-Agent' header cannot be changed as specified in -/// [TileLayer.tileProvider]'s documentation, due to a Dart/browser limitation. -/// -/// Does not support cancellation of tile loading via -/// [TileProvider.getImageWithCancelLoadingSupport], as abortion of in-flight -/// HTTP requests on the web is -/// [not yet supported in Dart](https://github.com/dart-lang/http/issues/424). -class NetworkTileProvider extends TileProvider { - /// [TileProvider] to fetch tiles from the network - /// - /// By default, a [RetryClient] is used to retry failed requests. 'dart:http' - /// or 'dart:io' might be needed to override this. - /// - /// On the web, the 'User-Agent' header cannot be changed, as specified in - /// [TileLayer.tileProvider]'s documentation, due to a Dart/browser limitation. - /// - /// Does not support cancellation of tile loading via - /// [TileProvider.getImageWithCancelLoadingSupport], as abortion of in-flight - /// HTTP requests on the web is - /// [not yet supported in Dart](https://github.com/dart-lang/http/issues/424). - NetworkTileProvider({ - super.headers, - Client? httpClient, - this.silenceExceptions = false, - }) : _isInternallyCreatedClient = httpClient == null, - _httpClient = httpClient ?? RetryClient(Client()); - - /// Whether to ignore exceptions and errors that occur whilst fetching tiles - /// over the network, and just return a transparent tile - final bool silenceExceptions; - - /// Long living client used to make all tile requests by - /// [MapNetworkImageProvider] for the duration that this provider is - /// alive - /// - /// Not automatically closed if created externally and passed as an argument - /// during construction. - final Client _httpClient; - - /// Whether [_httpClient] was created on construction (and not passed in) - final bool _isInternallyCreatedClient; - - /// Each [Completer] is completed once the corresponding tile has finished - /// loading - /// - /// Used to avoid disposing of [_httpClient] whilst HTTP requests are still - /// underway. - /// - /// Does not include tiles loaded from session cache. - final _tilesInProgress = HashMap>(); - - @override - ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => - MapNetworkImageProvider( - url: getTileUrl(coordinates, options), - fallbackUrl: getTileFallbackUrl(coordinates, options), - headers: headers, - httpClient: _httpClient, - silenceExceptions: silenceExceptions, - startedLoading: () => _tilesInProgress[coordinates] = Completer(), - finishedLoadingBytes: () { - _tilesInProgress[coordinates]?.complete(); - _tilesInProgress.remove(coordinates); - }, - ); - - @override - Future dispose() async { - if (_tilesInProgress.isNotEmpty) { - await Future.wait(_tilesInProgress.values.map((c) => c.future)); - } - if (_isInternallyCreatedClient) _httpClient.close(); - - super.dispose(); - } -} diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index ab9d3a4bf..a6a51e8a8 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -3,9 +3,9 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; +import 'package:flutter_map/src/misc/deg_rad_conversions.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:latlong2/latlong.dart'; -import 'package:vector_math/vector_math_64.dart'; /// Describes the view of a map. This includes the size/zoom/position/crs as /// well as the minimum/maximum zoom. This class is mostly immutable but has diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index d36c605aa..0ed0ed9b9 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -3,10 +3,10 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/gestures/map_interactive_viewer.dart'; +import 'package:flutter_map/src/misc/deg_rad_conversions.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; import 'package:latlong2/latlong.dart'; -import 'package:vector_math/vector_math_64.dart'; /// Implements [MapController] whilst exposing methods for internal use which /// should not be visible to the user (e.g. for setting the current camera). diff --git a/lib/src/misc/deg_rad_conversions.dart b/lib/src/misc/deg_rad_conversions.dart new file mode 100644 index 000000000..c31c7ea7c --- /dev/null +++ b/lib/src/misc/deg_rad_conversions.dart @@ -0,0 +1,7 @@ +import 'dart:math'; + +/// Constant factor to convert and angle from degrees to radians. +const double degrees2Radians = pi / 180.0; + +/// Constant factor to convert and angle from radians to degrees. +const double radians2Degrees = 180.0 / pi; diff --git a/pubspec.yaml b/pubspec.yaml index 3badb5085..ff86379d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,13 +31,15 @@ dependencies: dart_earcut: ^1.1.0 flutter: sdk: flutter - http: ^1.2.1 + http: ^1.5.0-beta latlong2: ^0.9.1 logger: ^2.0.0 meta: ^1.11.0 + path: ^1.9.1 + path_provider: ^2.1.5 polylabel: ^1.0.1 proj4dart: ^2.1.0 - vector_math: ^2.1.4 + uuid: ^4.5.1 dev_dependencies: flutter_lints: ">=4.0.0 <7.0.0" diff --git a/test/full_coverage_test.dart b/test/full_coverage_test.dart index 3e1f687bd..1f422fc07 100644 --- a/test/full_coverage_test.dart +++ b/test/full_coverage_test.dart @@ -30,12 +30,12 @@ import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image_view.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/asset/provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/file_providers/tile_provider_io.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/file_providers/tile_provider_stub.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_image_provider.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_tile_provider.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/file/native_tile_provider.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/file/stub_tile_provider.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/tile_provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; diff --git a/test/layer/tile_layer/tile_provider/network_image_provider_test.dart b/test/layer/tile_layer/tile_provider/network_image_provider_test.dart index 50a50779f..2001fb3b1 100644 --- a/test/layer/tile_layer/tile_provider/network_image_provider_test.dart +++ b/test/layer/tile_layer/tile_provider/network_image_provider_test.dart @@ -4,14 +4,15 @@ import 'dart:typed_data'; import 'package:flutter/painting.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_image_provider.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; import 'package:mocktail/mocktail.dart'; import '../../../test_utils/test_tile_image.dart'; -class MockHttpClient extends Mock implements BaseClient {} +class MockHttpClient extends Mock implements Client {} // Helper function to resolve the ImageInfo from the ImageProvider. Future getImageInfo(ImageProvider provider) { @@ -40,6 +41,9 @@ Uri randomUrl({bool fallback = false}) { } } +// TODO: Write tests to test aborting? +// TODO: Write tests to test built-in caching + void main() { const headers = { 'user-agent': 'flutter_map', @@ -50,45 +54,56 @@ void main() { final mockClient = MockHttpClient(); + NetworkTileImageProvider createDefaultImageProvider( + Uri url, { + Uri? fallbackUrl, + bool silenceExceptions = false, + }) => + NetworkTileImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl?.toString(), + headers: headers, + httpClient: mockClient, + abortTrigger: null, + silenceExceptions: silenceExceptions, + attemptDecodeOfHttpErrorResponses: true, + cachingProvider: const DisabledMapCachingProvider(), + ); + setUpAll(() { // Ensure the Mock library has example values for Uri. registerFallbackValue(Uri()); + registerFallbackValue(Request('GET', Uri())); }); // We expect a request to be made to the correct URL with the appropriate headers. testWidgets( 'Valid/expected response', (tester) async { - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async => testWhiteTileBytes); - - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - silenceExceptions: false, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, + when(() => mockClient.send(any())).thenAnswer( + (_) async => StreamedResponse(Stream.value(testWhiteTileBytes), 200), ); - expect(startedLoadingTriggered, false); + final url = randomUrl(); + final provider = createDefaultImageProvider(url); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNotNull); expect(img!.image.width, equals(256)); expect(img.image.height, equals(256)); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); @@ -96,415 +111,592 @@ void main() { // We expect the request to be made, and a HTTP ClientException to be bubbled // up to the caller. testWidgets( - 'Server failure - no fallback, exceptions enabled', + 'ClientException - no fallback, exceptions enabled', (tester) async { - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + when(() => mockClient.send(any())) .thenAnswer((_) async => throw ClientException('Server error')); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - silenceExceptions: false, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, - ); - - expect(startedLoadingTriggered, false); + final url = randomUrl(); + final provider = createDefaultImageProvider(url); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNull); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); testWidgets( - 'Server failure - no fallback, exceptions silenced', + 'ClientException - no fallback, exceptions silenced', (tester) async { - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + when(() => mockClient.send(any())) .thenAnswer((_) async => throw ClientException('Server error')); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - silenceExceptions: true, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, - ); - - expect(startedLoadingTriggered, false); + final url = randomUrl(); + final provider = createDefaultImageProvider(url, silenceExceptions: true); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNotNull); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); // We expect the regular URL to be called once, then the fallback URL. testWidgets( - 'Server failure - successful fallback, exceptions enabled', + 'ClientException - successful fallback', (tester) async { final url = randomUrl(); - when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) - .thenAnswer((_) async => throw ClientException('Server error')); + when( + () => mockClient.send( + any(that: isA().having((r) => r.url, 'URL', url))), + ).thenAnswer((_) async => throw ClientException('Server error')); final fallbackUrl = randomUrl(fallback: true); - when(() => - mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); - - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - silenceExceptions: false, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, + when( + () => mockClient.send(any( + that: isA().having((r) => r.url, 'URL', fallbackUrl))), + ).thenAnswer( + (_) async => StreamedResponse(Stream.value(testWhiteTileBytes), 200), ); - expect(startedLoadingTriggered, false); + final provider = + createDefaultImageProvider(url, fallbackUrl: fallbackUrl); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNotNull); expect(img!.image.width, equals(256)); expect(img.image.height, equals(256)); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) - .called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); testWidgets( - 'Server failure - successful fallback, exceptions silenced', + 'ClientException - failed fallback, exceptions enabled', (tester) async { - final url = randomUrl(); - when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + when(() => mockClient.send(any())) .thenAnswer((_) async => throw ClientException('Server error')); + final url = randomUrl(); final fallbackUrl = randomUrl(fallback: true); - when(() => - mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); + final provider = + createDefaultImageProvider(url, fallbackUrl: fallbackUrl); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; + final img = await tester.runAsync(() => getImageInfo(provider)); - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, + expect(img, isNull); + expect(tester.takeException(), isInstanceOf()); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'ClientException - failed fallback, exceptions silenced', + (tester) async { + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + final provider = createDefaultImageProvider( + url, + fallbackUrl: fallbackUrl, silenceExceptions: true, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, ); - expect(startedLoadingTriggered, false); - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNotNull); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) - .called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); testWidgets( - 'Server failure - failed fallback, exceptions enabled', + 'HTTP errstatus - no fallback, exceptions enabled', (tester) async { + when(() => mockClient.send(any())) + .thenAnswer((_) async => StreamedResponse(const Stream.empty(), 400)); + final url = randomUrl(); - final fallbackUrl = randomUrl(fallback: true); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async => throw ClientException('Server error')); + final provider = createDefaultImageProvider(url); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; + final img = await tester.runAsync(() => getImageInfo(provider)); - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - silenceExceptions: false, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, - ); + expect(img, isNull); + expect(tester.takeException(), isInstanceOf()); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + }, + timeout: defaultTimeout, + ); + testWidgets( + 'HTTP errstatus - no fallback, exceptions silenced', + (tester) async { + when(() => mockClient.send(any())) + .thenAnswer((_) async => StreamedResponse(const Stream.empty(), 400)); - expect(startedLoadingTriggered, false); + final url = randomUrl(); + final provider = createDefaultImageProvider(url, silenceExceptions: true); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - - expect(img, isNull); - expect(tester.takeException(), isInstanceOf()); + expect(img, isNotNull); + expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) - .called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); testWidgets( - 'Server failure - failed fallback, exceptions silenced', + 'HTTP errstatus - successful fallback', (tester) async { final url = randomUrl(); - final fallbackUrl = randomUrl(fallback: true); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async => throw ClientException('Server error')); + when( + () => mockClient.send( + any(that: isA().having((r) => r.url, 'URL', url))), + ).thenAnswer((_) async => StreamedResponse(const Stream.empty(), 400)); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - silenceExceptions: true, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, + final fallbackUrl = randomUrl(fallback: true); + when( + () => mockClient.send(any( + that: isA().having((r) => r.url, 'URL', fallbackUrl))), + ).thenAnswer( + (_) async => StreamedResponse(Stream.value(testWhiteTileBytes), 200), ); - expect(startedLoadingTriggered, false); + final provider = + createDefaultImageProvider(url, fallbackUrl: fallbackUrl); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) - .called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); testWidgets( - 'Non-image response - no fallback, exceptions enabled', + 'HTTP errstatus - failed fallback, exceptions enabled', (tester) async { - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - // 200 OK with html - return Uint8List.fromList(utf8.encode('Server Error')); - }); - - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - silenceExceptions: false, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, - ); + when(() => mockClient.send(any())) + .thenAnswer((_) async => StreamedResponse(const Stream.empty(), 400)); - expect(startedLoadingTriggered, false); + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + final provider = + createDefaultImageProvider(url, fallbackUrl: fallbackUrl); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNull); - final exception = tester.takeException(); - expect(exception, isInstanceOf()); - expect( - (exception as Exception).toString(), - equals('Exception: Invalid image data'), - ); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); + expect(tester.takeException(), isInstanceOf()); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); testWidgets( - 'Non-image response - no fallback, exceptions silenced', + 'HTTP errstatus - failed fallback, exceptions silenced', (tester) async { - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - // 200 OK with html - return Uint8List.fromList(utf8.encode('Server Error')); - }); + when(() => mockClient.send(any())) + .thenAnswer((_) async => StreamedResponse(const Stream.empty(), 400)); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + final provider = createDefaultImageProvider( + url, + fallbackUrl: fallbackUrl, silenceExceptions: true, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, ); - expect(startedLoadingTriggered, false); - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNotNull); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); testWidgets( - 'Non-image response - successful fallback, exceptions enabled', + 'HTTP errstatus with image - optimistic decode enabled', (tester) async { + when(() => mockClient.send(any())).thenAnswer( + (_) async => StreamedResponse( + Stream.value(testWhiteTileBytes), + 400, + ), + ); + final url = randomUrl(); - when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) - .thenAnswer((_) async { - // 200 OK with html - return Uint8List.fromList(utf8.encode('Server Error')); - }); + final provider = createDefaultImageProvider(url); - final fallbackUrl = randomUrl(fallback: true); - when(() => - mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); + expect(tester.takeException(), isInstanceOf()); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + }, + ); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; + testWidgets( + 'HTTP errstatus with image - optimistic decode disabled', + (tester) async { + when(() => mockClient.send(any())).thenAnswer( + (_) async => StreamedResponse( + Stream.value(testWhiteTileBytes), + 400, + ), + ); - final provider = MapNetworkImageProvider( + final url = randomUrl(); + final provider = NetworkTileImageProvider( url: url.toString(), - fallbackUrl: fallbackUrl.toString(), + fallbackUrl: null, headers: headers, httpClient: mockClient, + abortTrigger: null, silenceExceptions: false, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, + attemptDecodeOfHttpErrorResponses: false, + cachingProvider: const DisabledMapCachingProvider(), + ); + + final img = await tester.runAsync(() => getImageInfo(provider)); + + expect(img, isNull); + expect(tester.takeException(), isInstanceOf()); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + }, + ); + + testWidgets( + 'Non-image response - no fallback, exceptions enabled', + (tester) async { + when(() => mockClient.send(any())).thenAnswer( + (_) async => StreamedResponse( + Stream.value( + Uint8List.fromList(utf8.encode('Server Error'))), + 200, + ), ); - expect(startedLoadingTriggered, false); + final url = randomUrl(); + final provider = createDefaultImageProvider(url); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); + expect(img, isNull); + final exception = tester.takeException(); + expect(exception, isInstanceOf()); + expect( + (exception as Exception).toString(), + equals('Exception: Invalid image data'), + ); + + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - no fallback, exceptions silenced', + (tester) async { + when(() => mockClient.send(any())).thenAnswer( + (_) async => StreamedResponse( + Stream.value( + Uint8List.fromList(utf8.encode('Server Error'))), + 200, + ), + ); + + final url = randomUrl(); + final provider = createDefaultImageProvider( + url, + silenceExceptions: true, + ); + + final img = await tester.runAsync(() => getImageInfo(provider)); expect(img, isNotNull); - expect(img!.image.width, equals(256)); - expect(img.image.height, equals(256)); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) - .called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); testWidgets( - 'Non-image response - successful fallback, exceptions silenced', + 'Non-image response - successful fallback', (tester) async { final url = randomUrl(); - when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) - .thenAnswer((_) async { - // 200 OK with html - return Uint8List.fromList(utf8.encode('Server Error')); - }); + when(() => mockClient.send( + any(that: isA().having((r) => r.url, 'URL', url)))) + .thenAnswer( + (_) async => StreamedResponse( + Stream.value( + Uint8List.fromList(utf8.encode('Server Error'))), + 200, + ), + ); final fallbackUrl = randomUrl(fallback: true); - when(() => - mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); - - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - silenceExceptions: false, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, + when( + () => mockClient.send(any( + that: isA().having((r) => r.url, 'URL', fallbackUrl))), + ).thenAnswer( + (_) async => StreamedResponse(Stream.value(testWhiteTileBytes), 200), ); - expect(startedLoadingTriggered, false); + final provider = createDefaultImageProvider( + url, + fallbackUrl: fallbackUrl, + ); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) - .called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); @@ -520,26 +712,11 @@ void main() { return Uint8List.fromList(utf8.encode('Server Error')); }); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - silenceExceptions: false, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, - ); - - expect(startedLoadingTriggered, false); + final provider = + createDefaultImageProvider(url, fallbackUrl: fallbackUrl); final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNull); final exception = tester.takeException(); expect(exception, isInstanceOf()); @@ -548,9 +725,26 @@ void main() { equals('Exception: Invalid image data'), ); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) - .called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, ); @@ -566,32 +760,37 @@ void main() { return Uint8List.fromList(utf8.encode('Server Error')); }); - bool startedLoadingTriggered = false; - bool finishedLoadingTriggered = false; - - final provider = MapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, + final provider = createDefaultImageProvider( + url, + fallbackUrl: fallbackUrl, silenceExceptions: true, - startedLoading: () => startedLoadingTriggered = true, - finishedLoadingBytes: () => finishedLoadingTriggered = true, ); - expect(startedLoadingTriggered, false); - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(startedLoadingTriggered, true); - expect(finishedLoadingTriggered, true); - expect(img, isNotNull); expect(tester.takeException(), isInstanceOf()); - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) - .called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', url) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); + verify( + () => mockClient.send( + captureAny( + that: isA() + .having((r) => r.url, 'URL', fallbackUrl) + .having((r) => r.method, 'method', 'GET') + .having((r) => r.headers, 'headers', equals(headers)), + ), + ), + ).called(1); }, timeout: defaultTimeout, );