[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
https://github.com/flutter/flutter/issues/145954

Fixes https://github.com/flutter/flutter/issues/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
This commit is contained in:
Harry Terkelsen 2024-11-27 17:15:59 -08:00 committed by GitHub
parent d541354936
commit c519383bcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1131 additions and 90 deletions

View File

@ -10,28 +10,52 @@ import 'dart:ui_web' as ui_web;
import 'package:flutter/foundation.dart';
import '../web.dart' as web;
import '_web_image_info_web.dart';
import 'image_provider.dart' as image_provider;
import 'image_stream.dart';
/// Creates a type for an overridable factory function for testing purposes.
/// The type for an overridable factory function for creating an HTTP request,
/// used for testing purposes.
typedef HttpRequestFactory = web.XMLHttpRequest Function();
/// The type for an overridable factory function for creating <img> elements,
/// used for testing purposes.
typedef ImgElementFactory = web.HTMLImageElement Function();
// Method signature for _loadAsync decode callbacks.
typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer buffer);
/// Default HTTP client.
/// The default HTTP client.
web.XMLHttpRequest _httpClient() {
return web.XMLHttpRequest();
}
/// Creates an overridable factory function.
@visibleForTesting
HttpRequestFactory httpRequestFactory = _httpClient;
/// Restores to the default HTTP request factory.
/// Restores the default HTTP request factory.
@visibleForTesting
void debugRestoreHttpRequestFactory() {
httpRequestFactory = _httpClient;
}
/// The default <img> element factory.
web.HTMLImageElement _imgElementFactory() {
return web.document.createElement('img') as web.HTMLImageElement;
}
/// The factory function that creates <img> elements, can be overridden for
/// tests.
@visibleForTesting
ImgElementFactory imgElementFactory = _imgElementFactory;
/// Restores the default <img> element factory.
@visibleForTesting
void debugRestoreImgElementFactory() {
imgElementFactory = _imgElementFactory;
}
/// The web implementation of [image_provider.NetworkImage].
///
/// NetworkImage on the web does not support decoding to a specified size.
@ -64,12 +88,14 @@ class NetworkImage
final StreamController<ImageChunkEvent> chunkEvents =
StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
chunkEvents: chunkEvents.stream,
codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
scale: key.scale,
debugLabel: key.url,
return _ForwardingImageStreamCompleter(
_loadAsync(
key as NetworkImage,
decode,
chunkEvents,
),
informationCollector: _imageStreamInformationCollector(key),
debugLabel: key.url,
);
}
@ -80,12 +106,14 @@ class NetworkImage
// has been loaded or an error is thrown.
final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
chunkEvents: chunkEvents.stream,
codec: _loadAsync(key as NetworkImage, decode, chunkEvents),
scale: key.scale,
debugLabel: key.url,
return _ForwardingImageStreamCompleter(
_loadAsync(
key as NetworkImage,
decode,
chunkEvents,
),
informationCollector: _imageStreamInformationCollector(key),
debugLabel: key.url,
);
}
@ -101,10 +129,10 @@ class NetworkImage
return collector;
}
// Html renderer does not support decoding network images to a specified size. The decode parameter
// HTML renderer does not support decoding network images to a specified size. The decode parameter
// here is ignored and `ui_web.createImageCodecFromUrl` will be used directly
// in place of the typical `instantiateImageCodec` method.
Future<ui.Codec> _loadAsync(
Future<ImageStreamCompleter> _loadAsync(
NetworkImage key,
_SimpleDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
@ -117,62 +145,143 @@ class NetworkImage
// We use a different method when headers are set because the
// `ui_web.createImageCodecFromUrl` method is not capable of handling headers.
if (isSkiaWeb || containsNetworkImageHeaders) {
final Completer<web.XMLHttpRequest> completer =
Completer<web.XMLHttpRequest>();
final web.XMLHttpRequest request = httpRequestFactory();
if (containsNetworkImageHeaders) {
// It is not possible to load an <img> element and pass the headers with
// the request to fetch the image. Since the user has provided headers,
// this function should assume the headers are required to resolve to
// the correct resource and should not attempt to load the image in an
// <img> tag without the headers.
request.open('GET', key.url, true);
request.responseType = 'arraybuffer';
if (containsNetworkImageHeaders) {
key.headers!.forEach((String header, String value) {
request.setRequestHeader(header, value);
});
// Resolve the Codec before passing it to
// [MultiFrameImageStreamCompleter] so any errors aren't reported
// twice (once from the MultiFrameImageStreamCompleter and again
// from the wrapping [ForwardingImageStreamCompleter]).
final ui.Codec codec = await _fetchImageBytes(decode);
return MultiFrameImageStreamCompleter(
chunkEvents: chunkEvents.stream,
codec: Future<ui.Codec>.value(codec),
scale: key.scale,
debugLabel: key.url,
informationCollector: _imageStreamInformationCollector(key),
);
} else if (isSkiaWeb) {
try {
// Resolve the Codec before passing it to
// [MultiFrameImageStreamCompleter] so any errors aren't reported
// twice (once from the MultiFrameImageStreamCompleter and again
// from the wrapping [ForwardingImageStreamCompleter]).
final ui.Codec codec = await _fetchImageBytes(decode);
return MultiFrameImageStreamCompleter(
chunkEvents: chunkEvents.stream,
codec: Future<ui.Codec>.value(codec),
scale: key.scale,
debugLabel: key.url,
informationCollector: _imageStreamInformationCollector(key),
);
} catch (e) {
// If we failed to fetch the bytes, try to load the image in an <img>
// element instead.
final web.HTMLImageElement imageElement = imgElementFactory();
imageElement.src = key.url;
// Decode the <img> element before creating the ImageStreamCompleter
// to avoid double reporting the error.
await imageElement.decode().toDart;
return OneFrameImageStreamCompleter(
Future<ImageInfo>.value(
WebImageInfo(
imageElement,
debugLabel: key.url,
),
),
informationCollector: _imageStreamInformationCollector(key),
)..debugLabel = key.url;
}
request.addEventListener('load', (web.Event e) {
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.complete(request);
} else {
completer.completeError(e);
throw image_provider.NetworkImageLoadException(
statusCode: status, uri: resolved);
}
}.toJS);
request.addEventListener('error',
((JSObject e) => completer.completeError(e)).toJS);
request.send();
await completer.future;
final Uint8List bytes = (request.response! as JSArrayBuffer).toDart.asUint8List();
if (bytes.lengthInBytes == 0) {
throw image_provider.NetworkImageLoadException(
statusCode: request.status, uri: resolved);
}
return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
} else {
return ui_web.createImageCodecFromUrl(
resolved,
chunkCallback: (int bytes, int total) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: bytes, expectedTotalBytes: total));
},
// This branch is only hit by the HTML renderer, which is deprecated. The
// HTML renderer supports loading images with CORS restrictions, so we
// don't need to catch errors and try loading the image in an <img> tag
// in this case.
// Resolve the Codec before passing it to
// [MultiFrameImageStreamCompleter] so any errors aren't reported
// twice (once from the MultiFrameImageStreamCompleter) and again
// from the wrapping [ForwardingImageStreamCompleter].
final ui.Codec codec = await ui_web.createImageCodecFromUrl(
resolved,
chunkCallback: (int bytes, int total) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: bytes, expectedTotalBytes: total));
},
);
return MultiFrameImageStreamCompleter(
chunkEvents: chunkEvents.stream,
codec: Future<ui.Codec>.value(codec),
scale: key.scale,
debugLabel: key.url,
informationCollector: _imageStreamInformationCollector(key),
);
}
}
Future<ui.Codec> _fetchImageBytes(
_SimpleDecoderCallback decode,
) async {
final Uri resolved = Uri.base.resolve(url);
final bool containsNetworkImageHeaders = headers?.isNotEmpty ?? false;
final Completer<web.XMLHttpRequest> completer =
Completer<web.XMLHttpRequest>();
final web.XMLHttpRequest request = httpRequestFactory();
request.open('GET', url, true);
request.responseType = 'arraybuffer';
if (containsNetworkImageHeaders) {
headers!.forEach((String header, String value) {
request.setRequestHeader(header, value);
});
}
request.addEventListener('load', (web.Event e) {
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.complete(request);
} else {
completer.completeError(image_provider.NetworkImageLoadException(
statusCode: status, uri: resolved));
}
}.toJS);
request.addEventListener(
'error',
((JSObject e) =>
completer.completeError(image_provider.NetworkImageLoadException(
statusCode: request.status,
uri: resolved,
))).toJS,
);
request.send();
await completer.future;
final Uint8List bytes = (request.response! as JSArrayBuffer).toDart.asUint8List();
if (bytes.lengthInBytes == 0) {
throw image_provider.NetworkImageLoadException(
statusCode: request.status, uri: resolved);
}
return decode(await ui.ImmutableBuffer.fromUint8List(bytes));
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
@ -187,3 +296,61 @@ class NetworkImage
@override
String toString() => '${objectRuntimeType(this, 'NetworkImage')}("$url", scale: ${scale.toStringAsFixed(1)})';
}
/// An [ImageStreamCompleter] that delegates to another [ImageStreamCompleter]
/// that is loaded asynchronously.
///
/// This completer keeps its child completer alive until this completer is disposed.
class _ForwardingImageStreamCompleter extends ImageStreamCompleter {
_ForwardingImageStreamCompleter(this.task,
{InformationCollector? informationCollector, String? debugLabel}) {
this.debugLabel = debugLabel;
task.then((ImageStreamCompleter value) {
resolved = true;
if (_disposed) {
// Add a listener since the delegate completer won't dispose if it never
// had a listener.
value.addListener(ImageStreamListener((_, __) {}));
value.maybeDispose();
return;
}
completer = value;
handle = completer.keepAlive();
completer.addListener(ImageStreamListener(
(ImageInfo image, bool synchronousCall) {
setImage(image);
},
onChunk: (ImageChunkEvent event) {
reportImageChunkEvent(event);
},
onError:(Object exception, StackTrace? stackTrace) {
reportError(exception: exception, stack: stackTrace);
},
));
}, onError: (Object error, StackTrace stack) {
reportError(
context: ErrorDescription('resolving an image stream completer'),
exception: error,
stack: stack,
informationCollector: informationCollector,
silent: true,
);
});
}
final Future<ImageStreamCompleter> task;
bool resolved = false;
late final ImageStreamCompleter completer;
late final ImageStreamCompleterHandle handle;
bool _disposed = false;
@override
void onDisposed() {
if (resolved) {
handle.dispose();
}
_disposed = true;
super.onDisposed();
}
}

