From 35b55d51a81968b381d3a1f642bc7821e7cb8d46 Mon Sep 17 00:00:00 2001 From: xster Date: Tue, 31 Jul 2018 14:17:44 -0700 Subject: [PATCH] Add image stream error handling mechanism (#18424) --- .../lib/src/painting/image_stream.dart | 195 +++++-- packages/flutter/lib/src/widgets/image.dart | 27 +- .../test/painting/image_stream_test.dart | 490 ++++++++++-------- packages/flutter/test/widgets/image_test.dart | 314 ++++++++++- 4 files changed, 744 insertions(+), 282 deletions(-) diff --git a/packages/flutter/lib/src/painting/image_stream.dart b/packages/flutter/lib/src/painting/image_stream.dart index 9edcbcbe76..cb1c9299ed 100644 --- a/packages/flutter/lib/src/painting/image_stream.dart +++ b/packages/flutter/lib/src/painting/image_stream.dart @@ -68,6 +68,17 @@ class ImageInfo { /// same stack frame as the call to [ImageStream.addListener]). typedef void ImageListener(ImageInfo image, bool synchronousCall); +/// Signature for reporting errors when resolving images. +/// +/// Used by [ImageStream] and [precacheImage] to report errors. +typedef void ImageErrorListener(dynamic exception, StackTrace stackTrace); + +class _ImageListenerPair { + _ImageListenerPair(this.listener, this.errorListener); + final ImageListener listener; + final ImageErrorListener errorListener; +} + /// A handle to an image resource. /// /// ImageStream represents a handle to a [dart:ui.Image] object and its scale @@ -96,7 +107,7 @@ class ImageStream extends Diagnosticable { ImageStreamCompleter get completer => _completer; ImageStreamCompleter _completer; - List _listeners; + List<_ImageListenerPair> _listeners; /// Assigns a particular [ImageStreamCompleter] to this [ImageStream]. /// @@ -110,9 +121,14 @@ class ImageStream extends Diagnosticable { assert(_completer == null); _completer = value; if (_listeners != null) { - final List initialListeners = _listeners; + final List<_ImageListenerPair> initialListeners = _listeners; _listeners = null; - initialListeners.forEach(_completer.addListener); + for (_ImageListenerPair listenerPair in initialListeners) { + _completer.addListener( + listenerPair.listener, + onError: listenerPair.errorListener, + ); + } } } @@ -127,19 +143,32 @@ class ImageStream extends Diagnosticable { /// occurred. If the listener is added within a render object paint function, /// then use this flag to avoid calling [RenderObject.markNeedsPaint] during /// a paint. - void addListener(ImageListener listener) { + /// + /// An [ImageErrorListener] can also optionally be added along with the + /// `listener`. If an error occurred, `onError` will be called instead of + /// `listener`. + /// + /// Many `listener`s can have the same `onError` and one `listener` can also + /// have multiple `onError` by invoking [addListener] multiple times with + /// a different `onError` each time. + /// + /// Repeated `onError` will only be called once when an error occurs. + void addListener(ImageListener listener, { ImageErrorListener onError }) { if (_completer != null) - return _completer.addListener(listener); - _listeners ??= []; - _listeners.add(listener); + return _completer.addListener(listener, onError: onError); + _listeners ??= <_ImageListenerPair>[]; + _listeners.add(new _ImageListenerPair(listener, onError)); } - /// Stop listening for new concrete [ImageInfo] objects. + /// Stop listening for new concrete [ImageInfo] objects and errors from + /// the `listener`'s associated [ImageErrorListener]. void removeListener(ImageListener listener) { if (_completer != null) return _completer.removeListener(listener); assert(_listeners != null); - _listeners.remove(listener); + _listeners.removeWhere((_ImageListenerPair listenerPair) { + return listenerPair.listener == listener; + }); } /// Returns an object which can be used with `==` to determine if this @@ -164,7 +193,7 @@ class ImageStream extends Diagnosticable { ifPresent: _completer?.toStringShort(), ifNull: 'unresolved', )); - properties.add(new ObjectFlagProperty>( + properties.add(new ObjectFlagProperty>( 'listeners', _listeners, ifPresent: '${_listeners?.length} listener${_listeners?.length == 1 ? "" : "s" }', @@ -182,12 +211,14 @@ class ImageStream extends Diagnosticable { /// [ImageProvider] subclass will return an [ImageStream] and automatically /// configure it with the right [ImageStreamCompleter] when possible. abstract class ImageStreamCompleter extends Diagnosticable { - final List _listeners = []; - ImageInfo _current; + final List<_ImageListenerPair> _listeners = <_ImageListenerPair>[]; + ImageInfo _currentImage; + FlutterErrorDetails _currentError; /// Adds a listener callback that is called whenever a new concrete [ImageInfo] - /// object is available. If a concrete image is already available, this object - /// will call the listener synchronously. + /// object is available or an error is reported. If a concrete image is + /// already available, or if an error has been already reported, this object + /// will call the listener or error listener synchronously. /// /// If the [ImageStreamCompleter] completes multiple images over its lifetime, /// this listener will fire multiple times. @@ -196,45 +227,116 @@ abstract class ImageStreamCompleter extends Diagnosticable { /// occurred. If the listener is added within a render object paint function, /// then use this flag to avoid calling [RenderObject.markNeedsPaint] during /// a paint. - void addListener(ImageListener listener) { - _listeners.add(listener); - if (_current != null) { + void addListener(ImageListener listener, { ImageErrorListener onError }) { + _listeners.add(new _ImageListenerPair(listener, onError)); + if (_currentImage != null) { try { - listener(_current, true); + listener(_currentImage, true); } catch (exception, stack) { - _handleImageError('by a synchronously-called image listener', exception, stack); + reportError( + context: 'by a synchronously-called image listener', + exception: exception, + stack: stack, + ); } } + if (_currentError != null && onError != null) { + try { + onError(_currentError.exception, _currentError.stack); + } catch (exception, stack) { + FlutterError.reportError( + new FlutterErrorDetails( + exception: exception, + library: 'image resource service', + context: 'by a synchronously-called image error listener', + stack: stack, + ), + ); + } + } } - /// Stop listening for new concrete [ImageInfo] objects. + /// Stop listening for new concrete [ImageInfo] objects and errors from + /// its associated [ImageErrorListener]. void removeListener(ImageListener listener) { - _listeners.remove(listener); + _listeners.removeWhere((_ImageListenerPair listenerPair) { + return listenerPair.listener == listener; + }); } /// Calls all the registered listeners to notify them of a new image. @protected void setImage(ImageInfo image) { - _current = image; + _currentImage = image; if (_listeners.isEmpty) return; - final List localListeners = new List.from(_listeners); + final List localListeners = _listeners.map( + (_ImageListenerPair listenerPair) => listenerPair.listener + ).toList(); for (ImageListener listener in localListeners) { try { listener(image, false); } catch (exception, stack) { - _handleImageError('by an image listener', exception, stack); + reportError( + context: 'by an image listener', + exception: exception, + stack: stack, + ); } } } - void _handleImageError(String context, dynamic exception, dynamic stack) { - FlutterError.reportError(new FlutterErrorDetails( + /// Calls all the registered error listeners to notify them of an error that + /// occurred while resolving the image. + /// + /// If the same error listener is attached with multiple listeners, that + /// error listener will only be notified once. + /// + /// If no error listeners are attached, a [FlutterError] will be reported + /// instead. + @protected + void reportError({ + String context, + dynamic exception, + StackTrace stack, + InformationCollector informationCollector, + bool silent = false, + }) { + _currentError = new FlutterErrorDetails( exception: exception, stack: stack, library: 'image resource service', - context: context - )); + context: context, + informationCollector: informationCollector, + silent: silent, + ); + + // Many listeners can have the same error listener. De-duplicate. + final List localErrorListeners = + _listeners.map( + (_ImageListenerPair listenerPair) => listenerPair.errorListener + ).where( + (ImageErrorListener errorListener) => errorListener != null + ).toList(); + + if (localErrorListeners.isEmpty) { + FlutterError.reportError(_currentError); + } else { + for (ImageErrorListener errorListener in localErrorListeners) { + try { + errorListener(exception, stack); + } catch (exception, stack) { + FlutterError.reportError( + new FlutterErrorDetails( + context: 'by an image error listener', + library: 'image resource service', + exception: exception, + stack: stack, + ), + ); + } + } + } } /// Accumulates a list of strings describing the object's state. Subclasses @@ -242,8 +344,8 @@ abstract class ImageStreamCompleter extends Diagnosticable { @override void debugFillProperties(DiagnosticPropertiesBuilder description) { super.debugFillProperties(description); - description.add(new DiagnosticsProperty('current', _current, ifNull: 'unresolved', showName: false)); - description.add(new ObjectFlagProperty>( + description.add(new DiagnosticsProperty('current', _currentImage, ifNull: 'unresolved', showName: false)); + description.add(new ObjectFlagProperty>( 'listeners', _listeners, ifPresent: '${_listeners?.length} listener${_listeners?.length == 1 ? "" : "s" }', @@ -271,14 +373,13 @@ class OneFrameImageStreamCompleter extends ImageStreamCompleter { OneFrameImageStreamCompleter(Future image, { InformationCollector informationCollector }) : assert(image != null) { image.then(setImage, onError: (dynamic error, StackTrace stack) { - FlutterError.reportError(new FlutterErrorDetails( + reportError( + context: 'resolving a single-frame image stream', exception: error, stack: stack, - library: 'services', - context: 'resolving a single-frame image stream', informationCollector: informationCollector, silent: true, - )); + ); }); } } @@ -334,14 +435,13 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { _framesEmitted = 0, _timer = null { codec.then(_handleCodecReady, onError: (dynamic error, StackTrace stack) { - FlutterError.reportError(new FlutterErrorDetails( + reportError( + context: 'resolving an image codec', exception: error, stack: stack, - library: 'services', - context: 'resolving an image codec', informationCollector: informationCollector, silent: true, - )); + ); }); } @@ -397,14 +497,13 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { try { _nextFrame = await _codec.getNextFrame(); } catch (exception, stack) { - FlutterError.reportError(new FlutterErrorDetails( - exception: exception, - stack: stack, - library: 'services', - context: 'resolving an image frame', - informationCollector: _informationCollector, - silent: true, - )); + reportError( + context: 'resolving an image frame', + exception: exception, + stack: stack, + informationCollector: _informationCollector, + silent: true, + ); return; } if (_codec.frameCount == 1) { @@ -424,11 +523,11 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { bool get _hasActiveListeners => _listeners.isNotEmpty; @override - void addListener(ImageListener listener) { + void addListener(ImageListener listener, { ImageErrorListener onError }) { if (!_hasActiveListeners && _codec != null) { _decodeNextFrameAndSchedule(); } - super.addListener(listener); + super.addListener(listener, onError: onError); } @override diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index 6c96af3137..c2b90313bb 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -55,7 +55,7 @@ ImageConfiguration createLocalImageConfiguration(BuildContext context, { Size si /// Prefetches an image into the image cache. /// /// Returns a [Future] that will complete when the first image yielded by the -/// [ImageProvider] is available. +/// [ImageProvider] is available or failed to load. /// /// If the image is later used by an [Image] or [BoxDecoration] or [FadeInImage], /// it will probably be loaded faster. The consumer of the image does not need @@ -65,17 +65,38 @@ ImageConfiguration createLocalImageConfiguration(BuildContext context, { Size si /// The [BuildContext] and [Size] are used to select an image configuration /// (see [createLocalImageConfiguration]). /// +/// The `onError` argument can be used to manually handle errors while precaching. +/// /// See also: /// /// * [ImageCache], which holds images that may be reused. -Future precacheImage(ImageProvider provider, BuildContext context, { Size size }) { +Future precacheImage( + ImageProvider provider, + BuildContext context, { + Size size, + ImageErrorListener onError, +}) { final ImageConfiguration config = createLocalImageConfiguration(context, size: size); final Completer completer = new Completer(); final ImageStream stream = provider.resolve(config); void listener(ImageInfo image, bool sync) { completer.complete(); } - stream.addListener(listener); + void errorListener(dynamic exception, StackTrace stackTrace) { + completer.complete(); + if (onError != null) { + onError(exception, stackTrace); + } else { + FlutterError.reportError(new FlutterErrorDetails( + context: 'image failed to precache', + library: 'image resource service', + exception: exception, + stack: stackTrace, + silent: true, + )); + } + } + stream.addListener(listener, onError: errorListener); completer.future.then((Null _) { stream.removeListener(listener); }); return completer.future; } diff --git a/packages/flutter/test/painting/image_stream_test.dart b/packages/flutter/test/painting/image_stream_test.dart index 51c19784f7..e74f296f0f 100644 --- a/packages/flutter/test/painting/image_stream_test.dart +++ b/packages/flutter/test/painting/image_stream_test.dart @@ -151,288 +151,322 @@ void main() { expect(emittedImages, equals([new ImageInfo(image: frame.image)])); }); - testWidgets('ImageStream emits frames (animated images)', (WidgetTester tester) async { - final MockCodec mockCodec = new MockCodec(); - mockCodec.frameCount = 2; - mockCodec.repetitionCount = -1; - final Completer codecCompleter = new Completer(); + testWidgets('ImageStream emits frames (animated images)', (WidgetTester tester) async { + final MockCodec mockCodec = new MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = -1; + final Completer codecCompleter = new Completer(); - final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( - codec: codecCompleter.future, - scale: 1.0, - ); + final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( + codec: codecCompleter.future, + scale: 1.0, + ); - final List emittedImages = []; - imageStream.addListener((ImageInfo image, bool synchronousCall) { - emittedImages.add(image); - }); + final List emittedImages = []; + imageStream.addListener((ImageInfo image, bool synchronousCall) { + emittedImages.add(image); + }); - codecCompleter.complete(mockCodec); - await tester.idle(); + codecCompleter.complete(mockCodec); + await tester.idle(); - final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); - mockCodec.completeNextFrame(frame1); - await tester.idle(); - // We are waiting for the next animation tick, so at this point no frames - // should have been emitted. - expect(emittedImages.length, 0); + final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); + mockCodec.completeNextFrame(frame1); + await tester.idle(); + // We are waiting for the next animation tick, so at this point no frames + // should have been emitted. + expect(emittedImages.length, 0); - await tester.pump(); - expect(emittedImages, equals([new ImageInfo(image: frame1.image)])); + await tester.pump(); + expect(emittedImages, equals([new ImageInfo(image: frame1.image)])); - final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); - mockCodec.completeNextFrame(frame2); + final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); + mockCodec.completeNextFrame(frame2); - await tester.pump(const Duration(milliseconds: 100)); - // The duration for the current frame was 200ms, so we don't emit the next - // frame yet even though it is ready. - expect(emittedImages.length, 1); + await tester.pump(const Duration(milliseconds: 100)); + // The duration for the current frame was 200ms, so we don't emit the next + // frame yet even though it is ready. + expect(emittedImages.length, 1); - await tester.pump(const Duration(milliseconds: 100)); - expect(emittedImages, equals([ - new ImageInfo(image: frame1.image), - new ImageInfo(image: frame2.image), - ])); + await tester.pump(const Duration(milliseconds: 100)); + expect(emittedImages, equals([ + new ImageInfo(image: frame1.image), + new ImageInfo(image: frame2.image), + ])); - // Let the pending timer for the next frame to complete so we can cleanly - // quit the test without pending timers. - await tester.pump(const Duration(milliseconds: 400)); - }); + // Let the pending timer for the next frame to complete so we can cleanly + // quit the test without pending timers. + await tester.pump(const Duration(milliseconds: 400)); + }); - testWidgets('animation wraps back', (WidgetTester tester) async { - final MockCodec mockCodec = new MockCodec(); - mockCodec.frameCount = 2; - mockCodec.repetitionCount = -1; - final Completer codecCompleter = new Completer(); + testWidgets('animation wraps back', (WidgetTester tester) async { + final MockCodec mockCodec = new MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = -1; + final Completer codecCompleter = new Completer(); - final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( - codec: codecCompleter.future, - scale: 1.0, - ); + final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( + codec: codecCompleter.future, + scale: 1.0, + ); - final List emittedImages = []; - imageStream.addListener((ImageInfo image, bool synchronousCall) { - emittedImages.add(image); - }); + final List emittedImages = []; + imageStream.addListener((ImageInfo image, bool synchronousCall) { + emittedImages.add(image); + }); - codecCompleter.complete(mockCodec); - await tester.idle(); + codecCompleter.complete(mockCodec); + await tester.idle(); - final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); - final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); + final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); + final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); - mockCodec.completeNextFrame(frame1); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); // first animation frame shows on first app frame. - mockCodec.completeNextFrame(frame2); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. - mockCodec.completeNextFrame(frame1); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(const Duration(milliseconds: 400)); // emit 3rd frame + mockCodec.completeNextFrame(frame1); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); // first animation frame shows on first app frame. + mockCodec.completeNextFrame(frame2); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. + mockCodec.completeNextFrame(frame1); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(const Duration(milliseconds: 400)); // emit 3rd frame - expect(emittedImages, equals([ - new ImageInfo(image: frame1.image), - new ImageInfo(image: frame2.image), - new ImageInfo(image: frame1.image), - ])); + expect(emittedImages, equals([ + new ImageInfo(image: frame1.image), + new ImageInfo(image: frame2.image), + new ImageInfo(image: frame1.image), + ])); - // Let the pending timer for the next frame to complete so we can cleanly - // quit the test without pending timers. - await tester.pump(const Duration(milliseconds: 200)); - }); + // Let the pending timer for the next frame to complete so we can cleanly + // quit the test without pending timers. + await tester.pump(const Duration(milliseconds: 200)); + }); - testWidgets('animation doesnt repeat more than specified', (WidgetTester tester) async { - final MockCodec mockCodec = new MockCodec(); - mockCodec.frameCount = 2; - mockCodec.repetitionCount = 0; - final Completer codecCompleter = new Completer(); + testWidgets('animation doesnt repeat more than specified', (WidgetTester tester) async { + final MockCodec mockCodec = new MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = 0; + final Completer codecCompleter = new Completer(); - final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( - codec: codecCompleter.future, - scale: 1.0, - ); + final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( + codec: codecCompleter.future, + scale: 1.0, + ); - final List emittedImages = []; - imageStream.addListener((ImageInfo image, bool synchronousCall) { - emittedImages.add(image); - }); + final List emittedImages = []; + imageStream.addListener((ImageInfo image, bool synchronousCall) { + emittedImages.add(image); + }); - codecCompleter.complete(mockCodec); - await tester.idle(); + codecCompleter.complete(mockCodec); + await tester.idle(); - final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); - final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); + final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); + final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); - mockCodec.completeNextFrame(frame1); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); // first animation frame shows on first app frame. - mockCodec.completeNextFrame(frame2); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. - mockCodec.completeNextFrame(frame1); - // allow another frame to complete (but we shouldn't be asking for it as - // this animation should not repeat. - await tester.idle(); - await tester.pump(const Duration(milliseconds: 400)); + mockCodec.completeNextFrame(frame1); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); // first animation frame shows on first app frame. + mockCodec.completeNextFrame(frame2); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. + mockCodec.completeNextFrame(frame1); + // allow another frame to complete (but we shouldn't be asking for it as + // this animation should not repeat. + await tester.idle(); + await tester.pump(const Duration(milliseconds: 400)); - expect(emittedImages, equals([ - new ImageInfo(image: frame1.image), - new ImageInfo(image: frame2.image), - ])); - }); + expect(emittedImages, equals([ + new ImageInfo(image: frame1.image), + new ImageInfo(image: frame2.image), + ])); + }); - testWidgets('frames are only decoded when there are active listeners', (WidgetTester tester) async { - final MockCodec mockCodec = new MockCodec(); - mockCodec.frameCount = 2; - mockCodec.repetitionCount = -1; - final Completer codecCompleter = new Completer(); + testWidgets('frames are only decoded when there are active listeners', (WidgetTester tester) async { + final MockCodec mockCodec = new MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = -1; + final Completer codecCompleter = new Completer(); - final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( - codec: codecCompleter.future, - scale: 1.0, - ); + final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( + codec: codecCompleter.future, + scale: 1.0, + ); - final ImageListener listener = (ImageInfo image, bool synchronousCall) {}; - imageStream.addListener(listener); + final ImageListener listener = (ImageInfo image, bool synchronousCall) {}; + imageStream.addListener(listener); - codecCompleter.complete(mockCodec); - await tester.idle(); + codecCompleter.complete(mockCodec); + await tester.idle(); - final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); - final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); + final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); + final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); - mockCodec.completeNextFrame(frame1); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); // first animation frame shows on first app frame. - mockCodec.completeNextFrame(frame2); - imageStream.removeListener(listener); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(const Duration(milliseconds: 400)); // emit 2nd frame. + mockCodec.completeNextFrame(frame1); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); // first animation frame shows on first app frame. + mockCodec.completeNextFrame(frame2); + imageStream.removeListener(listener); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(const Duration(milliseconds: 400)); // emit 2nd frame. - // Decoding of the 3rd frame should not start as there are no registered - // listeners to the stream - expect(mockCodec.numFramesAsked, 2); + // Decoding of the 3rd frame should not start as there are no registered + // listeners to the stream + expect(mockCodec.numFramesAsked, 2); - imageStream.addListener(listener); - await tester.idle(); // let nextFrameFuture complete - expect(mockCodec.numFramesAsked, 3); - }); + imageStream.addListener(listener); + await tester.idle(); // let nextFrameFuture complete + expect(mockCodec.numFramesAsked, 3); + }); - testWidgets('multiple stream listeners', (WidgetTester tester) async { - final MockCodec mockCodec = new MockCodec(); - mockCodec.frameCount = 2; - mockCodec.repetitionCount = -1; - final Completer codecCompleter = new Completer(); + testWidgets('multiple stream listeners', (WidgetTester tester) async { + final MockCodec mockCodec = new MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = -1; + final Completer codecCompleter = new Completer(); - final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( - codec: codecCompleter.future, - scale: 1.0, - ); + final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( + codec: codecCompleter.future, + scale: 1.0, + ); - final List emittedImages1 = []; - final ImageListener listener1 = (ImageInfo image, bool synchronousCall) { - emittedImages1.add(image); - }; - final List emittedImages2 = []; - final ImageListener listener2 = (ImageInfo image, bool synchronousCall) { - emittedImages2.add(image); - }; - imageStream.addListener(listener1); - imageStream.addListener(listener2); + final List emittedImages1 = []; + final ImageListener listener1 = (ImageInfo image, bool synchronousCall) { + emittedImages1.add(image); + }; + final List emittedImages2 = []; + final ImageListener listener2 = (ImageInfo image, bool synchronousCall) { + emittedImages2.add(image); + }; + imageStream.addListener(listener1); + imageStream.addListener(listener2); - codecCompleter.complete(mockCodec); - await tester.idle(); + codecCompleter.complete(mockCodec); + await tester.idle(); - final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); - final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); + final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); + final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); - mockCodec.completeNextFrame(frame1); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); // first animation frame shows on first app frame. - expect(emittedImages1, equals([new ImageInfo(image: frame1.image)])); - expect(emittedImages2, equals([new ImageInfo(image: frame1.image)])); + mockCodec.completeNextFrame(frame1); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); // first animation frame shows on first app frame. + expect(emittedImages1, equals([new ImageInfo(image: frame1.image)])); + expect(emittedImages2, equals([new ImageInfo(image: frame1.image)])); - mockCodec.completeNextFrame(frame2); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); // next app frame will schedule a timer. - imageStream.removeListener(listener1); + mockCodec.completeNextFrame(frame2); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); // next app frame will schedule a timer. + imageStream.removeListener(listener1); - await tester.pump(const Duration(milliseconds: 400)); // emit 2nd frame. - expect(emittedImages1, equals([new ImageInfo(image: frame1.image)])); - expect(emittedImages2, equals([ - new ImageInfo(image: frame1.image), - new ImageInfo(image: frame2.image), - ])); - }); + await tester.pump(const Duration(milliseconds: 400)); // emit 2nd frame. + expect(emittedImages1, equals([new ImageInfo(image: frame1.image)])); + expect(emittedImages2, equals([ + new ImageInfo(image: frame1.image), + new ImageInfo(image: frame2.image), + ])); + }); - testWidgets('timer is canceled when listeners are removed', (WidgetTester tester) async { - final MockCodec mockCodec = new MockCodec(); - mockCodec.frameCount = 2; - mockCodec.repetitionCount = -1; - final Completer codecCompleter = new Completer(); + testWidgets('timer is canceled when listeners are removed', (WidgetTester tester) async { + final MockCodec mockCodec = new MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = -1; + final Completer codecCompleter = new Completer(); - final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( - codec: codecCompleter.future, - scale: 1.0, - ); + final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( + codec: codecCompleter.future, + scale: 1.0, + ); - final ImageListener listener = (ImageInfo image, bool synchronousCall) {}; - imageStream.addListener(listener); + final ImageListener listener = (ImageInfo image, bool synchronousCall) {}; + imageStream.addListener(listener); - codecCompleter.complete(mockCodec); - await tester.idle(); + codecCompleter.complete(mockCodec); + await tester.idle(); - final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); - final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); + final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); + final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); - mockCodec.completeNextFrame(frame1); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); // first animation frame shows on first app frame. + mockCodec.completeNextFrame(frame1); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); // first animation frame shows on first app frame. - mockCodec.completeNextFrame(frame2); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); + mockCodec.completeNextFrame(frame2); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); - imageStream.removeListener(listener); - // The test framework will fail this if there are pending timers at this - // point. - }); + imageStream.removeListener(listener); + // The test framework will fail this if there are pending timers at this + // point. + }); - testWidgets('timeDilation affects animation frame timers', (WidgetTester tester) async { - final MockCodec mockCodec = new MockCodec(); - mockCodec.frameCount = 2; - mockCodec.repetitionCount = -1; - final Completer codecCompleter = new Completer(); + testWidgets('timeDilation affects animation frame timers', (WidgetTester tester) async { + final MockCodec mockCodec = new MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = -1; + final Completer codecCompleter = new Completer(); - final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( - codec: codecCompleter.future, - scale: 1.0, - ); + final ImageStreamCompleter imageStream = new MultiFrameImageStreamCompleter( + codec: codecCompleter.future, + scale: 1.0, + ); - final ImageListener listener = (ImageInfo image, bool synchronousCall) {}; - imageStream.addListener(listener); + final ImageListener listener = (ImageInfo image, bool synchronousCall) {}; + imageStream.addListener(listener); - codecCompleter.complete(mockCodec); - await tester.idle(); + codecCompleter.complete(mockCodec); + await tester.idle(); - final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); - final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); + final FrameInfo frame1 = new FakeFrameInfo(20, 10, const Duration(milliseconds: 200)); + final FrameInfo frame2 = new FakeFrameInfo(200, 100, const Duration(milliseconds: 400)); - mockCodec.completeNextFrame(frame1); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); // first animation frame shows on first app frame. + mockCodec.completeNextFrame(frame1); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); // first animation frame shows on first app frame. - timeDilation = 2.0; - mockCodec.completeNextFrame(frame2); - await tester.idle(); // let nextFrameFuture complete - await tester.pump(); // schedule next app frame - await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. - // Decoding of the 3rd frame should not start after 200 ms, as time is - // dilated by a factor of 2. - expect(mockCodec.numFramesAsked, 2); - await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. - expect(mockCodec.numFramesAsked, 3); + timeDilation = 2.0; + mockCodec.completeNextFrame(frame2); + await tester.idle(); // let nextFrameFuture complete + await tester.pump(); // schedule next app frame + await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. + // Decoding of the 3rd frame should not start after 200 ms, as time is + // dilated by a factor of 2. + expect(mockCodec.numFramesAsked, 2); + await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. + expect(mockCodec.numFramesAsked, 3); + }); - }); + testWidgets('error handlers can intercept errors', (WidgetTester tester) async { + final MockCodec mockCodec = new MockCodec(); + mockCodec.frameCount = 1; + final Completer codecCompleter = new Completer(); + + final ImageStreamCompleter streamUnderTest = new MultiFrameImageStreamCompleter( + codec: codecCompleter.future, + scale: 1.0, + ); + + dynamic capturedException; + final ImageErrorListener errorListener = (dynamic exception, StackTrace stackTrace) { + capturedException = exception; + }; + + streamUnderTest.addListener( + (ImageInfo image, bool synchronousCall) {}, + onError: errorListener, + ); + + codecCompleter.complete(mockCodec); + // MultiFrameImageStreamCompleter only sets an error handler for the next + // frame future after the codec future has completed. + // Idling here lets the MultiFrameImageStreamCompleter advance and set the + // error handler for the nextFrame future. + await tester.idle(); + + mockCodec.failNextFrame('frame completion error'); + await tester.idle(); + + // No exception is passed up. + expect(tester.takeException(), isNull); + expect(capturedException, 'frame completion error'); + }); } diff --git a/packages/flutter/test/widgets/image_test.dart b/packages/flutter/test/widgets/image_test.dart index 2be26e68ff..de75af6d9d 100644 --- a/packages/flutter/test/widgets/image_test.dart +++ b/packages/flutter/test/widgets/image_test.dart @@ -316,6 +316,280 @@ void main() { expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(lifecycle state: defunct, not mounted, stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, [100×100] @ 1.0x, 0 listeners), pixels: [100×100] @ 1.0x)')); }); + testWidgets('Stream completer errors can be listened to by attaching before resolving', (WidgetTester tester) async { + dynamic capturedException; + StackTrace capturedStackTrace; + ImageInfo capturedImage; + final ImageErrorListener errorListener = (dynamic exception, StackTrace stackTrace) { + capturedException = exception; + capturedStackTrace = stackTrace; + }; + final ImageListener listener = (ImageInfo info, bool synchronous) { + capturedImage = info; + }; + + final Exception testException = new Exception('cannot resolve host'); + final StackTrace testStack = StackTrace.current; + final TestImageProvider imageProvider = new TestImageProvider(); + imageProvider._streamCompleter.addListener(listener, onError: errorListener); + ImageConfiguration configuration; + await tester.pumpWidget( + new Builder( + builder: (BuildContext context) { + configuration = createLocalImageConfiguration(context); + return new Container(); + }, + ), + ); + imageProvider.resolve(configuration); + imageProvider.fail(testException, testStack); + + expect(tester.binding.microtaskCount, 1); + await tester.idle(); // Let the failed completer's future hit the stream completer. + expect(tester.binding.microtaskCount, 0); + + expect(capturedImage, isNull); // The image stream listeners should never be called. + // The image stream error handler should have the original exception. + expect(capturedException, testException); + expect(capturedStackTrace, testStack); + // If there is an error listener, there should be no FlutterError reported. + expect(tester.takeException(), isNull); + }); + + testWidgets('Stream completer errors can be listened to by attaching after resolving', (WidgetTester tester) async { + dynamic capturedException; + StackTrace capturedStackTrace; + dynamic reportedException; + StackTrace reportedStackTrace; + ImageInfo capturedImage; + final ImageErrorListener errorListener = (dynamic exception, StackTrace stackTrace) { + capturedException = exception; + capturedStackTrace = stackTrace; + }; + final ImageListener listener = (ImageInfo info, bool synchronous) { + capturedImage = info; + }; + FlutterError.onError = (FlutterErrorDetails flutterError) { + reportedException = flutterError.exception; + reportedStackTrace = flutterError.stack; + }; + + final Exception testException = new Exception('cannot resolve host'); + final StackTrace testStack = StackTrace.current; + final TestImageProvider imageProvider = new TestImageProvider(); + ImageConfiguration configuration; + await tester.pumpWidget( + new Builder( + builder: (BuildContext context) { + configuration = createLocalImageConfiguration(context); + return new Container(); + }, + ), + ); + final ImageStream streamUnderTest = imageProvider.resolve(configuration); + + imageProvider.fail(testException, testStack); + + expect(tester.binding.microtaskCount, 1); + await tester.idle(); // Let the failed completer's future hit the stream completer. + expect(tester.binding.microtaskCount, 0); + + // Since there's no listeners attached yet, report error up via + // FlutterError. + expect(reportedException, testException); + expect(reportedStackTrace, testStack); + + streamUnderTest.addListener(listener, onError: errorListener); + + expect(capturedImage, isNull); // The image stream listeners should never be called. + // The image stream error handler should have the original exception. + expect(capturedException, testException); + expect(capturedStackTrace, testStack); + }); + + testWidgets('Duplicate listener registration does not affect error listeners', (WidgetTester tester) async { + dynamic capturedException; + StackTrace capturedStackTrace; + ImageInfo capturedImage; + final ImageErrorListener errorListener = (dynamic exception, StackTrace stackTrace) { + capturedException = exception; + capturedStackTrace = stackTrace; + }; + final ImageListener listener = (ImageInfo info, bool synchronous) { + capturedImage = info; + }; + + final Exception testException = new Exception('cannot resolve host'); + final StackTrace testStack = StackTrace.current; + final TestImageProvider imageProvider = new TestImageProvider(); + imageProvider._streamCompleter.addListener(listener, onError: errorListener); + // Add the exact same listener a second time without the errorListener. + imageProvider._streamCompleter.addListener(listener); + ImageConfiguration configuration; + await tester.pumpWidget( + new Builder( + builder: (BuildContext context) { + configuration = createLocalImageConfiguration(context); + return new Container(); + }, + ), + ); + imageProvider.resolve(configuration); + imageProvider.fail(testException, testStack); + + expect(tester.binding.microtaskCount, 1); + await tester.idle(); // Let the failed completer's future hit the stream completer. + expect(tester.binding.microtaskCount, 0); + + expect(capturedImage, isNull); // The image stream listeners should never be called. + // The image stream error handler should have the original exception. + expect(capturedException, testException); + expect(capturedStackTrace, testStack); + // If there is an error listener, there should be no FlutterError reported. + expect(tester.takeException(), isNull); + }); + + testWidgets('Duplicate error listeners are all called', (WidgetTester tester) async { + dynamic capturedException; + StackTrace capturedStackTrace; + ImageInfo capturedImage; + int errorListenerCalled = 0; + final ImageErrorListener errorListener = (dynamic exception, StackTrace stackTrace) { + capturedException = exception; + capturedStackTrace = stackTrace; + errorListenerCalled++; + }; + final ImageListener listener = (ImageInfo info, bool synchronous) { + capturedImage = info; + }; + + final Exception testException = new Exception('cannot resolve host'); + final StackTrace testStack = StackTrace.current; + final TestImageProvider imageProvider = new TestImageProvider(); + imageProvider._streamCompleter.addListener(listener, onError: errorListener); + // Add the exact same errorListener a second time. + imageProvider._streamCompleter.addListener(null, onError: errorListener); + ImageConfiguration configuration; + await tester.pumpWidget( + new Builder( + builder: (BuildContext context) { + configuration = createLocalImageConfiguration(context); + return new Container(); + }, + ), + ); + imageProvider.resolve(configuration); + imageProvider.fail(testException, testStack); + + expect(tester.binding.microtaskCount, 1); + await tester.idle(); // Let the failed completer's future hit the stream completer. + expect(tester.binding.microtaskCount, 0); + + expect(capturedImage, isNull); // The image stream listeners should never be called. + // The image stream error handler should have the original exception. + expect(capturedException, testException); + expect(capturedStackTrace, testStack); + expect(errorListenerCalled, 2); + // If there is an error listener, there should be no FlutterError reported. + expect(tester.takeException(), isNull); + }); + + testWidgets('Error listeners are removed along with listeners', (WidgetTester tester) async { + bool errorListenerCalled = false; + dynamic reportedException; + StackTrace reportedStackTrace; + ImageInfo capturedImage; + final ImageErrorListener errorListener = (dynamic exception, StackTrace stackTrace) { + errorListenerCalled = true; + }; + final ImageListener listener = (ImageInfo info, bool synchronous) { + capturedImage = info; + }; + FlutterError.onError = (FlutterErrorDetails flutterError) { + reportedException = flutterError.exception; + reportedStackTrace = flutterError.stack; + }; + + final Exception testException = new Exception('cannot resolve host'); + final StackTrace testStack = StackTrace.current; + final TestImageProvider imageProvider = new TestImageProvider(); + imageProvider._streamCompleter.addListener(listener, onError: errorListener); + // Now remove the listener the error listener is attached to. + // Don't explicitly remove the error listener. + imageProvider._streamCompleter.removeListener(listener); + ImageConfiguration configuration; + await tester.pumpWidget( + new Builder( + builder: (BuildContext context) { + configuration = createLocalImageConfiguration(context); + return new Container(); + }, + ), + ); + imageProvider.resolve(configuration); + + imageProvider.fail(testException, testStack); + + expect(tester.binding.microtaskCount, 1); + await tester.idle(); // Let the failed completer's future hit the stream completer. + expect(tester.binding.microtaskCount, 0); + + expect(errorListenerCalled, false); + // Since the error listener is removed, bubble up to FlutterError. + expect(reportedException, testException); + expect(reportedStackTrace, testStack); + expect(capturedImage, isNull); // The image stream listeners should never be called. + }); + + testWidgets('Removing duplicate listeners removes error listeners', (WidgetTester tester) async { + bool errorListenerCalled = false; + dynamic reportedException; + StackTrace reportedStackTrace; + ImageInfo capturedImage; + final ImageErrorListener errorListener = (dynamic exception, StackTrace stackTrace) { + errorListenerCalled = true; + }; + final ImageListener listener = (ImageInfo info, bool synchronous) { + capturedImage = info; + }; + FlutterError.onError = (FlutterErrorDetails flutterError) { + reportedException = flutterError.exception; + reportedStackTrace = flutterError.stack; + }; + + final Exception testException = new Exception('cannot resolve host'); + final StackTrace testStack = StackTrace.current; + final TestImageProvider imageProvider = new TestImageProvider(); + imageProvider._streamCompleter.addListener(listener, onError: errorListener); + // Duplicates the same set of listener and errorListener. + imageProvider._streamCompleter.addListener(listener, onError: errorListener); + // Now remove all specified listeners and associated error listeners. + // Don't explicitly remove the error listener. + imageProvider._streamCompleter.removeListener(listener); + ImageConfiguration configuration; + await tester.pumpWidget( + new Builder( + builder: (BuildContext context) { + configuration = createLocalImageConfiguration(context); + return new Container(); + }, + ), + ); + imageProvider.resolve(configuration); + + imageProvider.fail(testException, testStack); + + expect(tester.binding.microtaskCount, 1); + await tester.idle(); // Let the failed completer's future hit the stream completer. + expect(tester.binding.microtaskCount, 0); + + expect(errorListenerCalled, false); + // Since the error listener is removed, bubble up to FlutterError. + expect(reportedException, testException); + expect(reportedStackTrace, testStack); + expect(capturedImage, isNull); // The image stream listeners should never be called. + }); + testWidgets('Image.memory control test', (WidgetTester tester) async { await tester.pumpWidget(new Image.memory(new Uint8List.fromList(kTransparentImage), excludeFromSemantics: true,)); }); @@ -356,6 +630,36 @@ void main() { expect(isSync, isTrue); }); + testWidgets('Precache completes with onError on error', (WidgetTester tester) async { + dynamic capturedException; + StackTrace capturedStackTrace; + final ImageErrorListener errorListener = (dynamic exception, StackTrace stackTrace) { + capturedException = exception; + capturedStackTrace = stackTrace; + }; + + final Exception testException = new Exception('cannot resolve host'); + final StackTrace testStack = StackTrace.current; + final TestImageProvider imageProvider = new TestImageProvider(); + Future precache; + await tester.pumpWidget( + new Builder( + builder: (BuildContext context) { + precache = precacheImage(imageProvider, context, onError: errorListener); + return new Container(); + } + ) + ); + imageProvider.fail(testException, testStack); + await precache; + + // The image stream error handler should have the original exception. + expect(capturedException, testException); + expect(capturedStackTrace, testStack); + // If there is an error listener, there should be no FlutterError reported. + expect(tester.takeException(), isNull); + }); + testWidgets('TickerMode controls stream registration', (WidgetTester tester) async { final TestImageStreamCompleter imageStreamCompleter = new TestImageStreamCompleter(); final Image image = new Image( @@ -524,16 +828,20 @@ class TestImageProvider extends ImageProvider { _completer.complete(new ImageInfo(image: new TestImage())); } + void fail(dynamic exception, StackTrace stackTrace) { + _completer.completeError(exception, stackTrace); + } + @override String toString() => '${describeIdentity(this)}()'; } class TestImageStreamCompleter extends ImageStreamCompleter { - final List listeners = []; + final Map listeners = {}; @override - void addListener(ImageListener listener) { - listeners.add(listener); + void addListener(ImageListener listener, { ImageErrorListener onError }) { + listeners[listener] = onError; } @override