diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart index 3e980842eda..9a51bb74fca 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart @@ -3,11 +3,11 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:html' as html; import 'dart:typed_data'; import 'package:ui/ui.dart' as ui; +import '../dom.dart'; import '../html_image_codec.dart'; import '../safe_browser_api.dart'; import '../util.dart'; @@ -61,7 +61,7 @@ void skiaDecodeImageFromPixels( ); if (skImage == null) { - html.window.console.warn('Failed to create image from pixels.'); + domWindow.console.warn('Failed to create image from pixels.'); return; } @@ -82,11 +82,11 @@ class ImageCodecException implements Exception { const String _kNetworkImageMessage = 'Failed to load network image.'; -typedef HttpRequestFactory = html.HttpRequest Function(); +typedef HttpRequestFactory = DomXMLHttpRequest Function(); // ignore: prefer_function_declarations_over_variables -HttpRequestFactory httpRequestFactory = () => html.HttpRequest(); +HttpRequestFactory httpRequestFactory = () => createDomXMLHttpRequest(); void debugRestoreHttpRequestFactory() { - httpRequestFactory = () => html.HttpRequest(); + httpRequestFactory = () => createDomXMLHttpRequest(); } /// Instantiates a [ui.Codec] backed by an `SkAnimatedImage` from Skia after @@ -106,23 +106,24 @@ Future fetchImage( String url, WebOnlyImageCodecChunkCallback? chunkCallback) { final Completer completer = Completer(); - final html.HttpRequest request = httpRequestFactory(); - request.open('GET', url, async: true); + final DomXMLHttpRequest request = httpRequestFactory(); + request.open('GET', url, true); request.responseType = 'arraybuffer'; if (chunkCallback != null) { - request.onProgress.listen((html.ProgressEvent event) { + request.addEventListener('progress', allowInterop((DomEvent event) { + event = event as DomProgressEvent; chunkCallback.call(event.loaded!, event.total!); - }); + })); } - request.onError.listen((html.ProgressEvent event) { + request.addEventListener('error', allowInterop((DomEvent 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) { + request.addEventListener('load', allowInterop((DomEvent event) { final int status = request.status!; final bool accepted = status >= 200 && status < 300; final bool fileUri = status == 0; // file:// URIs have status of 0. @@ -140,7 +141,7 @@ Future fetchImage( } completer.complete(Uint8List.view(request.response as ByteBuffer)); - }); + })); request.send(); return completer.future; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image_web_codecs.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image_web_codecs.dart index 1d3cfff1dcb..15866d6cc94 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image_web_codecs.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image_web_codecs.dart @@ -10,7 +10,6 @@ import 'dart:async'; import 'dart:convert' show base64; -import 'dart:html' as html; import 'dart:math' as math; import 'dart:typed_data'; @@ -18,6 +17,7 @@ import 'package:meta/meta.dart'; import 'package:ui/ui.dart' as ui; import '../alarm_clock.dart'; +import '../dom.dart'; import '../safe_browser_api.dart'; import '../util.dart'; import 'canvaskit_api.dart'; @@ -202,8 +202,8 @@ class CkBrowserImageDecoder implements ui.Codec { return webDecoder; } catch (error) { - if (error is html.DomException) { - if (error.name == html.DomException.NOT_SUPPORTED) { + if (domInstanceOfString(error, 'DOMException')) { + if ((error as DomException).name == DomException.notSupported) { throw ImageCodecException( 'Image file format ($contentType) is not supported by this browser\'s ImageDecoder API.\n' 'Image source: $debugSource', @@ -455,11 +455,10 @@ Future readVideoFramePixelsUnmodified(VideoFrame videoFrame) async { Future encodeVideoFrameAsPng(VideoFrame videoFrame) async { final int width = videoFrame.displayWidth; final int height = videoFrame.displayHeight; - final html.CanvasElement canvas = html.CanvasElement() - ..width = width - ..height = height; - final html.CanvasRenderingContext2D ctx = canvas.context2D; - ctx.drawImage(videoFrame, 0, 0); - final String pngBase64 = canvas.toDataUrl().substring('data:image/png;base64,'.length); + final DomCanvasElement canvas = createDomCanvasElement(width: width, height: + height); + final DomCanvasRenderingContext2D ctx = canvas.getContext2D; + ctx.drawImage(videoFrame as DomCanvasImageSource, 0, 0); + final String pngBase64 = canvas.toDataURL().substring('data:image/png;base64,'.length); return base64.decode(pngBase64); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart index bceb79784f0..79e93339c5a 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart @@ -19,11 +19,20 @@ import 'package:js/js_util.dart' as js_util; class DomWindow {} extension DomWindowExtension on DomWindow { + external DomConsole get console; external DomDocument get document; external DomNavigator get navigator; external DomPerformance get performance; Future fetch(String url) => - js_util.promiseToFuture(js_util.callMethod(this, 'fetch', [url])); + js_util.promiseToFuture(js_util.callMethod(this, 'fetch', [url])); +} + +@JS() +@staticInterop +class DomConsole {} + +extension DomConsoleExtension on DomConsole { + external void warn(Object? arg); } @JS('window') @@ -88,6 +97,15 @@ extension DomEventExtension on DomEvent { external void stopPropagation(); } +@JS() +@staticInterop +class DomProgressEvent extends DomEvent {} + +extension DomProgressEventExtension on DomProgressEvent { + external int? get loaded; + external int? get total; +} + @JS() @staticInterop class DomNode extends DomEventTarget {} @@ -225,6 +243,7 @@ extension DomCanvasElementExtension on DomCanvasElement { external set width(int? value); external int? get height; external set height(int? value); + external String toDataURL([String? type]); Object? getContext(String contextType, [Map? attributes]) { return js_util.callMethod(this, 'getContext', [ @@ -232,25 +251,79 @@ extension DomCanvasElementExtension on DomCanvasElement { if (attributes != null) js_util.jsify(attributes) ]); } + + DomCanvasRenderingContext2D get getContext2D => + getContext('2d')! as DomCanvasRenderingContext2D; +} + +@JS() +@staticInterop +abstract class DomCanvasImageSource {} + +@JS() +@staticInterop +class DomCanvasRenderingContext2D {} + +extension DomCanvasRenderingContext2DExtension on DomCanvasRenderingContext2D { + external void drawImage(DomCanvasImageSource source, num destX, num destY); +} + +@JS() +@staticInterop +class DomXMLHttpRequestEventTarget extends DomEventTarget {} + +@JS('XMLHttpRequest') +@staticInterop +class DomXMLHttpRequest extends DomXMLHttpRequestEventTarget {} + +DomXMLHttpRequest createDomXMLHttpRequest() => + domCallConstructorString('XMLHttpRequest', [])! + as DomXMLHttpRequest; + +extension DomXMLHttpRequestExtension on DomXMLHttpRequest { + external dynamic get response; + external String get responseType; + external int? get status; + external set responseType(String value); + external void open(String method, String url, [bool? async]); + external void send(); } @JS() @staticInterop class DomResponse {} +@JS() +@staticInterop +class DomException { + static const String notSupported = 'NotSupportedError'; +} + +extension DomExceptionExtension on DomException { + external String get name; +} + extension DomResponseExtension on DomResponse { - Future arrayBuffer() => - js_util.promiseToFuture(js_util.callMethod(this, 'arrayBuffer', [])); + Future arrayBuffer() => js_util + .promiseToFuture(js_util.callMethod(this, 'arrayBuffer', [])); Future json() => - js_util.promiseToFuture(js_util.callMethod(this, 'json', [])); + js_util.promiseToFuture(js_util.callMethod(this, 'json', [])); Future text() => - js_util.promiseToFuture(js_util.callMethod(this, 'text', [])); + js_util.promiseToFuture(js_util.callMethod(this, 'text', [])); } Object? domGetConstructor(String constructorName) => js_util.getProperty(domWindow, constructorName); +Object? domCallConstructorString(String constructorName, List args) { + final Object? constructor = domGetConstructor(constructorName); + if (constructor == null) { + return null; + } + return js_util.callConstructor(constructor, args); +} + bool domInstanceOfString(Object? element, String objectType) => js_util.instanceof(element, domGetConstructor(objectType)!); diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/image_golden_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/image_golden_test.dart index e924b5153ee..09bab13c493 100644 --- a/engine/src/flutter/lib/web_ui/test/canvaskit/image_golden_test.dart +++ b/engine/src/flutter/lib/web_ui/test/canvaskit/image_golden_test.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:html' as html; import 'dart:typed_data'; +import 'package:js/js.dart'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; @@ -201,15 +202,15 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { test('skiaInstantiateWebImageCodec loads an image from the network', () async { - httpRequestFactory = () { - return TestHttpRequest() + final TestHttpRequestMock mock = TestHttpRequestMock() ..status = 200 - ..onLoad = Stream.fromIterable([ - html.ProgressEvent('test progress event'), - ]) ..response = kTransparentImage.buffer; - }; - final ui.Codec codec = await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null); + httpRequestFactory = () => TestHttpRequest(mock); + final Future futureCodec = + skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', + null); + mock.sendEvent('load', DomProgressEvent()); + final ui.Codec codec = await futureCodec; expect(codec.frameCount, 1); final ui.Image image = (await codec.getNextFrame()).image; expect(image.height, 1); @@ -251,14 +252,13 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { test('skiaInstantiateWebImageCodec throws exception on request error', () async { - httpRequestFactory = () { - return TestHttpRequest() - ..onError = Stream.fromIterable([ - html.ProgressEvent('test error'), - ]); - }; + final TestHttpRequestMock mock = TestHttpRequestMock(); + httpRequestFactory = () => TestHttpRequest(mock); try { - await skiaInstantiateWebImageCodec('url-does-not-matter', null); + final Future futureCodec = skiaInstantiateWebImageCodec( + 'url-does-not-matter', null); + mock.sendEvent('error', DomProgressEvent()); + await futureCodec; fail('Expected to throw'); } on ImageCodecException catch (exception) { expect( @@ -290,16 +290,15 @@ void _testForImageCodecs({required bool useBrowserImageDecoder}) { test('skiaInstantiateWebImageCodec includes URL in the error for malformed image', () async { - httpRequestFactory = () { - return TestHttpRequest() + final TestHttpRequestMock mock = TestHttpRequestMock() ..status = 200 - ..onLoad = Stream.fromIterable([ - html.ProgressEvent('test progress event'), - ]) ..response = Uint8List(0).buffer; - }; + httpRequestFactory = () => TestHttpRequest(mock); try { - await skiaInstantiateWebImageCodec('http://image-server.com/picture.jpg', null); + final Future futureCodec = skiaInstantiateWebImageCodec( + 'http://image-server.com/picture.jpg', null); + mock.sendEvent('load', DomProgressEvent()); + await futureCodec; fail('Expected to throw'); } on ImageCodecException catch (exception) { if (!browserSupportsImageDecoder) { @@ -702,116 +701,53 @@ void _testCkBrowserImageDecoder() { }); } -class TestHttpRequest implements html.HttpRequest { - @override +class TestHttpRequestMock { String responseType = 'invalid'; - - @override - int? timeout = 10; - - @override - bool? withCredentials = false; - - @override - void abort() { - throw UnimplementedError(); - } - - @override - void addEventListener(String type, html.EventListener? 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, html.EventListener? listener, [bool? useCapture]) { - throw UnimplementedError(); - } - - @override + int timeout = 10; + bool withCredentials = false; 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; + Map listeners = {}; - @override - String get statusText => throw UnimplementedError(); + void open(String method, String url, [bool? async]) {} + void send() {} + void addEventListener(String eventType, DomEventListener listener, [bool? + useCapture]) => + listeners[eventType] = listener; - @override - html.HttpRequestUpload get upload => throw UnimplementedError(); + void sendEvent(String eventType, DomProgressEvent event) => + listeners[eventType]!(event); +} + +@JS() +@anonymous +@staticInterop +class TestHttpRequest implements DomXMLHttpRequest { + factory TestHttpRequest(TestHttpRequestMock mock) { + return TestHttpRequest._( + responseType: mock.responseType, + timeout: mock.timeout, + withCredentials: mock.withCredentials, + response: mock.response, + status: mock.status, + open: allowInterop((String method, String url, [bool? async]) => + mock.open(method, url, async)), + send: allowInterop(() => mock.send()), + addEventListener: allowInterop((String eventType, DomEventListener + listener, [bool? useCapture]) => + mock.addEventListener(eventType, listener, useCapture))); + } + + external factory TestHttpRequest._({ + String responseType, + int timeout, + bool withCredentials, + dynamic response, + int status, + void Function(String method, String url, [bool? async]) open, + void Function() send, + void Function(String eventType, DomEventListener listener) addEventListener + }); } Future expectFrameData(ui.FrameInfo frame, List data) async {