View File

@ -0,0 +1,40 @@
// Copyright 2014 The Flutter 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:ui' as ui;
import 'image_stream.dart';
/// An [ImageInfo] object indicating that the image can only be displayed in
/// an <img> element, and no [dart:ui.Image] can be created for it.
///
/// This occurs on the web when the image resource is from a different origin
/// and is not configured for CORS. Since the image bytes cannot be directly
/// fetched, [ui.Image]s cannot be created from it. However, the image can
/// still be displayed if an <img> element is used.
class WebImageInfo implements ImageInfo {
@override
ImageInfo clone() => _unsupported();
@override
String? get debugLabel => _unsupported();
@override
void dispose() => _unsupported();
@override
ui.Image get image => _unsupported();
@override
bool isCloneOf(ImageInfo other) => _unsupported();
@override
double get scale => _unsupported();
@override
int get sizeBytes => _unsupported();
Never _unsupported() => throw UnsupportedError(
'WebImageInfo should never be instantiated in a non-web context.');
}

View File

@ -0,0 +1,70 @@
// Copyright 2014 The Flutter 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:ui';
import '../web.dart' as web;
import 'image_stream.dart';
/// An [ImageInfo] object indicating that the image can only be displayed in
/// an <img> element, and no [dart:ui.Image] can be created for it.
///
/// This occurs on the web when the image resource is from a different origin
/// and is not configured for CORS. Since the image bytes cannot be directly
/// fetched, [ui.Image]s cannot be created from it. However, the image can
/// still be displayed if an <img> element is used.
class WebImageInfo implements ImageInfo {
/// Creates a new [WebImageInfo] from a given <img> element.
WebImageInfo(this.htmlImage, {this.debugLabel});
/// The <img> element used to display this image. This <img> element has
/// already been decoded, so size information can be retrieved from it.
final web.HTMLImageElement htmlImage;
@override
final String? debugLabel;
@override
WebImageInfo clone() {
// There is no need to actually clone the <img> element here. We create
// another reference to the <img> element and let the browser garbage
// collect it when there are no more live references.
return WebImageInfo(
htmlImage,
debugLabel: debugLabel,
);
}
@override
void dispose() {
// There is nothing to do here. There is no way to delete an element
// directly, the most we can do is remove it from the DOM. But the <img>
// element here is never even added to the DOM. The browser will
// automatically garbage collect the element when there are no longer any
// live references to it.
}
@override
Image get image => throw UnsupportedError(
'Could not create image data for this image because access to it is '
'restricted by the Same-Origin Policy.\n'
'See https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy');
@override
bool isCloneOf(ImageInfo other) {
if (other is! WebImageInfo) {
return false;
}
// It is a clone if it points to the same <img> element.
return other.htmlImage == htmlImage && other.debugLabel == debugLabel;
}
@override
double get scale => 1.0;
@override
int get sizeBytes =>
(4 * htmlImage.naturalWidth * htmlImage.naturalHeight).toInt();
}

