Skip to content

Commit c519383

Browse files
[web] On the web platform, use an <img> tag to show an image if it can't be accessed with CORS (#157755)
When using `Image.network`, if the given URL points to an image that is cross-origin and not set up to allow CORS access, then we are unable to make a [ui.Image] from it. In this case, render the image using a platform view. This is the last remaining checklist item for flutter/flutter#145954 Fixes flutter/flutter#149843 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent d541354 commit c519383

11 files changed

+1127
-86
lines changed

packages/flutter/lib/src/painting/_network_image_web.dart

Lines changed: 227 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,52 @@ import 'dart:ui_web' as ui_web;
1010
import 'package:flutter/foundation.dart';
1111

1212
import '../web.dart' as web;
13+
import '_web_image_info_web.dart';
1314
import 'image_provider.dart' as image_provider;
1415
import 'image_stream.dart';
1516

16-
/// Creates a type for an overridable factory function for testing purposes.
17+
/// The type for an overridable factory function for creating an HTTP request,
18+
/// used for testing purposes.
1719
typedef HttpRequestFactory = web.XMLHttpRequest Function();
1820

21+
/// The type for an overridable factory function for creating <img> elements,
22+
/// used for testing purposes.
23+
typedef ImgElementFactory = web.HTMLImageElement Function();
24+
1925
// Method signature for _loadAsync decode callbacks.
2026
typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer buffer);
2127

22-
/// Default HTTP client.
28+
/// The default HTTP client.
2329
web.XMLHttpRequest _httpClient() {
2430
return web.XMLHttpRequest();
2531
}
2632

2733
/// Creates an overridable factory function.
34+
@visibleForTesting
2835
HttpRequestFactory httpRequestFactory = _httpClient;
2936

30-
/// Restores to the default HTTP request factory.
37+
/// Restores the default HTTP request factory.
38+
@visibleForTesting
3139
void debugRestoreHttpRequestFactory() {
3240
httpRequestFactory = _httpClient;
3341
}
3442

43+
/// The default <img> element factory.
44+
web.HTMLImageElement _imgElementFactory() {
45+
return web.document.createElement('img') as web.HTMLImageElement;
46+
}
47+
48+
/// The factory function that creates <img> elements, can be overridden for
49+
/// tests.
50+
@visibleForTesting
51+
ImgElementFactory imgElementFactory = _imgElementFactory;
52+
53+
/// Restores the default <img> element factory.
54+
@visibleForTesting
55+
void debugRestoreImgElementFactory() {
56+
imgElementFactory = _imgElementFactory;
57+
}
58+
3559
/// The web implementation of [image_provider.NetworkImage].
3660
///
3761
/// NetworkImage on the web does not support decoding to a specified size.
@@ -64,12 +88,14 @@ class NetworkImage
6488
final StreamController<ImageChunkEvent> chunkEvents =
6589
StreamController<ImageChunkEvent>();
6690

67-
return MultiFrameImageStreamCompleter(
68-
chunkEvents: chunkEvents.stream,
69-
codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
70-
scale: key.scale,
71-
debugLabel: key.url,
91+
return _ForwardingImageStreamCompleter(
92+
_loadAsync(
93+
key as NetworkImage,
94+
decode,
95+
chunkEvents,
96+
),
7297
informationCollector: _imageStreamInformationCollector(key),
98+
debugLabel: key.url,
7399
);
74100
}
75101

@@ -80,12 +106,14 @@ class NetworkImage
80106
// has been loaded or an error is thrown.
81107
final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
82108

83-
return MultiFrameImageStreamCompleter(
84-
chunkEvents: chunkEvents.stream,
85-
codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
86-
scale: key.scale,
87-
debugLabel: key.url,
109+
return _ForwardingImageStreamCompleter(
110+
_loadAsync(
111+
key as NetworkImage,
112+
decode,
113+
chunkEvents,
114+
),
88115
informationCollector: _imageStreamInformationCollector(key),
116+
debugLabel: key.url,
89117
);
90118
}
91119

