diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart index 749e6d8e2aa8f..b5a539e765af3 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -8,32 +8,80 @@ part of engine; /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia. ui.Codec skiaInstantiateImageCodec(Uint8List list, [int? width, int? height, int? format, int? rowBytes]) { - return CkAnimatedImage.decodeFromBytes(list); + return CkAnimatedImage.decodeFromBytes(list, 'encoded image bytes'); +} + +/// Thrown when the web engine fails to decode an image, either due to a +/// network issue, corrupted image contents, or missing codec. +class ImageCodecException implements Exception { + ImageCodecException(this._message); + + final String _message; + + @override + String toString() => 'ImageCodecException: $_message'; +} + +const String _kNetworkImageMessage = 'Failed to load network image.'; + +typedef HttpRequestFactory = html.HttpRequest Function(); +HttpRequestFactory httpRequestFactory = () => html.HttpRequest(); +void debugRestoreHttpRequestFactory() { + httpRequestFactory = () => html.HttpRequest(); } /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia after /// requesting from URI. Future skiaInstantiateWebImageCodec( - String uri, WebOnlyImageCodecChunkCallback? chunkCallback) { + String url, WebOnlyImageCodecChunkCallback? chunkCallback) { Completer completer = Completer(); - //TODO: Switch to using MakeImageFromCanvasImageSource when animated images are supported. - html.HttpRequest.request(uri, responseType: "arraybuffer", - onProgress: (html.ProgressEvent event) { - if (event.lengthComputable) { - chunkCallback?.call(event.loaded!, event.total!); + + final html.HttpRequest request = httpRequestFactory(); + request.open('GET', url, async: true); + request.responseType = 'arraybuffer'; + if (chunkCallback != null) { + request.onProgress.listen((html.ProgressEvent event) { + chunkCallback.call(event.loaded!, event.total!); + }); + } + + request.onError.listen((html.ProgressEvent event) { + completer.completeError(ImageCodecException( + '$_kNetworkImageMessage\n' + 'Image URL: $url\n' + 'Trying to load an image from another domain? Find answers at:\n' + 'https://flutter.dev/docs/development/platform-integration/web-images' + )); + }); + + request.onLoad.listen((html.ProgressEvent event) { + final int status = request.status!; + final bool accepted = status >= 200 && status < 300; + final bool fileUri = status == 0; // file:// URIs have status of 0. + final bool notModified = status == 304; + final bool unknownRedirect = status > 307 && status < 400; + final bool success = accepted || fileUri || notModified || unknownRedirect; + + if (!success) { + completer.completeError(ImageCodecException( + '$_kNetworkImageMessage\n' + 'Image URL: $url\n' + 'Server response code: $status'), + ); + return; } - }).then((html.HttpRequest response) { - if (response.status != 200) { - completer.completeError(Exception( - 'Network image request failed with status: ${response.status}')); + + try { + final Uint8List list = + new Uint8List.view((request.response as ByteBuffer)); + final CkAnimatedImage codec = CkAnimatedImage.decodeFromBytes(list, url); + completer.complete(codec); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); } - final Uint8List list = - new Uint8List.view((response.response as ByteBuffer)); - final CkAnimatedImage codec = CkAnimatedImage.decodeFromBytes(list); - completer.complete(codec); - }, onError: (dynamic error) { - completer.completeError(error); }); + + request.send(); return completer.future; } @@ -42,15 +90,19 @@ Future skiaInstantiateWebImageCodec( /// Wraps `SkAnimatedImage`. class CkAnimatedImage extends ManagedSkiaObject implements ui.Codec { /// Decodes an image from a list of encoded bytes. - CkAnimatedImage.decodeFromBytes(this._bytes); + CkAnimatedImage.decodeFromBytes(this._bytes, this.src); + final String src; final Uint8List _bytes; @override SkAnimatedImage createDefault() { final SkAnimatedImage? animatedImage = canvasKit.MakeAnimatedImageFromEncoded(_bytes); if (animatedImage == null) { - throw Exception('Failed to decode image'); + throw ImageCodecException( + 'Failed to decode image data.\n' + 'Image source: $src', + ); } return animatedImage; } diff --git a/lib/web_ui/test/canvaskit/image_test.dart b/lib/web_ui/test/canvaskit/image_test.dart index 2b046240e6667..6ff405b08a035 100644 --- a/lib/web_ui/test/canvaskit/image_test.dart +++ b/lib/web_ui/test/canvaskit/image_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. // @dart = 2.6 -import 'dart:html' show ProgressEvent; +import 'dart:html' as html; import 'dart:typed_data'; import 'package:test/bootstrap/browser.dart'; @@ -23,8 +23,12 @@ void testMain() { group('CanvasKit image', () { setUpCanvasKitTest(); + tearDown(() { + debugRestoreHttpRequestFactory(); + }); + test('CkAnimatedImage can be explicitly disposed of', () { - final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage); + final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage, 'test'); expect(image.debugDisposed, false); image.dispose(); expect(image.debugDisposed, true); @@ -99,13 +103,6 @@ void testMain() { testCollector.collectNow(); }); - test('skiaInstantiateWebImageCodec throws exception if given invalid URL', - () async { - expect(skiaInstantiateWebImageCodec('invalid-url', null), - throwsA(isA())); - testCollector.collectNow(); - }); - test('CkImage toByteData', () async { final SkImage skImage = canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage) @@ -116,14 +113,210 @@ void testMain() { testCollector.collectNow(); }); - test('Reports error when failing to decode image', () async { + test('skiaInstantiateWebImageCodec loads an image from the network', + () async { + httpRequestFactory = () { + return TestHttpRequest() + ..status = 200 + ..onLoad = Stream.fromIterable([ + html.ProgressEvent('test error'), + ]) + ..response = kTransparentImage.buffer; + }; + final ui.Codec codec = await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null); + expect(codec.frameCount, 1); + final ui.Image image = (await codec.getNextFrame()).image; + expect(image.height, 1); + expect(image.width, 1); + testCollector.collectNow(); + }); + + test('skiaInstantiateWebImageCodec throws exception on request error', + () async { + httpRequestFactory = () { + return TestHttpRequest() + ..onError = Stream.fromIterable([ + html.ProgressEvent('test error'), + ]); + }; + try { + await skiaInstantiateWebImageCodec('url-does-not-matter', null); + fail('Expected to throw'); + } on ImageCodecException catch (exception) { + expect( + exception.toString(), + 'ImageCodecException: Failed to load network image.\n' + 'Image URL: url-does-not-matter\n' + 'Trying to load an image from another domain? Find answers at:\n' + 'https://flutter.dev/docs/development/platform-integration/web-images', + ); + } + testCollector.collectNow(); + }); + + test('skiaInstantiateWebImageCodec throws exception on HTTP error', + () async { + try { + await skiaInstantiateWebImageCodec('/does-not-exist.jpg', null); + fail('Expected to throw'); + } on ImageCodecException catch (exception) { + expect( + exception.toString(), + 'ImageCodecException: Failed to load network image.\n' + 'Image URL: /does-not-exist.jpg\n' + 'Server response code: 404', + ); + } + testCollector.collectNow(); + }); + + test('skiaInstantiateWebImageCodec includes URL in the error for malformed image', + () async { + httpRequestFactory = () { + return TestHttpRequest() + ..status = 200 + ..onLoad = Stream.fromIterable([ + html.ProgressEvent('test error'), + ]) + ..response = Uint8List(0).buffer; + }; + try { + await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null); + fail('Expected to throw'); + } on ImageCodecException catch (exception) { + expect( + exception.toString(), + 'ImageCodecException: Failed to decode image data.\n' + 'Image source: http://image-server.com/picture.jpg', + ); + } + testCollector.collectNow(); + }); + + test('Reports error when failing to decode image data', () async { try { await ui.instantiateImageCodec(Uint8List(0)); fail('Expected to throw'); - } on Exception catch (exception) { - expect(exception.toString(), 'Exception: Failed to decode image'); + } on ImageCodecException catch (exception) { + expect( + exception.toString(), + 'ImageCodecException: Failed to decode image data.\n' + 'Image source: encoded image bytes' + ); } }); // TODO: https://github.com/flutter/flutter/issues/60040 }, skip: isIosSafari); } + +class TestHttpRequest implements html.HttpRequest { + @override + String responseType; + + @override + int timeout = 10; + + @override + bool withCredentials = false; + + @override + void abort() { + throw UnimplementedError(); + } + + @override + void addEventListener(String type, listener, [bool useCapture]) { + throw UnimplementedError(); + } + + @override + bool dispatchEvent(html.Event event) { + throw UnimplementedError(); + } + + @override + String getAllResponseHeaders() { + throw UnimplementedError(); + } + + @override + String getResponseHeader(String name) { + throw UnimplementedError(); + } + + @override + html.Events get on => throw UnimplementedError(); + + @override + Stream get onAbort => throw UnimplementedError(); + + @override + Stream onError = Stream.fromIterable([]); + + @override + Stream onLoad = Stream.fromIterable([]); + + @override + Stream get onLoadEnd => throw UnimplementedError(); + + @override + Stream get onLoadStart => throw UnimplementedError(); + + @override + Stream get onProgress => throw UnimplementedError(); + + @override + Stream get onReadyStateChange => throw UnimplementedError(); + + @override + Stream get onTimeout => throw UnimplementedError(); + + @override + void open(String method, String url, {bool async, String user, String password}) {} + + @override + void overrideMimeType(String mime) { + throw UnimplementedError(); + } + + @override + int get readyState => throw UnimplementedError(); + + @override + void removeEventListener(String type, listener, [bool useCapture]) { + throw UnimplementedError(); + } + + @override + dynamic response; + + @override + Map get responseHeaders => throw UnimplementedError(); + + @override + String get responseText => throw UnimplementedError(); + + @override + String get responseUrl => throw UnimplementedError(); + + @override + html.Document get responseXml => throw UnimplementedError(); + + @override + void send([dynamic bodyOrData]) { + } + + @override + void setRequestHeader(String name, String value) { + throw UnimplementedError(); + } + + @override + int status = -1; + + @override + String get statusText => throw UnimplementedError(); + + @override + html.HttpRequestUpload get upload => throw UnimplementedError(); +}