From 11b43e1704ed668999f7d01945b396b9a174488c Mon Sep 17 00:00:00 2001 From: Luka S Date: Wed, 6 Dec 2023 18:15:21 +0000 Subject: [PATCH] refactor!: multiple fixes & additions to `NetworkTileProvider` (and underlying `ImageProvider`) (#1742) --- analysis_options.yaml | 1 - lib/src/layer/marker_layer.dart | 1 - lib/src/layer/tile_layer/tile_image.dart | 1 - .../tile_provider/base_tile_provider.dart | 3 + .../tile_provider/network_image_provider.dart | 104 +-- .../tile_provider/network_tile_provider.dart | 40 +- .../network_image_provider_test.dart | 714 +++++++++++++----- 7 files changed, 641 insertions(+), 223 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 285cf1fb5..14a938d0e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -36,7 +36,6 @@ linter: noop_primitive_operations: true avoid_void_async: true avoid_redundant_argument_values: true - avoid_types_on_closure_parameters: true unnecessary_null_checks: true prefer_single_quotes: true unnecessary_parenthesis: true diff --git a/lib/src/layer/marker_layer.dart b/lib/src/layer/marker_layer.dart index 6b8e231a3..e5365f28f 100644 --- a/lib/src/layer/marker_layer.dart +++ b/lib/src/layer/marker_layer.dart @@ -104,7 +104,6 @@ class MarkerLayer extends StatelessWidget { return MobileLayerTransformer( child: Stack( - // ignore: avoid_types_on_closure_parameters children: (List markers) sync* { for (final m in markers) { // Resolve real alignment diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index cb8502001..93ce0b543 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -222,7 +222,6 @@ class TileImage extends ChangeNotifier { if (evictImageFromCache) { try { - // ignore: avoid_types_on_closure_parameters imageProvider.evict().catchError((Object e) { debugPrint(e.toString()); return false; diff --git a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart index 3683386b0..805dd0e5e 100644 --- a/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/base_tile_provider.dart @@ -124,6 +124,9 @@ abstract class TileProvider { } /// Called when the [TileLayer] is disposed + /// + /// When disposing resources, ensure that they are not currently being used + /// by tiles in progress. void dispose() {} /// Regex that describes the format of placeholders in a `urlTemplate` 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 index c98f81605..126a7b842 100644 --- a/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart @@ -3,6 +3,7 @@ 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 @@ -24,15 +25,31 @@ class FlutterMapNetworkImageProvider /// 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 BaseClient httpClient; - /// The headers to include with the tile fetch request + /// 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) /// - /// Not included in [operator==]. - final Map headers; + /// 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 /// @@ -44,60 +61,59 @@ class FlutterMapNetworkImageProvider required this.fallbackUrl, required this.headers, required this.httpClient, + required this.silenceExceptions, + required this.startedLoading, + required this.finishedLoadingBytes, }); @override ImageStreamCompleter loadImage( FlutterMapNetworkImageProvider key, ImageDecoderCallback decode, - ) { - final chunkEvents = StreamController(); - - return MultiFrameImageStreamCompleter( - codec: _loadAsync(key, chunkEvents, decode), - chunkEvents: chunkEvents.stream, - scale: 1, - debugLabel: url, - informationCollector: () => [ - DiagnosticsProperty('URL', url), - DiagnosticsProperty('Fallback URL', fallbackUrl), - DiagnosticsProperty('Current provider', key), - ], - ); - } - - @override - Future obtainKey( - ImageConfiguration configuration, ) => - SynchronousFuture(this); + MultiFrameImageStreamCompleter( + codec: _load(key, decode), + scale: 1, + debugLabel: url, + informationCollector: () => [ + DiagnosticsProperty('URL', url), + DiagnosticsProperty('Fallback URL', fallbackUrl), + DiagnosticsProperty('Current provider', key), + ], + ); - Future _loadAsync( + Future _load( FlutterMapNetworkImageProvider key, - StreamController chunkEvents, ImageDecoderCallback decode, { bool useFallback = false, - }) async { - try { - return decode( - await ImmutableBuffer.fromUint8List( - await httpClient.readBytes( - Uri.parse(useFallback ? fallbackUrl ?? '' : url), - headers: headers, - ), - ), - ).catchError((dynamic e) { - // ignore: only_throw_errors - if (useFallback || fallbackUrl == null) throw e as Object; - return _loadAsync(key, chunkEvents, decode, useFallback: true); - }); - } catch (_) { - // This redundancy necessary, do not remove - if (useFallback || fallbackUrl == null) rethrow; - return _loadAsync(key, chunkEvents, decode, useFallback: true); - } + }) { + 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) || 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 index 208892787..ee43bb94a 100644 --- a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:collection'; + import 'package:flutter/rendering.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; @@ -34,10 +37,26 @@ class NetworkTileProvider extends TileProvider { NetworkTileProvider({ super.headers, BaseClient? httpClient, - }) : httpClient = httpClient ?? RetryClient(Client()); + this.silenceExceptions = false, + }) : _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 + /// [FlutterMapNetworkImageProvider] for the duration that this provider is + /// alive + final BaseClient _httpClient; - /// The HTTP client used to make network requests for tiles - final BaseClient httpClient; + /// 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) => @@ -45,12 +64,21 @@ class NetworkTileProvider extends TileProvider { url: getTileUrl(coordinates, options), fallbackUrl: getTileFallbackUrl(coordinates, options), headers: headers, - httpClient: httpClient, + httpClient: _httpClient, + silenceExceptions: silenceExceptions, + startedLoading: () => _tilesInProgress[coordinates] = Completer(), + finishedLoadingBytes: () { + _tilesInProgress[coordinates]?.complete(); + _tilesInProgress.remove(coordinates); + }, ); @override - void dispose() { - httpClient.close(); + Future dispose() async { + if (_tilesInProgress.isNotEmpty) { + await Future.wait(_tilesInProgress.values.map((c) => c.future)); + } + _httpClient.close(); super.dispose(); } } 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 53b22a30a..c2d296e43 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 @@ -17,17 +17,12 @@ class MockHttpClient extends Mock implements BaseClient {} Future getImageInfo(ImageProvider provider) { final completer = Completer(); - final ImageStream stream = provider.resolve(ImageConfiguration.empty); - stream.addListener( - ImageStreamListener( - (imageInfo, _) { - return completer.complete(imageInfo); - }, - onError: (exception, stackTrace) { - return completer.completeError(exception, stackTrace); - }, - ), - ); + provider.resolve(ImageConfiguration.empty).addListener( + ImageStreamListener( + (imageInfo, _) => completer.complete(imageInfo), + onError: completer.completeError, + ), + ); return completer.future; } @@ -52,174 +47,553 @@ void main() { const defaultTimeout = Timeout(Duration(seconds: 1)); + final mockClient = MockHttpClient(); + setUpAll(() { // Ensure the Mock library has example values for Uri. registerFallbackValue(Uri()); }); // We expect a request to be made to the correct URL with the appropriate headers. - testWidgets('test load with correct url/headers', (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNotNull); - expect(img!.image.width, equals(256)); - expect(img.image.height, equals(256)); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - }, timeout: defaultTimeout); + 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 = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + 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(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, + timeout: defaultTimeout, + ); // We expect the request to be made, and a HTTP ClientException to be bubbled // up to the caller. - testWidgets('test load with server failure (no fallback)', (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - throw ClientException( - 'Server error', - ); - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNull); - expect(tester.takeException(), isInstanceOf()); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - }, timeout: defaultTimeout); + testWidgets( + 'Server failure - no fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + 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); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Server failure - no fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + 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); + }, + timeout: defaultTimeout, + ); // We expect the regular URL to be called once, then the fallback URL. - testWidgets('test load with server error (with successful fallback)', - (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) - .thenAnswer((_) async { - throw ClientException( - 'Server error', - ); - }); - final fallbackUrl = randomUrl(fallback: true); - when(() => - mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNotNull); - expect(img!.image.width, equals(256)); - expect(img.image.height, equals(256)); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); - }, timeout: defaultTimeout); - - testWidgets('test load with server error (with failed fallback)', - (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - final fallbackUrl = randomUrl(fallback: true); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - throw ClientException( - 'Server error', - ); - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNull); - expect(tester.takeException(), isInstanceOf()); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); - }, timeout: defaultTimeout); - - testWidgets('test load with invalid response (no fallback)', (tester) async { - final mockClient = MockHttpClient(); - final url = randomUrl(); - when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) - .thenAnswer((_) async { - // 200 OK with html - return Uint8List.fromList(utf8.encode('Server Error')); - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: null, - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNull); - expect(tester.takeException(), isInstanceOf()); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - }, timeout: defaultTimeout); - - testWidgets('test load with invalid response (with successful fallback)', - (tester) async { - final mockClient = MockHttpClient(); - 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 fallbackUrl = randomUrl(fallback: true); - when(() => - mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) - .thenAnswer((_) async { - return testWhiteTileBytes; - }); - - final provider = FlutterMapNetworkImageProvider( - url: url.toString(), - fallbackUrl: fallbackUrl.toString(), - headers: headers, - httpClient: mockClient, - ); - - final img = await tester.runAsync(() => getImageInfo(provider)); - expect(img, isNotNull); - expect(img!.image.width, equals(256)); - expect(img.image.height, equals(256)); - - verify(() => mockClient.readBytes(url, headers: headers)).called(1); - verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); - }, timeout: defaultTimeout); + testWidgets( + 'Server failure - successful fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + .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 = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + 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(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); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Server failure - successful fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + .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 = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + 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); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Server failure - failed fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + 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.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Server failure - failed fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => throw ClientException('Server error')); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + 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); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - no 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 = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + 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); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - no 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')); + }); + + bool startedLoadingTriggered = false; + bool finishedLoadingTriggered = false; + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + 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); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - successful fallback, exceptions enabled', + (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')); + }); + + 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 = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + 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(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); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - successful fallback, exceptions silenced', + (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')); + }); + + 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 = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + 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); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - failed fallback, exceptions enabled', + (tester) async { + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + 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 = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + silenceExceptions: false, + startedLoading: () => startedLoadingTriggered = true, + finishedLoadingBytes: () => finishedLoadingTriggered = true, + ); + + expect(startedLoadingTriggered, false); + + 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); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)) + .called(1); + }, + timeout: defaultTimeout, + ); + + testWidgets( + 'Non-image response - failed fallback, exceptions silenced', + (tester) async { + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + 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 = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + 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); + }, + timeout: defaultTimeout, + ); + + tearDownAll(() => mockClient.close()); }