Skip to content

Commit d0b6e42

Browse files
authored
[canvaskit] improve image error handling and messaging (flutter#22951)
1 parent 1749dbc commit d0b6e42

File tree

2 files changed

+276
-31
lines changed

2 files changed

+276
-31
lines changed

lib/web_ui/lib/src/engine/canvaskit/image.dart

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,80 @@ part of engine;
88
/// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia.
99
ui.Codec skiaInstantiateImageCodec(Uint8List list,
1010
[int? width, int? height, int? format, int? rowBytes]) {
11-
return CkAnimatedImage.decodeFromBytes(list);
11+
return CkAnimatedImage.decodeFromBytes(list, 'encoded image bytes');
12+
}
13+
14+
/// Thrown when the web engine fails to decode an image, either due to a
15+
/// network issue, corrupted image contents, or missing codec.
16+
class ImageCodecException implements Exception {
17+
ImageCodecException(this._message);
18+
19+
final String _message;
20+
21+
@override
22+
String toString() => 'ImageCodecException: $_message';
23+
}
24+
25+
const String _kNetworkImageMessage = 'Failed to load network image.';
26+
27+
typedef HttpRequestFactory = html.HttpRequest Function();
28+
HttpRequestFactory httpRequestFactory = () => html.HttpRequest();
29+
void debugRestoreHttpRequestFactory() {
30+
httpRequestFactory = () => html.HttpRequest();
1231
}
1332

1433
/// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia after
1534
/// requesting from URI.
1635
Future<ui.Codec> skiaInstantiateWebImageCodec(
17-
String uri, WebOnlyImageCodecChunkCallback? chunkCallback) {
36+
String url, WebOnlyImageCodecChunkCallback? chunkCallback) {
1837
Completer<ui.Codec> completer = Completer<ui.Codec>();
19-
//TODO: Switch to using MakeImageFromCanvasImageSource when animated images are supported.
20-
html.HttpRequest.request(uri, responseType: "arraybuffer",
21-
onProgress: (html.ProgressEvent event) {
22-
if (event.lengthComputable) {
23-
chunkCallback?.call(event.loaded!, event.total!);
38+
39+
final html.HttpRequest request = httpRequestFactory();
40+
request.open('GET', url, async: true);
41+
request.responseType = 'arraybuffer';
42+
if (chunkCallback != null) {
43+
request.onProgress.listen((html.ProgressEvent event) {
44+
chunkCallback.call(event.loaded!, event.total!);
45+
});
46+
}
47+
48+
request.onError.listen((html.ProgressEvent event) {
49+
completer.completeError(ImageCodecException(
50+
'$_kNetworkImageMessage\n'
51+
'Image URL: $url\n'
52+
'Trying to load an image from another domain? Find answers at:\n'
53+
'https://flutter.dev/docs/development/platform-integration/web-images'
54+
));
55+
});
56+
57+
request.onLoad.listen((html.ProgressEvent event) {
58+
final int status = request.status!;
59+
final bool accepted = status >= 200 && status < 300;
60+
final bool fileUri = status == 0; // file:// URIs have status of 0.
61+
final bool notModified = status == 304;
62+
final bool unknownRedirect = status > 307 && status < 400;
63+
final bool success = accepted || fileUri || notModified || unknownRedirect;
64+
65+
if (!success) {
66+
completer.completeError(ImageCodecException(
67+
'$_kNetworkImageMessage\n'
68+
'Image URL: $url\n'
69+
'Server response code: $status'),
70+
);
71+
return;
2472
}
25-
}).then((html.HttpRequest response) {
26-
if (response.status != 200) {
27-
completer.completeError(Exception(
28-
'Network image request failed with status: ${response.status}'));
73+
74+
try {
75+
final Uint8List list =
76+
new Uint8List.view((request.response as ByteBuffer));
77+
final CkAnimatedImage codec = CkAnimatedImage.decodeFromBytes(list, url);
78+
completer.complete(codec);
79+
} catch (error, stackTrace) {
80+
completer.completeError(error, stackTrace);
2981
}
30-
final Uint8List list =
31-
new Uint8List.view((response.response as ByteBuffer));
32-
final CkAnimatedImage codec = CkAnimatedImage.decodeFromBytes(list);
33-
completer.complete(codec);
34-
}, onError: (dynamic error) {
35-
completer.completeError(error);
3682
});
83+
84+
request.send();
3785
return completer.future;
3886
}
3987

@@ -42,15 +90,19 @@ Future<ui.Codec> skiaInstantiateWebImageCodec(
4290
/// Wraps `SkAnimatedImage`.
4391
class CkAnimatedImage extends ManagedSkiaObject<SkAnimatedImage> implements ui.Codec {
4492
/// Decodes an image from a list of encoded bytes.
45-
CkAnimatedImage.decodeFromBytes(this._bytes);
93+
CkAnimatedImage.decodeFromBytes(this._bytes, this.src);
4694

95+
final String src;
4796
final Uint8List _bytes;
4897

4998
@override
5099
SkAnimatedImage createDefault() {
51100
final SkAnimatedImage? animatedImage = canvasKit.MakeAnimatedImageFromEncoded(_bytes);
52101
if (animatedImage == null) {
53-
throw Exception('Failed to decode image');
102+
throw ImageCodecException(
103+
'Failed to decode image data.\n'
104+
'Image source: $src',
105+
);
54106
}
55107
return animatedImage;
56108
}

lib/web_ui/test/canvaskit/image_test.dart

Lines changed: 205 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// found in the LICENSE file.
44

55
// @dart = 2.6
6-
import 'dart:html' show ProgressEvent;
6+
import 'dart:html' as html;
77
import 'dart:typed_data';
88

99
import 'package:test/bootstrap/browser.dart';
@@ -23,8 +23,12 @@ void testMain() {
2323
group('CanvasKit image', () {
2424
setUpCanvasKitTest();
2525

26+
tearDown(() {
27+
debugRestoreHttpRequestFactory();
28+
});
29+
2630
test('CkAnimatedImage can be explicitly disposed of', () {
27-
final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage);
31+
final CkAnimatedImage image = CkAnimatedImage.decodeFromBytes(kTransparentImage, 'test');
2832
expect(image.debugDisposed, false);
2933
image.dispose();
3034
expect(image.debugDisposed, true);
@@ -99,13 +103,6 @@ void testMain() {
99103
testCollector.collectNow();
100104
});
101105

102-
test('skiaInstantiateWebImageCodec throws exception if given invalid URL',
103-
() async {
104-
expect(skiaInstantiateWebImageCodec('invalid-url', null),
105-
throwsA(isA<ProgressEvent>()));
106-
testCollector.collectNow();
107-
});
108-
109106
test('CkImage toByteData', () async {
110107
final SkImage skImage =
111108
canvasKit.MakeAnimatedImageFromEncoded(kTransparentImage)
@@ -116,14 +113,210 @@ void testMain() {
116113
testCollector.collectNow();
117114
});
118115

119-
test('Reports error when failing to decode image', () async {
116+
test('skiaInstantiateWebImageCodec loads an image from the network',
117+
() async {
118+
httpRequestFactory = () {
119+
return TestHttpRequest()
120+
..status = 200
121+
..onLoad = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
122+
html.ProgressEvent('test error'),
123+
])
124+
..response = kTransparentImage.buffer;
125+
};
126+
final ui.Codec codec = await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null);
127+
expect(codec.frameCount, 1);
128+
final ui.Image image = (await codec.getNextFrame()).image;
129+
expect(image.height, 1);
130+
expect(image.width, 1);
131+
testCollector.collectNow();
132+
});
133+
134+
test('skiaInstantiateWebImageCodec throws exception on request error',
135+
() async {
136+
httpRequestFactory = () {
137+
return TestHttpRequest()
138+
..onError = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
139+
html.ProgressEvent('test error'),
140+
]);
141+
};
142+
try {
143+
await skiaInstantiateWebImageCodec('url-does-not-matter', null);
144+
fail('Expected to throw');
145+
} on ImageCodecException catch (exception) {
146+
expect(
147+
exception.toString(),
148+
'ImageCodecException: Failed to load network image.\n'
149+
'Image URL: url-does-not-matter\n'
150+
'Trying to load an image from another domain? Find answers at:\n'
151+
'https://flutter.dev/docs/development/platform-integration/web-images',
152+
);
153+
}
154+
testCollector.collectNow();
155+
});
156+
157+
test('skiaInstantiateWebImageCodec throws exception on HTTP error',
158+
() async {
159+
try {
160+
await skiaInstantiateWebImageCodec('/does-not-exist.jpg', null);
161+
fail('Expected to throw');
162+
} on ImageCodecException catch (exception) {
163+
expect(
164+
exception.toString(),
165+
'ImageCodecException: Failed to load network image.\n'
166+
'Image URL: /does-not-exist.jpg\n'
167+
'Server response code: 404',
168+
);
169+
}
170+
testCollector.collectNow();
171+
});
172+
173+
test('skiaInstantiateWebImageCodec includes URL in the error for malformed image',
174+
() async {
175+
httpRequestFactory = () {
176+
return TestHttpRequest()
177+
..status = 200
178+
..onLoad = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[
179+
html.ProgressEvent('test error'),
180+
])
181+
..response = Uint8List(0).buffer;
182+
};
183+
try {
184+
await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null);
185+
fail('Expected to throw');
186+
} on ImageCodecException catch (exception) {
187+
expect(
188+
exception.toString(),
189+
'ImageCodecException: Failed to decode image data.\n'
190+
'Image source: http://image-server.com/picture.jpg',
191+
);
192+
}
193+
testCollector.collectNow();
194+
});
195+
196+
test('Reports error when failing to decode image data', () async {
120197
try {
121198
await ui.instantiateImageCodec(Uint8List(0));
122199
fail('Expected to throw');
123-
} on Exception catch (exception) {
124-
expect(exception.toString(), 'Exception: Failed to decode image');
200+
} on ImageCodecException catch (exception) {
201+
expect(
202+
exception.toString(),
203+
'ImageCodecException: Failed to decode image data.\n'
204+
'Image source: encoded image bytes'
205+
);
125206
}
126207
});
127208
// TODO: https://github.com/flutter/flutter/issues/60040
128209
}, skip: isIosSafari);
129210
}
211+
212+
class TestHttpRequest implements html.HttpRequest {
213+
@override
214+
String responseType;
215+
216+
@override
217+
int timeout = 10;
218+
219+
@override
220+
bool withCredentials = false;
221+
222+
@override
223+
void abort() {
224+
throw UnimplementedError();
225+
}
226+
227+
@override
228+
void addEventListener(String type, listener, [bool useCapture]) {
229+
throw UnimplementedError();
230+
}
231+
232+
@override
233+
bool dispatchEvent(html.Event event) {
234+
throw UnimplementedError();
235+
}
236+
237+
@override
238+
String getAllResponseHeaders() {
239+
throw UnimplementedError();
240+
}
241+
242+
@override
243+
String getResponseHeader(String name) {
244+
throw UnimplementedError();
245+
}
246+
247+
@override
248+
html.Events get on => throw UnimplementedError();
249+
250+
@override
251+
Stream<html.ProgressEvent> get onAbort => throw UnimplementedError();
252+
253+
@override
254+
Stream<html.ProgressEvent> onError = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[]);
255+
256+
@override
257+
Stream<html.ProgressEvent> onLoad = Stream<html.ProgressEvent>.fromIterable(<html.ProgressEvent>[]);
258+
259+
@override
260+
Stream<html.ProgressEvent> get onLoadEnd => throw UnimplementedError();
261+
262+
@override
263+
Stream<html.ProgressEvent> get onLoadStart => throw UnimplementedError();
264+
265+
@override
266+
Stream<html.ProgressEvent> get onProgress => throw UnimplementedError();
267+
268+
@override
269+
Stream<html.Event> get onReadyStateChange => throw UnimplementedError();
270+
271+
@override
272+
Stream<html.ProgressEvent> get onTimeout => throw UnimplementedError();
273+
274+
@override
275+
void open(String method, String url, {bool async, String user, String password}) {}
276+
277+
@override
278+
void overrideMimeType(String mime) {
279+
throw UnimplementedError();
280+
}
281+
282+
@override
283+
int get readyState => throw UnimplementedError();
284+
285+
@override
286+
void removeEventListener(String type, listener, [bool useCapture]) {
287+
throw UnimplementedError();
288+
}
289+
290+
@override
291+
dynamic response;
292+
293+
@override
294+
Map<String, String> get responseHeaders => throw UnimplementedError();
295+
296+
@override
297+
String get responseText => throw UnimplementedError();
298+
299+
@override
300+
String get responseUrl => throw UnimplementedError();
301+
302+
@override
303+
html.Document get responseXml => throw UnimplementedError();
304+
305+
@override
306+
void send([dynamic bodyOrData]) {
307+
}
308+
309+
@override
310+
void setRequestHeader(String name, String value) {
311+
throw UnimplementedError();
312+
}
313+
314+
@override
315+
int status = -1;
316+
317+
@override
318+
String get statusText => throw UnimplementedError();
319+
320+
@override
321+
html.HttpRequestUpload get upload => throw UnimplementedError();
322+
}

0 commit comments

Comments
 (0)