From b12bdd0ea13619903b9a56164c69e7e62410b723 Mon Sep 17 00:00:00 2001 From: Alexander Aprelev Date: Wed, 18 Sep 2019 20:38:54 -0700 Subject: [PATCH] Use separate isolate for image loading. (#34188) * Use separate isolate for image loading. Use TransferableTypedData to const-cost receive bytes from that isolate. --- .../src/foundation/consolidate_response.dart | 72 ++-- .../lib/src/painting/_network_image_io.dart | 242 +++++++++++--- .../lib/src/services/asset_bundle.dart | 5 +- .../foundation/consolidate_response_test.dart | 29 +- .../test/painting/image_provider_test.dart | 310 ++++++++++++++---- .../test/widgets/image_headers_test.dart | 53 --- 6 files changed, 520 insertions(+), 191 deletions(-) delete mode 100644 packages/flutter/test/widgets/image_headers_test.dart diff --git a/packages/flutter/lib/src/foundation/consolidate_response.dart b/packages/flutter/lib/src/foundation/consolidate_response.dart index 3c64e19d7e9..d274cb36afb 100644 --- a/packages/flutter/lib/src/foundation/consolidate_response.dart +++ b/packages/flutter/lib/src/foundation/consolidate_response.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; /// Signature for getting notified when chunks of bytes are received while @@ -22,11 +23,11 @@ import 'dart:typed_data'; /// returned to the client and the total size of the response may not be known /// until the request has been fully processed). /// -/// This is used in [consolidateHttpClientResponseBytes]. +/// This is used in [getHttpClientResponseBytes]. typedef BytesReceivedCallback = void Function(int cumulative, int total); /// Efficiently converts the response body of an [HttpClientResponse] into a -/// [Uint8List]. +/// [TransferableTypedData]. /// /// The future returned will forward any error emitted by `response`. /// @@ -43,13 +44,13 @@ typedef BytesReceivedCallback = void Function(int cumulative, int total); /// bytes from this method (assuming the response is sending compressed bytes), /// set both [HttpClient.autoUncompress] to false and the `autoUncompress` /// parameter to false. -Future consolidateHttpClientResponseBytes( +Future getHttpClientResponseBytes( HttpClientResponse response, { bool autoUncompress = true, BytesReceivedCallback onBytesReceived, }) { assert(autoUncompress != null); - final Completer completer = Completer.sync(); + final Completer completer = Completer.sync(); final _OutputBuffer output = _OutputBuffer(); ByteConversionSink sink = output; @@ -89,41 +90,54 @@ Future consolidateHttpClientResponseBytes( } }, onDone: () { sink.close(); - completer.complete(output.bytes); + completer.complete(TransferableTypedData.fromList(output.chunks)); }, onError: completer.completeError, cancelOnError: true); return completer.future; } +/// Efficiently converts the response body of an [HttpClientResponse] into a +/// [Uint8List]. +/// +/// (This method is deprecated - use [getHttpClientResponseBytes] instead.) +/// +/// The future returned will forward any error emitted by `response`. +/// +/// The `onBytesReceived` callback, if specified, will be invoked for every +/// chunk of bytes that is received while consolidating the response bytes. +/// If the callback throws an error, processing of the response will halt, and +/// the returned future will complete with the error that was thrown by the +/// callback. For more information on how to interpret the parameters to the +/// callback, see the documentation on [BytesReceivedCallback]. +/// +/// If the `response` is gzipped and the `autoUncompress` parameter is true, +/// this will automatically un-compress the bytes in the returned list if it +/// hasn't already been done via [HttpClient.autoUncompress]. To get compressed +/// bytes from this method (assuming the response is sending compressed bytes), +/// set both [HttpClient.autoUncompress] to false and the `autoUncompress` +/// parameter to false. +@Deprecated('Use getHttpClientResponseBytes instead') +Future consolidateHttpClientResponseBytes( + HttpClientResponse response, { + bool autoUncompress = true, + BytesReceivedCallback onBytesReceived, +}) async { + final TransferableTypedData bytes = await getHttpClientResponseBytes( + response, + autoUncompress: autoUncompress, + onBytesReceived: onBytesReceived, + ); + return bytes.materialize().asUint8List(); +} + class _OutputBuffer extends ByteConversionSinkBase { - List> _chunks = >[]; - int _contentLength = 0; - Uint8List _bytes; + final List chunks = []; @override void add(List chunk) { - assert(_bytes == null); - _chunks.add(chunk); - _contentLength += chunk.length; + chunks.add(chunk); } @override - void close() { - if (_bytes != null) { - // We've already been closed; this is a no-op - return; - } - _bytes = Uint8List(_contentLength); - int offset = 0; - for (List chunk in _chunks) { - _bytes.setRange(offset, offset + chunk.length, chunk); - offset += chunk.length; - } - _chunks = null; - } - - Uint8List get bytes { - assert(_bytes != null); - return _bytes; - } + void close() {} } diff --git a/packages/flutter/lib/src/painting/_network_image_io.dart b/packages/flutter/lib/src/painting/_network_image_io.dart index 2b2c4a72cfc..5ee977827d5 100644 --- a/packages/flutter/lib/src/painting/_network_image_io.dart +++ b/packages/flutter/lib/src/painting/_network_image_io.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -14,7 +15,7 @@ import 'debug.dart'; import 'image_provider.dart' as image_provider; import 'image_stream.dart'; -/// The dart:io implemenation of [image_provider.NetworkImage]. +/// The dart:io implementation of [image_provider.NetworkImage]. class NetworkImage extends image_provider.ImageProvider implements image_provider.NetworkImage { /// Creates an object that fetches the image at the given URL. /// @@ -39,9 +40,9 @@ class NetworkImage extends image_provider.ImageProvider chunkEvents = StreamController(); return MultiFrameImageStreamCompleter( @@ -57,63 +58,138 @@ class NetworkImage extends image_provider.ImageProvider _pendingLoader; + static RawReceivePort _loaderErrorHandler; + static List<_DownloadRequest> _pendingLoadRequests; + static SendPort _requestPort; Future _loadAsync( NetworkImage key, StreamController chunkEvents, ) async { + RawReceivePort downloadResponseHandler; try { assert(key == this); final Uri resolved = Uri.base.resolve(key.url); - final HttpClientRequest request = await _httpClient.getUrl(resolved); - headers?.forEach((String name, String value) { - request.headers.add(name, value); - }); - final HttpClientResponse response = await request.close(); - if (response.statusCode != HttpStatus.ok) - throw image_provider.NetworkImageLoadException(statusCode: response.statusCode, uri: resolved); - final Uint8List bytes = await consolidateHttpClientResponseBytes( - response, - onBytesReceived: (int cumulative, int total) { - chunkEvents.add(ImageChunkEvent( - cumulativeBytesLoaded: cumulative, - expectedTotalBytes: total, - )); - }, + final Completer bytesCompleter = Completer(); + downloadResponseHandler = RawReceivePort((_DownloadResponse response) { + if (response.bytes != null) { + if (bytesCompleter.isCompleted) { + // If an uncaught error occurred in the worker isolate, we'll have + // already completed our bytes completer. + return; + } + bytesCompleter.complete(response.bytes); + } else if (response.chunkEvent != null) { + chunkEvents.add(response.chunkEvent); + } else if (response.error != null) { + bytesCompleter.completeError(response.error); + } else { + assert(false); + } + }); + + // This will keep references to [debugNetworkImageHttpClientProvider] tree-shaken + // out of release builds. + HttpClientProvider httpClientProvider; + assert(() { httpClientProvider = debugNetworkImageHttpClientProvider; return true; }()); + + final _DownloadRequest downloadRequest = _DownloadRequest( + downloadResponseHandler.sendPort, + resolved, + headers, + httpClientProvider, ); - if (bytes.lengthInBytes == 0) + if (_requestPort != null) { + // If worker isolate is properly set up ([_requestPort] is holding + // initialized [SendPort]), then just send download request down to it. + _requestPort.send(downloadRequest); + } else { + if (_pendingLoader == null) { + // If worker isolate creation was not started, start creation now. + _spawnAndSetupIsolate(); + } + // Record download request so it can either send a request when isolate is ready or handle errors. + _pendingLoadRequests.add(downloadRequest); + } + + final TransferableTypedData transferable = await bytesCompleter.future; + + final Uint8List bytes = transferable.materialize().asUint8List(); + if (bytes.isEmpty) throw Exception('NetworkImage is an empty file: $resolved'); return PaintingBinding.instance.instantiateImageCodec(bytes); } finally { chunkEvents.close(); + downloadResponseHandler?.close(); } } + void _spawnAndSetupIsolate() { + assert(_pendingLoadRequests == null); + assert(_loaderErrorHandler == null); + assert(_pendingLoader == null); + _pendingLoadRequests = <_DownloadRequest>[]; + _pendingLoader = _spawnIsolate()..then((Isolate isolate) { + _loaderErrorHandler = RawReceivePort((List errorAndStackTrace) { + _cleanupDueToError(errorAndStackTrace[0]); + }); + isolate.addErrorListener(_loaderErrorHandler.sendPort); + isolate.resume(isolate.pauseCapability); + }).catchError((dynamic error, StackTrace stackTrace) { + _cleanupDueToError(error); + }); + } + + void _cleanupDueToError(dynamic error) { + for (_DownloadRequest request in _pendingLoadRequests) { + request.handleError(error); + } + _pendingLoadRequests = null; + _pendingLoader = null; + _loaderErrorHandler.close(); + _loaderErrorHandler = null; + } + + Future _spawnIsolate() { + // Once worker isolate is up and running it sends it's [sendPort] over + // [communicationBootstrapHandler] receive port. + // If [sendPort] is [null], it indicates that worker isolate exited after + // being idle. + final RawReceivePort communicationBootstrapHandler = RawReceivePort((SendPort sendPort) { + _requestPort = sendPort; + if (sendPort == null) { + assert(_pendingLoadRequests.isEmpty); + _pendingLoader = null; + _pendingLoadRequests = null; + _loaderErrorHandler.close(); + _loaderErrorHandler = null; + return; + } + + // When we received [SendPort] for the worker isolate, we send all + // pending requests that were accumulated before worker isolate provided + // it's port (before [_requestPort] was populated). + _pendingLoadRequests.forEach(sendPort.send); + _pendingLoadRequests.clear(); + }); + + return Isolate.spawn(_initializeWorkerIsolate, + communicationBootstrapHandler.sendPort, paused: true); + } + @override bool operator ==(dynamic other) { if (other.runtimeType != runtimeType) return false; final NetworkImage typedOther = other; - return url == typedOther.url - && scale == typedOther.scale; + return url == typedOther.url && scale == typedOther.scale; } @override @@ -122,3 +198,95 @@ class NetworkImage extends image_provider.ImageProvider '$runtimeType("$url", scale: $scale)'; } + +@immutable +class _DownloadResponse { + const _DownloadResponse.bytes(this.bytes) : assert(bytes != null), chunkEvent = null, error = null; + const _DownloadResponse.chunkEvent(this.chunkEvent) : assert(chunkEvent != null), bytes = null, error = null; + const _DownloadResponse.error(this.error) : assert(error != null), bytes = null, chunkEvent = null; + + final TransferableTypedData bytes; + final ImageChunkEvent chunkEvent; + final dynamic error; +} + +@immutable +class _DownloadRequest { + const _DownloadRequest(this.sendPort, this.uri, this.headers, this.httpClientProvider) : + assert(sendPort != null), assert(uri != null); + + final SendPort sendPort; + final Uri uri; + final Map headers; + final HttpClientProvider httpClientProvider; + + void handleError(dynamic error) { sendPort.send(_DownloadResponse.error(error)); } +} + +// We set `autoUncompress` to false to ensure that we can trust the value of +// the `Content-Length` HTTP header. We automatically uncompress the content +// in our call to [getHttpClientResponseBytes]. +final HttpClient _sharedHttpClient = HttpClient()..autoUncompress = false; +const Duration _idleDuration = Duration(seconds: 60); + +/// Sets up the worker isolate to listen for incoming [_DownloadRequest]s from +/// the main isolate. +/// +/// This method runs on a worker isolate. +/// +/// The `handshakeSendPort` argument is this worker isolate's communications +/// link back to the main isolate. It is used to set-up the channel with which +/// the main isolate sends download requests to the worker isolate. +void _initializeWorkerIsolate(SendPort handshakeSendPort) { + int ongoingRequests = 0; + Timer idleTimer; + RawReceivePort downloadRequestHandler; + + // Sets up a handler that processes download requests messages. + downloadRequestHandler = RawReceivePort((_DownloadRequest downloadRequest) async { + ongoingRequests++; + idleTimer?.cancel(); + final HttpClient httpClient = downloadRequest.httpClientProvider != null + ? downloadRequest.httpClientProvider() + : _sharedHttpClient; + + try { + final HttpClientRequest request = await httpClient.getUrl(downloadRequest.uri); + downloadRequest.headers?.forEach((String name, String value) { + request.headers.add(name, value); + }); + final HttpClientResponse response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw image_provider.NetworkImageLoadException( + statusCode: response?.statusCode, + uri: downloadRequest.uri, + ); + } + final TransferableTypedData transferable = await getHttpClientResponseBytes( + response, + onBytesReceived: (int cumulative, int total) { + downloadRequest.sendPort.send(_DownloadResponse.chunkEvent( + ImageChunkEvent( + cumulativeBytesLoaded: cumulative, + expectedTotalBytes: total, + ), + )); + }, + ); + downloadRequest.sendPort.send(_DownloadResponse.bytes(transferable)); + } catch (error) { + downloadRequest.sendPort.send(_DownloadResponse.error(error)); + } + ongoingRequests--; + if (ongoingRequests == 0) { + idleTimer = Timer(_idleDuration, () { + assert(ongoingRequests == 0); + // [null] indicates that worker is going down. + handshakeSendPort.send(null); + downloadRequestHandler.close(); + }); + } + }); + + handshakeSendPort.send(downloadRequestHandler.sendPort); +} diff --git a/packages/flutter/lib/src/services/asset_bundle.dart b/packages/flutter/lib/src/services/asset_bundle.dart index de2bc054961..7367f20ec37 100644 --- a/packages/flutter/lib/src/services/asset_bundle.dart +++ b/packages/flutter/lib/src/services/asset_bundle.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; @@ -120,8 +121,8 @@ class NetworkAssetBundle extends AssetBundle { 'Unable to load asset: $key\n' 'HTTP status code: ${response.statusCode}' ); - final Uint8List bytes = await consolidateHttpClientResponseBytes(response); - return bytes.buffer.asByteData(); + final TransferableTypedData transferable = await getHttpClientResponseBytes(response); + return transferable.materialize().asByteData(); } /// Retrieve a string from the asset bundle, parse it with the given function, diff --git a/packages/flutter/test/foundation/consolidate_response_test.dart b/packages/flutter/test/foundation/consolidate_response_test.dart index 428708cf5e2..3af76f76cf6 100644 --- a/packages/flutter/test/foundation/consolidate_response_test.dart +++ b/packages/flutter/test/foundation/consolidate_response_test.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; @@ -14,7 +15,7 @@ import 'package:mockito/mockito.dart'; import '../flutter_test_alternative.dart'; void main() { - group(consolidateHttpClientResponseBytes, () { + group(getHttpClientResponseBytes, () { final Uint8List chunkOne = Uint8List.fromList([0, 1, 2, 3, 4, 5]); final Uint8List chunkTwo = Uint8List.fromList([6, 7, 8, 9, 10]); MockHttpClientResponse response; @@ -46,24 +47,24 @@ void main() { test('Converts an HttpClientResponse with contentLength to bytes', () async { when(response.contentLength) .thenReturn(chunkOne.length + chunkTwo.length); - final List bytes = - await consolidateHttpClientResponseBytes(response); + final List bytes = (await getHttpClientResponseBytes(response)) + .materialize().asUint8List(); expect(bytes, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); }); test('Converts a compressed HttpClientResponse with contentLength to bytes', () async { when(response.contentLength).thenReturn(chunkOne.length); - final List bytes = - await consolidateHttpClientResponseBytes(response); + final List bytes = (await getHttpClientResponseBytes(response)) + .materialize().asUint8List(); expect(bytes, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); }); test('Converts an HttpClientResponse without contentLength to bytes', () async { when(response.contentLength).thenReturn(-1); - final List bytes = - await consolidateHttpClientResponseBytes(response); + final List bytes = (await getHttpClientResponseBytes(response)) + .materialize().asUint8List(); expect(bytes, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); }); @@ -72,7 +73,7 @@ void main() { final int syntheticTotal = (chunkOne.length + chunkTwo.length) * 2; when(response.contentLength).thenReturn(syntheticTotal); final List records = []; - await consolidateHttpClientResponseBytes( + await getHttpClientResponseBytes( response, onBytesReceived: (int cumulative, int total) { records.addAll([cumulative, total]); @@ -110,13 +111,13 @@ void main() { }); when(response.contentLength).thenReturn(-1); - expect(consolidateHttpClientResponseBytes(response), + expect(getHttpClientResponseBytes(response), throwsA(isInstanceOf())); }); test('Propagates error to Future return value if onBytesReceived throws', () async { when(response.contentLength).thenReturn(-1); - final Future> result = consolidateHttpClientResponseBytes( + final Future result = getHttpClientResponseBytes( response, onBytesReceived: (int cumulative, int total) { throw 'misbehaving callback'; @@ -157,14 +158,14 @@ void main() { test('Uncompresses GZIP bytes if autoUncompress is true and response.compressionState is compressed', () async { when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed); when(response.contentLength).thenReturn(gzipped.length); - final List bytes = await consolidateHttpClientResponseBytes(response); + final List bytes = (await getHttpClientResponseBytes(response)).materialize().asUint8List(); expect(bytes, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); }); test('returns gzipped bytes if autoUncompress is false and response.compressionState is compressed', () async { when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed); when(response.contentLength).thenReturn(gzipped.length); - final List bytes = await consolidateHttpClientResponseBytes(response, autoUncompress: false); + final List bytes = (await getHttpClientResponseBytes(response, autoUncompress: false)).materialize().asUint8List(); expect(bytes, gzipped); }); @@ -172,7 +173,7 @@ void main() { when(response.compressionState).thenReturn(HttpClientResponseCompressionState.compressed); when(response.contentLength).thenReturn(gzipped.length); final List records = []; - await consolidateHttpClientResponseBytes( + await getHttpClientResponseBytes( response, onBytesReceived: (int cumulative, int total) { records.addAll([cumulative, total]); @@ -192,7 +193,7 @@ void main() { when(response.compressionState).thenReturn(HttpClientResponseCompressionState.decompressed); when(response.contentLength).thenReturn(syntheticTotal); final List records = []; - await consolidateHttpClientResponseBytes( + await getHttpClientResponseBytes( response, onBytesReceived: (int cumulative, int total) { records.addAll([cumulative, total]); diff --git a/packages/flutter/test/painting/image_provider_test.dart b/packages/flutter/test/painting/image_provider_test.dart index 76b12e012c2..0197dc89945 100644 --- a/packages/flutter/test/painting/image_provider_test.dart +++ b/packages/flutter/test/painting/image_provider_test.dart @@ -144,26 +144,11 @@ void main() { }); group(NetworkImage, () { - MockHttpClient httpClient; - - setUp(() { - httpClient = MockHttpClient(); - debugNetworkImageHttpClientProvider = () => httpClient; - }); - - tearDown(() { - debugNetworkImageHttpClientProvider = null; - }); - test('Expect thrown exception with statusCode', () async { final int errorStatusCode = HttpStatus.notFound; const String requestUrl = 'foo-url'; - final MockHttpClientRequest request = MockHttpClientRequest(); - final MockHttpClientResponse response = MockHttpClientResponse(); - when(httpClient.getUrl(any)).thenAnswer((_) => Future.value(request)); - when(request.close()).thenAnswer((_) => Future.value(response)); - when(response.statusCode).thenReturn(errorStatusCode); + debugNetworkImageHttpClientProvider = returnErrorStatusCode; final Completer caughtError = Completer(); @@ -188,32 +173,40 @@ void main() { }); test('Uses the HttpClient provided by debugNetworkImageHttpClientProvider if set', () async { - when(httpClient.getUrl(any)).thenThrow('client1'); + debugNetworkImageHttpClientProvider = throwOnAnyClient1; + final List capturedErrors = []; Future loadNetworkImage() async { final NetworkImage networkImage = NetworkImage(nonconst('foo')); - final ImageStreamCompleter completer = networkImage.load(networkImage); - completer.addListener(ImageStreamListener( - (ImageInfo image, bool synchronousCall) { }, + final Completer completer = Completer(); + networkImage.load(networkImage).addListener(ImageStreamListener( + (ImageInfo image, bool synchronousCall) { + completer.complete(true); + }, onError: (dynamic error, StackTrace stackTrace) { capturedErrors.add(error); + completer.complete(false); }, )); - await Future.value(); + await completer.future; } await loadNetworkImage(); - expect(capturedErrors, ['client1']); - final MockHttpClient client2 = MockHttpClient(); - when(client2.getUrl(any)).thenThrow('client2'); - debugNetworkImageHttpClientProvider = () => client2; + expect(capturedErrors, isNotNull); + expect(capturedErrors.length, 1); + expect(capturedErrors[0], equals('client1')); + + debugNetworkImageHttpClientProvider = throwOnAnyClient2; await loadNetworkImage(); - expect(capturedErrors, ['client1', 'client2']); + expect(capturedErrors, isNotNull); + expect(capturedErrors.length, 2); + expect(capturedErrors[0], equals('client1')); + expect(capturedErrors[1], equals('client2')); }, skip: isBrowser); test('Propagates http client errors during resolve()', () async { - when(httpClient.getUrl(any)).thenThrow(Error()); + debugNetworkImageHttpClientProvider = throwErrorOnAny; bool uncaught = false; await runZoned(() async { @@ -237,40 +230,16 @@ void main() { }); test('Notifies listeners of chunk events', () async { + debugNetworkImageHttpClientProvider = respondOnAny; + const int chunkSize = 8; - final List chunks = [ - for (int offset = 0; offset < kTransparentImage.length; offset += chunkSize) - Uint8List.fromList(kTransparentImage.skip(offset).take(chunkSize).toList()), - ]; + final List chunks = createChunks(chunkSize); + final Completer imageAvailable = Completer(); - final MockHttpClientRequest request = MockHttpClientRequest(); - final MockHttpClientResponse response = MockHttpClientResponse(); - when(httpClient.getUrl(any)).thenAnswer((_) => Future.value(request)); - when(request.close()).thenAnswer((_) => Future.value(response)); - when(response.statusCode).thenReturn(HttpStatus.ok); - when(response.contentLength).thenReturn(kTransparentImage.length); - when(response.listen( - any, - onDone: anyNamed('onDone'), - onError: anyNamed('onError'), - cancelOnError: anyNamed('cancelOnError'), - )).thenAnswer((Invocation invocation) { - final void Function(List) onData = invocation.positionalArguments[0]; - final void Function(Object) onError = invocation.namedArguments[#onError]; - final void Function() onDone = invocation.namedArguments[#onDone]; - final bool cancelOnError = invocation.namedArguments[#cancelOnError]; - - return Stream.fromIterable(chunks).listen( - onData, - onDone: onDone, - onError: onError, - cancelOnError: cancelOnError, - ); - }); - final ImageProvider imageProvider = NetworkImage(nonconst('foo')); final ImageStream result = imageProvider.resolve(ImageConfiguration.empty); final List events = []; + result.addListener(ImageStreamListener( (ImageInfo image, bool synchronousCall) { imageAvailable.complete(); @@ -289,6 +258,69 @@ void main() { expect(events[i].expectedTotalBytes, kTransparentImage.length); } }, skip: isBrowser); + + test('Uses http request headers', () async { + debugNetworkImageHttpClientProvider = respondOnAnyWithHeaders; + + final Completer imageAvailable = Completer(); + final ImageProvider imageProvider = NetworkImage(nonconst('foo'), + headers: const {'flutter': 'flutter'}, + ); + final ImageStream result = imageProvider.resolve(ImageConfiguration.empty); + result.addListener(ImageStreamListener( + (ImageInfo image, bool synchronousCall) { + imageAvailable.complete(true); + }, + onError: (dynamic error, StackTrace stackTrace) { + imageAvailable.completeError(error, stackTrace); + }, + )); + expect(await imageAvailable.future, isTrue); + }, skip: isBrowser); + + test('Handles http stream errors', () async { + debugNetworkImageHttpClientProvider = respondErrorOnAny; + + final Completer imageAvailable = Completer(); + final ImageProvider imageProvider = NetworkImage(nonconst('bar')); + final ImageStream result = imageProvider.resolve(ImageConfiguration.empty); + final List events = []; + + result.addListener(ImageStreamListener( + (ImageInfo image, bool synchronousCall) { + imageAvailable.complete(null); + }, + onChunk: (ImageChunkEvent event) { + events.add(event); + }, + onError: (dynamic error, StackTrace stackTrace) { + imageAvailable.complete(error); + }, + )); + final String error = await imageAvailable.future; + expect(error, 'failed chunk'); + }, skip: isBrowser); + + test('Handles http connection errors', () async { + debugNetworkImageHttpClientProvider = respondErrorOnConnection; + + final Completer imageAvailable = Completer(); + final ImageProvider imageProvider = NetworkImage(nonconst('baz')); + final ImageStream result = imageProvider.resolve(ImageConfiguration.empty); + result.addListener(ImageStreamListener( + (ImageInfo image, bool synchronousCall) { + imageAvailable.complete(null); + }, + onError: (dynamic error, StackTrace stackTrace) { + imageAvailable.complete(error); + }, + )); + final dynamic err = await imageAvailable.future; + expect(err, const TypeMatcher() + .having((NetworkImageLoadException e) => e.toString(), 'e', startsWith('HTTP request failed')) + .having((NetworkImageLoadException e) => e.statusCode, 'statusCode', HttpStatus.badGateway) + .having((NetworkImageLoadException e) => e.uri.toString(), 'uri', endsWith('/baz'))); + }, skip: isBrowser); }); }); } @@ -296,3 +328,169 @@ void main() { class MockHttpClient extends Mock implements HttpClient {} class MockHttpClientRequest extends Mock implements HttpClientRequest {} class MockHttpClientResponse extends Mock implements HttpClientResponse {} +class MockHttpHeaders extends Mock implements HttpHeaders {} + +HttpClient returnErrorStatusCode() { + final int errorStatusCode = HttpStatus.notFound; + + debugNetworkImageHttpClientProvider = returnErrorStatusCode; + + final MockHttpClientRequest request = MockHttpClientRequest(); + final MockHttpClientResponse response = MockHttpClientResponse(); + final MockHttpClient httpClient = MockHttpClient(); + when(httpClient.getUrl(any)).thenAnswer((_) => Future.value(request)); + when(request.close()).thenAnswer((_) => Future.value(response)); + when(response.statusCode).thenReturn(errorStatusCode); + + return httpClient; +} + +HttpClient throwOnAnyClient1() { + final MockHttpClient httpClient = MockHttpClient(); + when(httpClient.getUrl(any)).thenThrow('client1'); + return httpClient; +} + +HttpClient throwOnAnyClient2() { + final MockHttpClient httpClient = MockHttpClient(); + when(httpClient.getUrl(any)).thenThrow('client2'); + return httpClient; +} + +HttpClient throwErrorOnAny() { + final MockHttpClient httpClient = MockHttpClient(); + when(httpClient.getUrl(any)).thenThrow(Exception()); + return httpClient; +} + +HttpClient respondOnAny() { + const int chunkSize = 8; + final List chunks = createChunks(chunkSize); + final MockHttpClientRequest request = MockHttpClientRequest(); + final MockHttpClientResponse response = MockHttpClientResponse(); + final MockHttpClient httpClient = MockHttpClient(); + when(httpClient.getUrl(any)).thenAnswer((_) => Future.value(request)); + when(request.close()).thenAnswer((_) => Future.value(response)); + when(response.statusCode).thenReturn(HttpStatus.ok); + when(response.contentLength).thenReturn(kTransparentImage.length); + when(response.listen( + any, + onDone: anyNamed('onDone'), + onError: anyNamed('onError'), + cancelOnError: anyNamed('cancelOnError'), + )).thenAnswer((Invocation invocation) { + final void Function(Uint8List) onData = invocation.positionalArguments[0]; + final void Function(Object) onError = invocation.namedArguments[#onError]; + final void Function() onDone = invocation.namedArguments[#onDone]; + final bool cancelOnError = invocation.namedArguments[#cancelOnError]; + + return Stream.fromIterable(chunks).listen( + onData, + onDone: onDone, + onError: onError, + cancelOnError: cancelOnError, + ); + }); + return httpClient; +} + +HttpClient respondOnAnyWithHeaders() { + final List invocations = []; + + const int chunkSize = 8; + final List chunks = createChunks(chunkSize); + final MockHttpClientRequest request = MockHttpClientRequest(); + final MockHttpClientResponse response = MockHttpClientResponse(); + final MockHttpClient httpClient = MockHttpClient(); + final MockHttpHeaders headers = MockHttpHeaders(); + when(httpClient.getUrl(any)).thenAnswer((_) => Future.value(request)); + when(request.headers).thenReturn(headers); + when(headers.add(any, any)).thenAnswer((Invocation invocation) { + invocations.add(invocation); + }); + + when(request.close()).thenAnswer((Invocation invocation) { + if (invocations.length == 1 && + invocations[0].positionalArguments.length == 2 && + invocations[0].positionalArguments[0] == 'flutter' && + invocations[0].positionalArguments[1] == 'flutter') { + return Future.value(response); + } else { + return Future.value(null); + } + }); + when(response.statusCode).thenReturn(HttpStatus.ok); + when(response.contentLength).thenReturn(kTransparentImage.length); + when(response.listen( + any, + onDone: anyNamed('onDone'), + onError: anyNamed('onError'), + cancelOnError: anyNamed('cancelOnError'), + )).thenAnswer((Invocation invocation) { + final void Function(Uint8List) onData = invocation.positionalArguments[0]; + final void Function(Object) onError = invocation.namedArguments[#onError]; + final void Function() onDone = invocation.namedArguments[#onDone]; + final bool cancelOnError = invocation.namedArguments[#cancelOnError]; + + return Stream.fromIterable(chunks).listen( + onData, + onDone: onDone, + onError: onError, + cancelOnError: cancelOnError, + ); + }); + return httpClient; +} + +HttpClient respondErrorOnConnection() { + final MockHttpClientRequest request = MockHttpClientRequest(); + final MockHttpClientResponse response = MockHttpClientResponse(); + final MockHttpClient httpClient = MockHttpClient(); + when(httpClient.getUrl(any)).thenAnswer((_) => Future.value(request)); + when(request.close()).thenAnswer((_) => Future.value(response)); + when(response.statusCode).thenReturn(HttpStatus.badGateway); + return httpClient; +} + +HttpClient respondErrorOnAny() { + const int chunkSize = 8; + final MockHttpClientRequest request = MockHttpClientRequest(); + final MockHttpClientResponse response = MockHttpClientResponse(); + final MockHttpClient httpClient = MockHttpClient(); + when(httpClient.getUrl(any)).thenAnswer((_) => Future.value(request)); + when(request.close()).thenAnswer((_) => Future.value(response)); + when(response.statusCode).thenReturn(HttpStatus.ok); + when(response.contentLength).thenReturn(kTransparentImage.length); + when(response.listen( + any, + onDone: anyNamed('onDone'), + onError: anyNamed('onError'), + cancelOnError: anyNamed('cancelOnError'), + )).thenAnswer((Invocation invocation) { + final void Function(Uint8List) onData = invocation.positionalArguments[0]; + final void Function(Object) onError = invocation.namedArguments[#onError]; + final void Function() onDone = invocation.namedArguments[#onDone]; + final bool cancelOnError = invocation.namedArguments[#cancelOnError]; + + return createRottenChunks(chunkSize).listen( + onData, + onDone: onDone, + onError: onError, + cancelOnError: cancelOnError, + ); + }); + return httpClient; +} + +List createChunks(int chunkSize) { + final List chunks = [ + for (int offset = 0; offset < kTransparentImage.length; offset += chunkSize) + Uint8List.fromList(kTransparentImage.skip(offset).take(chunkSize).toList()), + ]; + return chunks; +} + +Stream createRottenChunks(int chunkSize) async* { + yield Uint8List.fromList(kTransparentImage.take(chunkSize).toList()); + throw 'failed chunk'; +} diff --git a/packages/flutter/test/widgets/image_headers_test.dart b/packages/flutter/test/widgets/image_headers_test.dart deleted file mode 100644 index 68bc680cdb9..00000000000 --- a/packages/flutter/test/widgets/image_headers_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2017 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; - -import '../painting/image_data.dart'; - -void main() { - final MockHttpClient client = MockHttpClient(); - final MockHttpClientRequest request = MockHttpClientRequest(); - final MockHttpClientResponse response = MockHttpClientResponse(); - final MockHttpHeaders headers = MockHttpHeaders(); - - testWidgets('Headers', (WidgetTester tester) async { - HttpOverrides.runZoned>(() async { - await tester.pumpWidget(Image.network( - 'https://www.example.com/images/frame.png', - headers: const {'flutter': 'flutter'}, - )); - - verify(headers.add('flutter', 'flutter')).called(1); - - }, createHttpClient: (SecurityContext _) { - when(client.getUrl(any)).thenAnswer((_) => Future.value(request)); - when(request.headers).thenReturn(headers); - when(request.close()).thenAnswer((_) => Future.value(response)); - when(response.contentLength).thenReturn(kTransparentImage.length); - when(response.statusCode).thenReturn(HttpStatus.ok); - when(response.listen(any)).thenAnswer((Invocation invocation) { - final void Function(List) onData = invocation.positionalArguments[0]; - final void Function() onDone = invocation.namedArguments[#onDone]; - final void Function(Object, [ StackTrace ]) onError = invocation.namedArguments[#onError]; - final bool cancelOnError = invocation.namedArguments[#cancelOnError]; - return Stream>.fromIterable(>[kTransparentImage]).listen(onData, onDone: onDone, onError: onError, cancelOnError: cancelOnError); - }); - return client; - }); - }, skip: isBrowser); -} - -class MockHttpClient extends Mock implements HttpClient {} - -class MockHttpClientRequest extends Mock implements HttpClientRequest {} - -class MockHttpClientResponse extends Mock implements HttpClientResponse {} - -class MockHttpHeaders extends Mock implements HttpHeaders {}