@@ -101,10 +129,10 @@ class NetworkImage
101129
return collector;
102130
}
103131

104-
// Html renderer does not support decoding network images to a specified size. The decode parameter
132+
// HTML renderer does not support decoding network images to a specified size. The decode parameter
105133
// here is ignored and `ui_web.createImageCodecFromUrl` will be used directly
106134
// in place of the typical `instantiateImageCodec` method.
107-
Future<ui.Codec> _loadAsync(
135+
Future<ImageStreamCompleter> _loadAsync(
108136
NetworkImage key,
109137
_SimpleDecoderCallback decode,
110138
StreamController<ImageChunkEvent> chunkEvents,
@@ -117,60 +145,141 @@ class NetworkImage
117145

118146
// We use a different method when headers are set because the
119147
// `ui_web.createImageCodecFromUrl` method is not capable of handling headers.
120-
if (isSkiaWeb || containsNetworkImageHeaders) {
121-
final Completer<web.XMLHttpRequest> completer =
122-
Completer<web.XMLHttpRequest>();
123-
final web.XMLHttpRequest request = httpRequestFactory();
124-
125-
request.open('GET', key.url, true);
126-
request.responseType = 'arraybuffer';
127-
if (containsNetworkImageHeaders) {
128-
key.headers!.forEach((String header, String value) {
129-
request.setRequestHeader(header, value);
130-
});
148+
if (containsNetworkImageHeaders) {
149+
// It is not possible to load an <img> element and pass the headers with
150+
// the request to fetch the image. Since the user has provided headers,
151+
// this function should assume the headers are required to resolve to
152+
// the correct resource and should not attempt to load the image in an
153+
// <img> tag without the headers.
154+
155+
// Resolve the Codec before passing it to
156+
// [MultiFrameImageStreamCompleter] so any errors aren't reported
157+
// twice (once from the MultiFrameImageStreamCompleter and again
158+
// from the wrapping [ForwardingImageStreamCompleter]).
159+
final ui.Codec codec = await _fetchImageBytes(decode);
160+
return MultiFrameImageStreamCompleter(
161+
chunkEvents: chunkEvents.stream,
162+
codec: Future<ui.Codec>.value(codec),
163+
scale: key.scale,
164+
debugLabel: key.url,
165+
informationCollector: _imageStreamInformationCollector(key),
166+
);
167+
} else if (isSkiaWeb) {
168+
try {
169+
// Resolve the Codec before passing it to
170+
// [MultiFrameImageStreamCompleter] so any errors aren't reported
171+
// twice (once from the MultiFrameImageStreamCompleter and again
172+
// from the wrapping [ForwardingImageStreamCompleter]).
173+
final ui.Codec codec = await _fetchImageBytes(decode);
174+
return MultiFrameImageStreamCompleter(
175+
chunkEvents: chunkEvents.stream,
176+
codec: Future<ui.Codec>.value(codec),
177+
scale: key.scale,
178+
debugLabel: key.url,
179+
informationCollector: _imageStreamInformationCollector(key),
180+
);
181+
} catch (e) {
182+
// If we failed to fetch the bytes, try to load the image in an <img>
183+
// element instead.
184+
final web.HTMLImageElement imageElement = imgElementFactory();
185+
imageElement.src = key.url;
186+
// Decode the <img> element before creating the ImageStreamCompleter
187+
// to avoid double reporting the error.
188+
await imageElement.decode().toDart;
189+
return OneFrameImageStreamCompleter(
190+
Future<ImageInfo>.value(
191+
WebImageInfo(
192+
imageElement,
193+
debugLabel: key.url,
194+
),
195+
),
196+
informationCollector: _imageStreamInformationCollector(key),
197+
)..debugLabel = key.url;
131198
}
199+
} else {
200+
// This branch is only hit by the HTML renderer, which is deprecated. The
201+
// HTML renderer supports loading images with CORS restrictions, so we
202+
// don't need to catch errors and try loading the image in an <img> tag
203+
// in this case.
204+
205+
// Resolve the Codec before passing it to
206+
// [MultiFrameImageStreamCompleter] so any errors aren't reported
207+
// twice (once from the MultiFrameImageStreamCompleter) and again
208+
// from the wrapping [ForwardingImageStreamCompleter].
209+
final ui.Codec codec = await ui_web.createImageCodecFromUrl(
210+
resolved,
211+
chunkCallback: (int bytes, int total) {
212+
chunkEvents.add(ImageChunkEvent(
213+
cumulativeBytesLoaded: bytes, expectedTotalBytes: total));
214+
},
215+
);
216+
return MultiFrameImageStreamCompleter(
217+
chunkEvents: chunkEvents.stream,
218+
codec: Future<ui.Codec>.value(codec),
219+
scale: key.scale,
220+
debugLabel: key.url,
221+
informationCollector: _imageStreamInformationCollector(key),
222+
);
223+
}
224+
}
132225

133-
request.addEventListener('load', (web.Event e) {
134-
final int status = request.status;
135-
final bool accepted = status >= 200 && status < 300;
136-
final bool fileUri = status == 0; // file:// URIs have status of 0.
137-
final bool notModified = status == 304;
138-
final bool unknownRedirect = status > 307 && status < 400;
139-
final bool success =
140-
accepted || fileUri || notModified || unknownRedirect;
226+
Future<ui.Codec> _fetchImageBytes(
227+
_SimpleDecoderCallback decode,
228+
) async {
229+
final Uri resolved = Uri.base.resolve(url);
141230

142-
if (success) {
143-
completer.complete(request);
144-
} else {
145-
completer.completeError(e);
146-
throw image_provider.NetworkImageLoadException(
147-
statusCode: status, uri: resolved);
148-
}
149-
}.toJS);
231+
final bool containsNetworkImageHeaders = headers?.isNotEmpty ?? false;
150232

151-
request.addEventListener('error',
152-
((JSObject e) => completer.completeError(e)).toJS);
233+
final Completer<web.XMLHttpRequest> completer =
234+
Completer<web.XMLHttpRequest>();
235+
final web.XMLHttpRequest request = httpRequestFactory();
153236

154-
request.send();
237+
request.open('GET', url, true);
238+
request.responseType = 'arraybuffer';
239+
if (containsNetworkImageHeaders) {
240+
headers!.forEach((String header, String value) {
241+
request.setRequestHeader(header, value);
242+
});
243+
}
155244

156-
await completer.future;
245+
request.addEventListener('load', (web.Event e) {
246+
final int status = request.status;
247+
final bool accepted = status >= 200 && status < 300;
248+
final bool fileUri = status == 0; // file:// URIs have status of 0.
249+
final bool notModified = status == 304;
250+
final bool unknownRedirect = status > 307 && status < 400;
251+
final bool success =
252+
accepted || fileUri || notModified || unknownRedirect;
253+
254+
if (success) {
255+
completer.complete(request);
256+
} else {
257+
completer.completeError(image_provider.NetworkImageLoadException(
258+
statusCode: status, uri: resolved));
259+
}
260+
}.toJS);
261+
262+
request.addEventListener(
263+
'error',
264+
((JSObject e) =>
265+
completer.completeError(image_provider.NetworkImageLoadException(
266+
statusCode: request.status,
267+
uri: resolved,
268+
))).toJS,
269+
);
157270

158-
final Uint8List bytes = (request.response! as JSArrayBuffer).toDart.asUint8List();
271+
request.send();
159272

160-
if (bytes.lengthInBytes == 0) {
161-
throw image_provider.NetworkImageLoadException(
162-
statusCode: request.status, uri: resolved);
163-
}
164-
return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
165-
} else {
166-
return ui_web.createImageCodecFromUrl(
167-
resolved,
168-
chunkCallback: (int bytes, int total) {
169-
chunkEvents.add(ImageChunkEvent(
170-
cumulativeBytesLoaded: bytes, expectedTotalBytes: total));
171-
},
172-
);
273+
await completer.future;
274+
275+
final Uint8List bytes = (request.response! as JSArrayBuffer).toDart.asUint8List();
276+
277+
if (bytes.lengthInBytes == 0) {
278+
throw image_provider.NetworkImageLoadException(
279+
statusCode: request.status, uri: resolved);
173280
}
281+
282+
return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
174283
}
175284

176285
@override
@@ -187,3 +296,61 @@ class NetworkImage
187296
@override
188297
String toString() => '${objectRuntimeType(this, 'NetworkImage')}("$url", scale: ${scale.toStringAsFixed(1)})';
189298
}
299+
300+
/// An [ImageStreamCompleter] that delegates to another [ImageStreamCompleter]
301+
/// that is loaded asynchronously.
302+
///
303+
/// This completer keeps its child completer alive until this completer is disposed.
304+
class _ForwardingImageStreamCompleter extends ImageStreamCompleter {
305+
_ForwardingImageStreamCompleter(this.task,
306+
{InformationCollector? informationCollector, String? debugLabel}) {
307+
this.debugLabel = debugLabel;
308+
task.then((ImageStreamCompleter value) {
309+
resolved = true;
310+
if (_disposed) {
311+
// Add a listener since the delegate completer won't dispose if it never
312+
// had a listener.
313+
value.addListener(ImageStreamListener((_, __) {}));
314+
value.maybeDispose();
315+
return;
316+
}
317+
completer = value;
318+
handle = completer.keepAlive();
319+
completer.addListener(ImageStreamListener(
320+
(ImageInfo image, bool synchronousCall) {
321+
setImage(image);
322+
},
323+
onChunk: (ImageChunkEvent event) {
324+
reportImageChunkEvent(event);
325+
},
326+
onError:(Object exception, StackTrace? stackTrace) {
327+
reportError(exception: exception, stack: stackTrace);
328+
},
329+
));
330+
}, onError: (Object error, StackTrace stack) {
331+
reportError(
332+
context: ErrorDescription('resolving an image stream completer'),
333+
exception: error,
334+
stack: stack,
335+
informationCollector: informationCollector,
336+
silent: true,
337+
);
338+
});
339+
}
340+
341+
final Future<ImageStreamCompleter> task;
342+
bool resolved = false;
343+
late final ImageStreamCompleter completer;
344+
late final ImageStreamCompleterHandle handle;
345+
346+
bool _disposed = false;
347+
348+
@override
349+
void onDisposed() {
350+
if (resolved) {
351+
handle.dispose();
352+
}
353+
_disposed = true;
354+
super.onDisposed();
355+
}
356+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:ui' as ui;
6+
7+
import 'image_stream.dart';
8+
9+
/// An [ImageInfo] object indicating that the image can only be displayed in
10+
/// an <img> element, and no [dart:ui.Image] can be created for it.
11+
///
12+
/// This occurs on the web when the image resource is from a different origin
13+
/// and is not configured for CORS. Since the image bytes cannot be directly
14+
/// fetched, [ui.Image]s cannot be created from it. However, the image can
15+
/// still be displayed if an <img> element is used.
16+
class WebImageInfo implements ImageInfo {
17+
@override
18+
ImageInfo clone() => _unsupported();
19+
20+
@override
21+
String? get debugLabel => _unsupported();
22+
23+
@override
24+
void dispose() => _unsupported();
25+
26+
@override
27+
ui.Image get image => _unsupported();
28+
29+
@override
30+
bool isCloneOf(ImageInfo other) => _unsupported();
31+
32+
@override
33+
double get scale => _unsupported();
34+
35+
@override
36+
int get sizeBytes => _unsupported();
37+
38+
Never _unsupported() => throw UnsupportedError(
39+
'WebImageInfo should never be instantiated in a non-web context.');
40+
}

0 commit comments

Comments
 (0)