Skip to content

Commit

Permalink
[canvaskit] improve image error handling and messaging (flutter#22951)
Browse files Browse the repository at this point in the history
  • Loading branch information
yjbanov committed Dec 14, 2020
1 parent 1749dbc commit d0b6e42
Show file tree
Hide file tree
Showing 2 changed files with 276 additions and 31 deletions.
90 changes: 71 additions & 19 deletions lib/web_ui/lib/src/engine/canvaskit/image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ui.Codec> skiaInstantiateWebImageCodec(
String uri, WebOnlyImageCodecChunkCallback? chunkCallback) {
String url, WebOnlyImageCodecChunkCallback? chunkCallback) {
Completer<ui.Codec> completer = Completer<ui.Codec>();
//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;
}

Expand All @@ -42,15 +90,19 @@ Future<ui.Codec> skiaInstantiateWebImageCodec(
/// Wraps `SkAnimatedImage`.
class CkAnimatedImage extends ManagedSkiaObject<SkAnimatedImage> 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;
}
Expand Down
217 changes: 205 additions & 12 deletions lib/web_ui/test/canvaskit/image_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -99,13 +103,6 @@ void testMain() {
testCollector.collectNow();
});

test('skiaInstantiateWebImageCodec throws exception if given invalid URL',
() async {
expect(skiaInstantiateWebImageCodec('invalid-url', null),
throwsA(isA<ProgressEvent>()));
testCollector.collectNow();
});

test('CkImage toByteData', () async {
final SkImage skImage =
canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)
Expand All @@ -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<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
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<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
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<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
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<html.ProgressEvent> get onAbort => throw UnimplementedError();

@override
Stream<html.ProgressEvent> onError = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[]);

@override
Stream<html.ProgressEvent> onLoad = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[]);

@override
Stream<html.ProgressEvent> get onLoadEnd => throw UnimplementedError();

@override
Stream<html.ProgressEvent> get onLoadStart => throw UnimplementedError();

@override
Stream<html.ProgressEvent> get onProgress => throw UnimplementedError();

@override
Stream<html.Event> get onReadyStateChange => throw UnimplementedError();

@override
Stream<html.ProgressEvent> 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<String, String> 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();
}

0 comments on commit d0b6e42

Please sign in to comment.