View File

@ -690,6 +690,24 @@ abstract class ImageStreamCompleter with Diagnosticable {
bool _disposed = false;
/// Called when this [ImageStreamCompleter] has actually been disposed.
///
/// Subclasses should override this if they need to clean up resources when
/// they are disposed.
@mustCallSuper
@protected
void onDisposed() {}
/// Disposes this [ImageStreamCompleter] unless:
/// 1. It has never had a listener
/// 2. It is already disposed
/// 3. It has listeners.
/// 4. It has active "keep alive" handles.
@nonVirtual
void maybeDispose() {
_maybeDispose();
}
@mustCallSuper
void _maybeDispose() {
if (!_hadAtLeastOneListener || _disposed || _listeners.isNotEmpty || _keepAliveHandles != 0) {
@ -700,6 +718,7 @@ abstract class ImageStreamCompleter with Diagnosticable {
_currentImage?.dispose();
_currentImage = null;
_disposed = true;
onDisposed();
}
void _checkDisposed() {

View File

@ -67,6 +67,7 @@ extension type DOMTokenList._(JSObject _) implements JSObject {
extension type Element._(JSObject _) implements Node, JSObject {
external DOMTokenList get classList;
external void append(JSAny nodes);
external void remove();
}
extension type Event._(JSObject _) implements JSObject {}
@ -83,6 +84,7 @@ extension type HTMLElement._(JSObject _) implements Element, JSObject {
external String get innerText;
external set innerText(String value);
external CSSStyleDeclaration get style;
external HTMLElement cloneNode(bool deep);
}
extension type HTMLHeadElement._(JSObject _) implements HTMLElement, JSObject {}
@ -91,6 +93,22 @@ extension type HTMLStyleElement._(JSObject _) implements HTMLElement, JSObject {
external CSSStyleSheet? get sheet;
}
extension type HTMLImageElement._(JSObject _) implements HTMLElement, JSObject {
external String get src;
external set src(String value);
external num get naturalWidth;
external num get naturalHeight;
external JSPromise<JSAny?> decode();
}
extension type HTMLCanvasElement._(JSObject _) implements HTMLElement, JSObject {
external int get width;
external set width(int value);
external int get height;
external set height(int value);
external String toDataURL();
}
extension type MediaQueryList._(JSObject _) implements EventTarget, JSObject {
external bool get matches;
}
@ -120,6 +138,7 @@ extension type Window._(JSObject _) implements EventTarget, JSObject {
external Navigator get navigator;
external MediaQueryList matchMedia(String query);
external Selection? getSelection();
external String get origin;
}
extension type XMLHttpRequest._(JSObject _)

View File

@ -0,0 +1,50 @@
// Copyright 2014 The Flutter 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 '../painting/_web_image_info_io.dart';
import 'basic.dart';
import 'framework.dart';
/// A [Widget] that displays an image that is backed by an <img> element.
class RawWebImage extends StatelessWidget {
/// Creates a [RawWebImage].
RawWebImage({
super.key,
required this.image,
this.debugImageLabel,
this.width,
this.height,
this.fit,
this.alignment = Alignment.center,
this.matchTextDirection = false,
}) {
throw UnsupportedError('Cannot create a $RawWebImage when not running on the web');
}
/// The underlying `<img>` element to be displayed.
final WebImageInfo image;
/// A debug label explaining the image.
final String? debugImageLabel;
/// The requested width for this widget.
final double? width;
/// The requested height for this widget.
final double? height;
/// How the `<img>` should be inscribed in the box constraining it.
final BoxFit? fit;
/// How the image should be aligned in the box constraining it.
final AlignmentGeometry alignment;
/// Whether or not the alignment of the image should match the text direction.
final bool matchTextDirection;
@override
Widget build(BuildContext context) {
throw UnsupportedError('It is impossible to instantiate a RawWebImage when not running on the web');
}
}

View File

@ -0,0 +1,382 @@
// Copyright 2014 The Flutter 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:ui_web' as ui_web;
import 'package:flutter/foundation.dart';
import '../painting/_web_image_info_web.dart';
import '../rendering/box.dart';
import '../rendering/shifted_box.dart';
import '../web.dart' as web;
import 'basic.dart';
import 'framework.dart';
import 'platform_view.dart';
/// Displays an `<img>` element with `src` set to [src].
class ImgElementPlatformView extends StatelessWidget {
/// Creates a platform view backed with an `<img>` element.
ImgElementPlatformView(this.src, {super.key}) {
if (!_registered) {
_register();
}
}
static const String _viewType = 'Flutter__ImgElementImage__';
static bool _registered = false;
static void _register() {
assert(!_registered);
_registered = true;
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId,
{Object? params}) {
final Map<Object?, Object?> paramsMap = params! as Map<Object?, Object?>;
// Create a new <img> element. The browser is able to display the image
// without fetching it over the network again.
final web.HTMLImageElement img =
web.document.createElement('img') as web.HTMLImageElement;
img.src = paramsMap['src']! as String;
return img;
});
}
/// The `src` URL for the `<img>` tag.
final String? src;
@override
Widget build(BuildContext context) {
if (src == null) {
return const SizedBox.expand();
}
return HtmlElementView(
viewType: _viewType,
creationParams: <String, String?>{'src': src},
);
}
}
/// A widget which displays and lays out an underlying `<img>` platform view.
class RawWebImage extends SingleChildRenderObjectWidget {
/// Creates a [RawWebImage].
RawWebImage({
super.key,
required this.image,
this.debugImageLabel,
this.width,
this.height,
this.fit,
this.alignment = Alignment.center,
this.matchTextDirection = false,
}) : super(child: ImgElementPlatformView(image.htmlImage.src));
/// The underlying `<img>` element to be displayed.
final WebImageInfo image;
/// A debug label explaining the image.
final String? debugImageLabel;
/// The requested width for this widget.
final double? width;
/// The requested height for this widget.
final double? height;
/// How the `<img>` should be inscribed in the box constraining it.
final BoxFit? fit;
/// How the image should be aligned in the box constraining it.
final AlignmentGeometry alignment;
/// Whether or not the alignment of the image should match the text direction.
final bool matchTextDirection;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderWebImage(
image: image.htmlImage,
width: width,
height: height,
fit: fit,
alignment: alignment,
matchTextDirection: matchTextDirection,
textDirection: matchTextDirection || alignment is! Alignment
? Directionality.of(context)
: null,
);
}
@override
void updateRenderObject(BuildContext context, RenderWebImage renderObject) {
renderObject
..image = image.htmlImage
..width = width
..height = height
..fit = fit
..alignment = alignment
..matchTextDirection = matchTextDirection
..textDirection = matchTextDirection || alignment is! Alignment
? Directionality.of(context)
: null;
}
}
/// Lays out and positions the child `<img>` element similarly to [RenderImage].
class RenderWebImage extends RenderShiftedBox {
/// Creates a new [RenderWebImage].
RenderWebImage({
RenderBox? child,
required web.HTMLImageElement image,
double? width,
double? height,
BoxFit? fit,
AlignmentGeometry alignment = Alignment.center,
bool matchTextDirection = false,
TextDirection? textDirection,
}) : _image = image,
_width = width,
_height = height,
_fit = fit,
_alignment = alignment,
_matchTextDirection = matchTextDirection,
_textDirection = textDirection,
super(child);
Alignment? _resolvedAlignment;
bool? _flipHorizontally;
void _resolve() {
if (_resolvedAlignment != null) {
return;
}
_resolvedAlignment = alignment.resolve(textDirection);
_flipHorizontally =
matchTextDirection && textDirection == TextDirection.rtl;
}
void _markNeedResolution() {
_resolvedAlignment = null;
_flipHorizontally = null;
markNeedsPaint();
}
/// Whether to paint the image in the direction of the [TextDirection].
///
/// If this is true, then in [TextDirection.ltr] contexts, the image will be
/// drawn with its origin in the top left (the "normal" painting direction for
/// images); and in [TextDirection.rtl] contexts, the image will be drawn with
/// a scaling factor of -1 in the horizontal direction so that the origin is
/// in the top right.
///
/// This is occasionally used with images in right-to-left environments, for
/// images that were designed for left-to-right locales. Be careful, when
/// using this, to not flip images with integral shadows, text, or other
/// effects that will look incorrect when flipped.
///
/// If this is set to true, [textDirection] must not be null.
bool get matchTextDirection => _matchTextDirection;
bool _matchTextDirection;
set matchTextDirection(bool value) {
if (value == _matchTextDirection) {
return;
}
_matchTextDirection = value;
_markNeedResolution();
}
/// The text direction with which to resolve [alignment].
///
/// This may be changed to null, but only after the [alignment] and
/// [matchTextDirection] properties have been changed to values that do not
/// depend on the direction.
TextDirection? get textDirection => _textDirection;
TextDirection? _textDirection;
set textDirection(TextDirection? value) {
if (_textDirection == value) {
return;
}
_textDirection = value;
_markNeedResolution();
}
/// The image to display.
web.HTMLImageElement get image => _image;
web.HTMLImageElement _image;
set image(web.HTMLImageElement value) {
if (value == _image) {
return;
}
// If we get a clone of our image, it's the same underlying native data -
// return early.
if (value.src == _image.src) {
return;
}
final bool sizeChanged = _image.naturalWidth != value.naturalWidth ||
_image.naturalHeight != value.naturalHeight;
_image = value;
markNeedsPaint();
if (sizeChanged && (_width == null || _height == null)) {
markNeedsLayout();
}
}
/// If non-null, requires the image to have this width.
///
/// If null, the image will pick a size that best preserves its intrinsic
/// aspect ratio.
double? get width => _width;
double? _width;
set width(double? value) {
if (value == _width) {
return;
}
_width = value;
markNeedsLayout();
}
/// If non-null, require the image to have this height.
///
/// If null, the image will pick a size that best preserves its intrinsic
/// aspect ratio.
double? get height => _height;
double? _height;
set height(double? value) {
if (value == _height) {
return;
}
_height = value;
markNeedsLayout();
}
/// How to inscribe the image into the space allocated during layout.
///
/// The default varies based on the other fields. See the discussion at
/// [paintImage].
BoxFit? get fit => _fit;
BoxFit? _fit;
set fit(BoxFit? value) {
if (value == _fit) {
return;
}
_fit = value;
markNeedsPaint();
}
/// How to align the image within its bounds.
///
/// If this is set to a text-direction-dependent value, [textDirection] must
/// not be null.
AlignmentGeometry get alignment => _alignment;
AlignmentGeometry _alignment;
set alignment(AlignmentGeometry value) {
if (value == _alignment) {
return;
}
_alignment = value;
_markNeedResolution();
}
/// Find a size for the render image within the given constraints.
///
/// - The dimensions of the RenderImage must fit within the constraints.
/// - The aspect ratio of the RenderImage matches the intrinsic aspect
/// ratio of the image.
/// - The RenderImage's dimension are maximal subject to being smaller than
/// the intrinsic size of the image.
Size _sizeForConstraints(BoxConstraints constraints) {
// Folds the given |width| and |height| into |constraints| so they can all
// be treated uniformly.
constraints = BoxConstraints.tightFor(
width: _width,
height: _height,
).enforce(constraints);
return constraints.constrainSizeAndAttemptToPreserveAspectRatio(Size(
_image.naturalWidth.toDouble(),
_image.naturalHeight.toDouble(),
));
}
@override
double computeMinIntrinsicWidth(double height) {
assert(height >= 0.0);
if (_width == null && _height == null) {
return 0.0;
}
return _sizeForConstraints(BoxConstraints.tightForFinite(height: height))
.width;
}
@override
double computeMaxIntrinsicWidth(double height) {
assert(height >= 0.0);
return _sizeForConstraints(BoxConstraints.tightForFinite(height: height))
.width;
}
@override
double computeMinIntrinsicHeight(double width) {
assert(width >= 0.0);
if (_width == null && _height == null) {
return 0.0;
}
return _sizeForConstraints(BoxConstraints.tightForFinite(width: width))
.height;
}
@override
double computeMaxIntrinsicHeight(double width) {
assert(width >= 0.0);
return _sizeForConstraints(BoxConstraints.tightForFinite(width: width))
.height;
}
@override
bool hitTestSelf(Offset position) => true;
@override
@protected
Size computeDryLayout(covariant BoxConstraints constraints) {
return _sizeForConstraints(constraints);
}
@override
void performLayout() {
_resolve();
assert(_resolvedAlignment != null);
assert(_flipHorizontally != null);
size = _sizeForConstraints(constraints);
if (child == null) {
return;
}
final Size inputSize =
Size(image.naturalWidth.toDouble(), image.naturalHeight.toDouble());
fit ??= BoxFit.scaleDown;
final FittedSizes fittedSizes = applyBoxFit(fit!, inputSize, size);
final Size childSize = fittedSizes.destination;
child!.layout(BoxConstraints.tight(childSize));
final double halfWidthDelta = (size.width - childSize.width) / 2.0;
final double halfHeightDelta = (size.height - childSize.height) / 2.0;
final double dx = halfWidthDelta +
(_flipHorizontally! ? -_resolvedAlignment!.x : _resolvedAlignment!.x) *
halfWidthDelta;
final double dy =
halfHeightDelta + _resolvedAlignment!.y * halfHeightDelta;
final BoxParentData childParentData = child!.parentData! as BoxParentData;
childParentData.offset = Offset(dx, dy);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<web.HTMLImageElement>('image', image));
properties.add(DoubleProperty('width', width, defaultValue: null));
properties.add(DoubleProperty('height', height, defaultValue: null));
properties.add(EnumProperty<BoxFit>('fit', fit, defaultValue: null));
properties.add(DiagnosticsProperty<AlignmentGeometry>(
'alignment', alignment,
defaultValue: null));
}
}

View File

@ -17,6 +17,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/semantics.dart';
import '../painting/_web_image_info_io.dart' if (dart.library.js_util) '../painting/_web_image_info_web.dart';
import '_web_image_io.dart' if (dart.library.js_util) '_web_image_web.dart';
import 'basic.dart';
import 'binding.dart';
import 'disposable_build_context.dart';
@ -1287,28 +1289,45 @@ class _ImageState extends State<Image> with WidgetsBindingObserver {
}
}
Widget result = RawImage(
// Do not clone the image, because RawImage is a stateless wrapper.
// The image will be disposed by this state object when it is not needed
// anymore, such as when it is unmounted or when the image stream pushes
// a new image.
image: _imageInfo?.image,
debugImageLabel: _imageInfo?.debugLabel,
width: widget.width,
height: widget.height,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: widget.fit,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
late Widget result;
if (_imageInfo case final WebImageInfo webImage) {
// TODO(harryterkelsen): Support the remaining properties that are
// supported by `RawImage` but not `RawWebImage`. See the following issue
// above for a discussion of the missing properties and suggestions for
// how they can be implemented, https://github.com/flutter/flutter/issues/159565.
result = RawWebImage(
image: webImage,
debugImageLabel: _imageInfo?.debugLabel,
width: widget.width,
height: widget.height,
fit: widget.fit,
alignment: widget.alignment,
matchTextDirection: widget.matchTextDirection,
);
} else {
result = RawImage(
// Do not clone the image, because RawImage is a stateless wrapper.
// The image will be disposed by this state object when it is not needed
// anymore, such as when it is unmounted or when the image stream pushes
// a new image.
image: _imageInfo?.image,
debugImageLabel: _imageInfo?.debugLabel,
width: widget.width,
height: widget.height,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: widget.fit,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
}
if (!widget.excludeFromSemantics) {
result = Semantics(

View File

@ -2,10 +2,14 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide NetworkImage;
import 'package:flutter/src/painting/_network_image_web.dart';
import 'package:flutter/src/painting/_web_image_info_web.dart';
import 'package:flutter/src/web.dart' as web_shim;
import 'package:flutter/src/widgets/_web_image_web.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:web/web.dart' as web;
@ -14,6 +18,7 @@ import '_test_http_request.dart';
void runTests() {
tearDown(() {
debugRestoreHttpRequestFactory();
debugRestoreImgElementFactory();
});
testWidgets('loads an image from the network with headers',
@ -64,14 +69,21 @@ void runTests() {
);
await tester.pumpWidget(image);
expect((tester.takeException() as web.ProgressEvent).type, 'test error');
expect(
tester.takeException(),
isA<NetworkImageLoadException>().having(
(NetworkImageLoadException e) => e.statusCode,
'status code',
404,
),
);
});
testWidgets('loads an image from the network with empty response',
(WidgetTester tester) async {
final TestHttpRequest testHttpRequest = TestHttpRequest()
..status = 200
..mockEvent = MockEvent('load', web.Event('test error'))
..mockEvent = MockEvent('load', web.Event('successful load'))
..response = (Uint8List.fromList(<int>[])).buffer;
httpRequestFactory = () {
@ -92,4 +104,201 @@ void runTests() {
expect(tester.takeException().toString(),
'HTTP request failed, statusCode: 200, https://www.example.com/images/frame3.png');
});
testWidgets('emits a WebImageInfo if the image is cross-origin',
(WidgetTester tester) async {
final TestHttpRequest failingRequest = TestHttpRequest()
..status = 500
..mockEvent = MockEvent('load', web.Event('bytes inaccessible'))
..response = (Uint8List.fromList(<int>[])).buffer;
final TestImgElement testImg = TestImgElement();
httpRequestFactory = () {
return failingRequest.getMock() as web_shim.XMLHttpRequest;
};
imgElementFactory = () {
return testImg.getMock() as web_shim.HTMLImageElement;
};
const NetworkImage networkImage = NetworkImage('https://www.example.com/images/frame4.png');
ImageInfo? imageInfo;
Object? recordedError;
Completer<void>? imageCompleter;
await tester.runAsync(() async {
imageCompleter = Completer<void>();
final ImageStream stream = networkImage.resolve(ImageConfiguration.empty);
stream.addListener(ImageStreamListener((ImageInfo info, bool isSync) {
imageInfo = info;
imageCompleter!.complete();
}, onError: (Object error, StackTrace? stackTrace) {
recordedError = error;
imageCompleter!.complete();
}));
});
await tester.runAsync(() async {
testImg.decodeSuccess();
await imageCompleter!.future;
});
expect(recordedError, isNull);
expect(imageInfo, isA<WebImageInfo>());
final WebImageInfo webImageInfo = imageInfo! as WebImageInfo;
expect(webImageInfo.htmlImage.src, equals('https://www.example.com/images/frame4.png'));
}, skip: !isSkiaWeb);
testWidgets('emits an error if the image is cross-origin but fails to decode',
(WidgetTester tester) async {
final TestHttpRequest failingRequest = TestHttpRequest()
..status = 500
..mockEvent = MockEvent('load', web.Event('bytes inaccessible'))
..response = (Uint8List.fromList(<int>[])).buffer;
final TestImgElement testImg = TestImgElement();
httpRequestFactory = () {
return failingRequest.getMock() as web_shim.XMLHttpRequest;
};
imgElementFactory = () {
return testImg.getMock() as web_shim.HTMLImageElement;
};
const NetworkImage networkImage = NetworkImage('https://www.example.com/images/frame5.png');
ImageInfo? imageInfo;
Object? recordedError;
Completer<void>? imageCompleter;
await tester.runAsync(() async {
imageCompleter = Completer<void>();
final ImageStream stream = networkImage.resolve(ImageConfiguration.empty);
stream.addListener(ImageStreamListener((ImageInfo info, bool isSync) {
imageInfo = info;
imageCompleter!.complete();
}, onError: (Object error, StackTrace? stackTrace) {
recordedError = error;
imageCompleter!.complete();
}));
});
await tester.runAsync(() async {
testImg.decodeFailure();
await imageCompleter!.future;
});
expect(recordedError, isNotNull);
expect(imageInfo, isNull);
}, skip: !isSkiaWeb);
testWidgets('Image renders an image using a Platform View if the image info is WebImageInfo',
(WidgetTester tester) async {
final TestImgElement testImg = TestImgElement();
final _TestImageStreamCompleter streamCompleter = _TestImageStreamCompleter();
final _TestImageProvider imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
await tester.pumpWidget(
Image(
image: imageProvider,
),
);
// Before getting a WebImageInfo, the Image resolves to a RawImage.
expect(find.byType(RawImage), findsOneWidget);
expect(find.byType(RawWebImage), findsNothing);
expect(find.byType(PlatformViewLink), findsNothing);
streamCompleter.setData(imageInfo: WebImageInfo(testImg.getMock() as web_shim.HTMLImageElement));
await tester.pump();
expect(find.byType(RawImage), findsNothing);
// After getting a WebImageInfo, the Image uses a Platform View to render.
expect(find.byType(RawWebImage), findsOneWidget);
expect(find.byType(PlatformViewLink), findsOneWidget);
}, skip: !isSkiaWeb);
}
class _TestImageProvider extends ImageProvider<Object> {
_TestImageProvider({required ImageStreamCompleter streamCompleter}) {
_streamCompleter = streamCompleter;
}
final Completer<ImageInfo> _completer = Completer<ImageInfo>();
late ImageStreamCompleter _streamCompleter;
@override
Future<Object> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<_TestImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(Object key, ImageDecoderCallback decode) {
return _streamCompleter;
}
void complete(web_shim.HTMLImageElement image) {
_completer.complete(WebImageInfo(image));
}
void fail(Object exception, StackTrace? stackTrace) {
_completer.completeError(exception, stackTrace);
}
@override
String toString() => '${describeIdentity(this)}()';
}
/// An [ImageStreamCompleter] that gives access to the added listeners.
///
/// Such an access to listeners is hacky,
/// because it breaks encapsulation by allowing to invoke listeners without
/// taking care about lifecycle of the created images, that may result in not disposed images.
///
/// That's why some tests that use it are opted out from leak tracking.
class _TestImageStreamCompleter extends ImageStreamCompleter {
_TestImageStreamCompleter();
ImageInfo? _currentImage;
final Set<ImageStreamListener> listeners = <ImageStreamListener>{};
@override
void addListener(ImageStreamListener listener) {
listeners.add(listener);
if (_currentImage != null) {
listener.onImage(_currentImage!.clone(), true);
}
}
@override
void removeListener(ImageStreamListener listener) {
listeners.remove(listener);
}
void setData({
ImageInfo? imageInfo,
ImageChunkEvent? chunkEvent,
}) {
if (imageInfo != null) {
_currentImage?.dispose();
_currentImage = imageInfo;
}
final List<ImageStreamListener> localListeners = listeners.toList();
for (final ImageStreamListener listener in localListeners) {
if (imageInfo != null) {
listener.onImage(imageInfo.clone(), false);
}
if (chunkEvent != null && listener.onChunk != null) {
listener.onChunk!(chunkEvent);
}
}
}
void setError({
required Object exception,
StackTrace? stackTrace,
}) {
final List<ImageStreamListener> localListeners = listeners.toList();
for (final ImageStreamListener listener in localListeners) {
listener.onError?.call(exception, stackTrace);
}
}
void dispose() {
final List<ImageStreamListener> listenersCopy = listeners.toList();
listenersCopy.forEach(removeListener);
}
}

View File

@ -83,3 +83,69 @@ class MockEvent {
final String type;
final web.Event event;
}
@JS()
@staticInterop
@anonymous
class ImgElementMock {
external factory ImgElementMock({
JSFunction? decode,
});
}
class TestImgElement {
TestImgElement() {
_mock = ImgElementMock(decode: decode.toJS);
final JSAny mock = _mock as JSAny;
objectDefineProperty(mock, 'src',
<String, JSFunction>{
'get': (() => src).toJS,
'set': ((JSString newValue) {
src = newValue.toDart;
}).toJS,
}.jsify()!,
);
objectDefineProperty(mock, 'naturalWidth',
<String, JSFunction>{
'get': (() => naturalWidth).toJS,
'set': ((JSNumber newValue) {
naturalWidth = newValue.toDartInt;
}).toJS,
}.jsify()!,
);
objectDefineProperty(mock, 'naturalHeight',
<String, JSFunction>{
'get': (() => naturalHeight).toJS,
'set': ((JSNumber newValue) {
naturalHeight = newValue.toDartInt;
}).toJS,
}.jsify()!,
);
}
late ImgElementMock _mock;
String src = '';
int naturalWidth = -1;
int naturalHeight = -1;
late JSFunction _resolveFunc;
late JSFunction _rejectFunc;
JSPromise<JSAny?> decode() {
return JSPromise<JSAny?>((JSFunction resolveFunc, JSFunction rejectFunc) {
_resolveFunc = resolveFunc;
_rejectFunc = rejectFunc;
}.toJS);
}
void decodeSuccess() {
_resolveFunc.callAsFunction();
}
void decodeFailure() {
_rejectFunc.callAsFunction();
}
web.HTMLImageElement getMock() => _mock as web.HTMLImageElement;
}

View File

@ -242,7 +242,7 @@ void main() {
const NetworkImage provider = NetworkImage(url);
final MultiFrameImageStreamCompleter completer = provider.loadBuffer(provider, noOpDecoderBufferCallback) as MultiFrameImageStreamCompleter;
final ImageStreamCompleter completer = provider.loadBuffer(provider, noOpDecoderBufferCallback);
expect(completer.debugLabel, url);
});