diff --git a/packages/vector_graphics/CHANGELOG.md b/packages/vector_graphics/CHANGELOG.md index de88a770fce..04b0860ef82 100644 --- a/packages/vector_graphics/CHANGELOG.md +++ b/packages/vector_graphics/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.1.14 +* Adds error handling when parsing an invalid svg string. +* Adds error handling when downloading svg string from network. +* Adds error handling when reading svg file from asset. +* Expose ErrorWidgetBuilder. + ## 1.1.13 * Fix execution on the web with WebAssembly. diff --git a/packages/vector_graphics/lib/src/listener.dart b/packages/vector_graphics/lib/src/listener.dart index f266de4fb4a..3a3f63758db 100644 --- a/packages/vector_graphics/lib/src/listener.dart +++ b/packages/vector_graphics/lib/src/listener.dart @@ -55,7 +55,7 @@ final Map> _pendingDecodes = /// Decode a vector graphics binary asset into a [Picture]. /// /// Throws a [StateError] if the data is invalid. -Future decodeVectorGraphics( +Future decodeVectorGraphics( ByteData data, { required Locale? locale, required TextDirection? textDirection, diff --git a/packages/vector_graphics/lib/src/vector_graphics.dart b/packages/vector_graphics/lib/src/vector_graphics.dart index eb5eca76fd1..7e638ff31c4 100644 --- a/packages/vector_graphics/lib/src/vector_graphics.dart +++ b/packages/vector_graphics/lib/src/vector_graphics.dart @@ -5,6 +5,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:vector_graphics_codec/vector_graphics_codec.dart'; @@ -279,8 +280,7 @@ class _PictureData { @immutable class _PictureKey { - const _PictureKey( - this.cacheKey, this.locale, this.textDirection, this.clipViewbox); + const _PictureKey(this.cacheKey, this.locale, this.textDirection, this.clipViewbox); final Object cacheKey; final Locale? locale; @@ -306,10 +306,8 @@ class _VectorGraphicWidgetState extends State { Locale? locale; TextDirection? textDirection; - static final Map<_PictureKey, _PictureData> _livePictureCache = - <_PictureKey, _PictureData>{}; - static final Map<_PictureKey, Future<_PictureData>> _pendingPictures = - <_PictureKey, Future<_PictureData>>{}; + static final Map<_PictureKey, _PictureData?> _livePictureCache = <_PictureKey, _PictureData?>{}; + static final Map<_PictureKey, Future<_PictureData?>> _pendingPictures = <_PictureKey, Future<_PictureData?>>{}; @override void didChangeDependencies() { @@ -345,29 +343,38 @@ class _VectorGraphicWidgetState extends State { } } - Future<_PictureData> _loadPicture( - BuildContext context, _PictureKey key, BytesLoader loader) { + Future<_PictureData?> _loadPicture(BuildContext context, _PictureKey key, BytesLoader loader) { if (_pendingPictures.containsKey(key)) { return _pendingPictures[key]!; } - final Future<_PictureData> result = - loader.loadBytes(context).then((ByteData data) { - return decodeVectorGraphics( - data, - locale: key.locale, - textDirection: key.textDirection, - clipViewbox: key.clipViewbox, - loader: loader, - onError: (Object error, StackTrace? stackTrace) { - return _handleError( - error, - stackTrace, - ); - }, - ); - }).then((PictureInfo pictureInfo) { + + final Future<_PictureData?> result = loader.loadBytes(context).then((ByteData data) async { + if (data.lengthInBytes == 0) { + debugPrint('_VectorGraphicWidgetState.decodeVectorGraphics: empty'); + _handleError(const FormatException('Empty SVG xml content'), null); + return null; + } else { + return decodeVectorGraphics(data, + locale: key.locale, + textDirection: key.textDirection, + clipViewbox: key.clipViewbox, + loader: loader, onError: (Object error, StackTrace? stackTrace) { + debugPrintStack( + stackTrace: stackTrace, label: '_VectorGraphicWidgetState.decodeVectorGraphics.onError: $error'); + _handleError(error, stackTrace); + }); + } + }).onError((Object? error, StackTrace stackTrace) { + debugPrintStack(stackTrace: stackTrace, label: '_VectorGraphicWidgetState._loadPictureInfo.onError: $error'); + _handleError(error ?? '', stackTrace); + return null; + }).then((PictureInfo? pictureInfo) { + if (pictureInfo == null) { + return null; + } return _PictureData(pictureInfo, 0, key); }); + _pendingPictures[key] = result; result.whenComplete(() { _pendingPictures.remove(key); @@ -376,6 +383,9 @@ class _VectorGraphicWidgetState extends State { } void _handleError(Object error, StackTrace? stackTrace) { + if (!mounted) { + return; + } setState(() { _error = error; _stackTrace = stackTrace; @@ -385,8 +395,7 @@ class _VectorGraphicWidgetState extends State { void _loadAssetBytes() { // First check if we have an avilable picture and use this immediately. final Object loaderKey = widget.loader.cacheKey(context); - final _PictureKey key = - _PictureKey(loaderKey, locale, textDirection, widget.clipViewbox); + final _PictureKey key = _PictureKey(loaderKey, locale, textDirection, widget.clipViewbox); final _PictureData? data = _livePictureCache[key]; if (data != null) { data.count += 1; @@ -398,7 +407,10 @@ class _VectorGraphicWidgetState extends State { } // If not, then check if there is a pending load. final BytesLoader loader = widget.loader; - _loadPicture(context, key, loader).then((_PictureData data) { + _loadPicture(context, key, loader).then((_PictureData? data) { + if (data == null) { + return; + } data.count += 1; // The widget may have changed, requesting a new vector graphic before @@ -674,7 +686,7 @@ class VectorGraphicUtilities { /// /// It is the caller's responsibility to handle disposing the picture when /// they are done with it. - Future loadPicture( + Future loadPicture( BytesLoader loader, BuildContext? context, { bool clipViewbox = true, diff --git a/packages/vector_graphics/pubspec.yaml b/packages/vector_graphics/pubspec.yaml index 03e6c562bbb..0a8f73ed5a8 100644 --- a/packages/vector_graphics/pubspec.yaml +++ b/packages/vector_graphics/pubspec.yaml @@ -5,7 +5,7 @@ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+ # See https://github.com/flutter/flutter/issues/157626 before publishing a new # version. publish_to: none -version: 1.1.13 +version: 1.1.14 environment: sdk: ^3.4.0 diff --git a/packages/vector_graphics/test/listener_test.dart b/packages/vector_graphics/test/listener_test.dart index cabf8051a11..a64beef1915 100644 --- a/packages/vector_graphics/test/listener_test.dart +++ b/packages/vector_graphics/test/listener_test.dart @@ -43,31 +43,31 @@ void main() { }); test('decode without clip', () async { - final PictureInfo info = await decodeVectorGraphics( + final PictureInfo? info = await decodeVectorGraphics( vectorGraphicBuffer, locale: ui.PlatformDispatcher.instance.locale, textDirection: ui.TextDirection.ltr, clipViewbox: true, loader: const AssetBytesLoader('test'), ); - final ui.Image image = info.picture.toImageSync(15, 15); - final Uint32List imageBytes = - (await image.toByteData())!.buffer.asUint32List(); + expect(info, isNotNull); + final ui.Image image = info!.picture.toImageSync(15, 15); + final Uint32List imageBytes = (await image.toByteData())!.buffer.asUint32List(); expect(imageBytes.first, 0xFF000000); expect(imageBytes.last, 0x00000000); }, skip: kIsWeb); test('decode with clip', () async { - final PictureInfo info = await decodeVectorGraphics( + final PictureInfo? info = await decodeVectorGraphics( vectorGraphicBuffer, locale: ui.PlatformDispatcher.instance.locale, textDirection: ui.TextDirection.ltr, clipViewbox: false, loader: const AssetBytesLoader('test'), ); - final ui.Image image = info.picture.toImageSync(15, 15); - final Uint32List imageBytes = - (await image.toByteData())!.buffer.asUint32List(); + expect(info, isNotNull); + final ui.Image image = info!.picture.toImageSync(15, 15); + final Uint32List imageBytes = (await image.toByteData())!.buffer.asUint32List(); expect(imageBytes.first, 0xFF000000); expect(imageBytes.last, 0xFF000000); }, skip: kIsWeb); diff --git a/packages/vector_graphics/test/render_vector_graphics_test.dart b/packages/vector_graphics/test/render_vector_graphics_test.dart index bf5f12889ca..dc800465379 100644 --- a/packages/vector_graphics/test/render_vector_graphics_test.dart +++ b/packages/vector_graphics/test/render_vector_graphics_test.dart @@ -18,7 +18,7 @@ import 'package:vector_graphics/vector_graphics.dart'; import 'package:vector_graphics_codec/vector_graphics_codec.dart'; void main() { - late PictureInfo pictureInfo; + late PictureInfo? pictureInfo; tearDown(() { // Since we don't always explicitly dispose render objects in unit tests, manually clear @@ -41,7 +41,7 @@ void main() { test('Rasterizes a picture to a draw image call', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -62,7 +62,7 @@ void main() { test('Multiple render objects with the same scale share a raster', () async { final RenderVectorGraphic renderVectorGraphicA = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -70,7 +70,7 @@ void main() { 1.0, ); final RenderVectorGraphic renderVectorGraphicB = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -91,7 +91,7 @@ void main() { test('disposing render object release raster', () async { final RenderVectorGraphic renderVectorGraphicA = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -99,7 +99,7 @@ void main() { 1.0, ); final RenderVectorGraphic renderVectorGraphicB = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -126,7 +126,7 @@ void main() { 'Multiple render objects with the same scale share a raster, different load order', () async { final RenderVectorGraphic renderVectorGraphicA = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -134,7 +134,7 @@ void main() { 1.0, ); final RenderVectorGraphic renderVectorGraphicB = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -158,7 +158,7 @@ void main() { test('Changing color filter does not re-rasterize', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -185,7 +185,7 @@ void main() { test('Changing device pixel ratio does re-rasterize and dispose old raster', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -210,7 +210,7 @@ void main() { test('Changing scale does re-rasterize and dispose old raster', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -235,7 +235,7 @@ void main() { test('The raster size is increased by the inverse picture scale', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -254,7 +254,7 @@ void main() { test('The raster size is increased by the device pixel ratio', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 2.0, @@ -273,7 +273,7 @@ void main() { test('The raster size is increased by the device pixel ratio and ratio', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 2.0, @@ -292,7 +292,7 @@ void main() { test('Changing size asserts if it is different from the picture size', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -313,7 +313,7 @@ void main() { test('Does not rasterize a picture when fully transparent', () async { final FixedOpacityAnimation opacity = FixedOpacityAnimation(0.0); final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -339,7 +339,7 @@ void main() { test('paints partially opaque picture', () async { final FixedOpacityAnimation opacity = FixedOpacityAnimation(0.5); final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -355,7 +355,7 @@ void main() { test('Disposing render object disposes picture', () async { final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -376,7 +376,7 @@ void main() { test('Removes listeners on detach, dispose, adds then on attach', () async { final FixedOpacityAnimation opacity = FixedOpacityAnimation(0.5); final RenderVectorGraphic renderVectorGraphic = RenderVectorGraphic( - pictureInfo, + pictureInfo!, 'test', null, 1.0, @@ -412,7 +412,7 @@ void main() { test('Color filter applies clip', () async { final RenderPictureVectorGraphic render = RenderPictureVectorGraphic( - pictureInfo, + pictureInfo!, const ui.ColorFilter.mode(Colors.green, ui.BlendMode.difference), null, ); diff --git a/third_party/packages/flutter_svg/CHANGELOG.md b/third_party/packages/flutter_svg/CHANGELOG.md index 1f64d4639a5..6e3ede39bed 100644 --- a/third_party/packages/flutter_svg/CHANGELOG.md +++ b/third_party/packages/flutter_svg/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.0.12 +* Adds error handling when parsing an invalid svg string. +* Adds error handling when downloading svg string from network. +* Adds error handling when reading svg file from asset. +* Expose ErrorWidgetBuilder. + ## 2.0.11 * Transfers the package source from https://github.com/dnfield/flutter_svg diff --git a/third_party/packages/flutter_svg/example/assets/invalid.svg b/third_party/packages/flutter_svg/example/assets/invalid.svg new file mode 100644 index 00000000000..20bd54528ca --- /dev/null +++ b/third_party/packages/flutter_svg/example/assets/invalid.svg @@ -0,0 +1 @@ +< width="69" height="69" viewBox="0 0 69 69"> diff --git a/third_party/packages/flutter_svg/example/lib/grid.dart b/third_party/packages/flutter_svg/example/lib/grid.dart index 163893f5056..77ce2d17b0e 100644 --- a/third_party/packages/flutter_svg/example/lib/grid.dart +++ b/third_party/packages/flutter_svg/example/lib/grid.dart @@ -1,8 +1,11 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; const List _assetNames = [ - // 'assets/notfound.svg', // uncomment to test an asset that doesn't exist. + 'assets/invalid.svg', + 'assets/notfound.svg', // uncomment to test an asset that doesn't exist. 'assets/flutter_logo.svg', 'assets/dart.svg', 'assets/simple/clip_path_3.svg', @@ -35,7 +38,7 @@ const List _assetNames = [ ]; /// Assets treated as "icons" - using a color filter to render differently. -const List iconNames = [ +const List _iconNames = [ 'assets/deborah_ufw/new-action-expander.svg', 'assets/deborah_ufw/new-camera.svg', 'assets/deborah_ufw/new-gif-button.svg', @@ -49,12 +52,27 @@ const List iconNames = [ ]; /// Assets to test network access. -const List uriNames = [ +const List _uriNames = [ 'http://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg', 'https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/410.svg', 'https://upload.wikimedia.org/wikipedia/commons/b/b4/Chess_ndd45.svg', ]; +const List _uriFailedNames = [ + 'an error image url.svg', // invalid url. + 'https: /sadf.svg', // invalid url. + 'http://www.google.com/404', // 404 url. + 'https://picsum.photos/200', // wrong format image url. +]; + +const List _stringNames = [ + ''' ''', // Shows an example of an SVG image that will fetch a raster image from a URL. + ''' ''', // valid svg + '''''', // empty svg. + 'sdf sdf ', // invalid svg. + '', // empty string. +]; + void main() { runApp(_MyApp()); } @@ -81,59 +99,10 @@ class _MyHomePage extends StatefulWidget { } class _MyHomePageState extends State<_MyHomePage> { - final List _painters = []; - late double _dimension; - - @override - void initState() { - super.initState(); - _dimension = 203.0; - for (final String assetName in _assetNames) { - _painters.add( - SvgPicture.asset(assetName), - ); - } - - for (int i = 0; i < iconNames.length; i++) { - _painters.add( - Directionality( - textDirection: TextDirection.ltr, - child: SvgPicture.asset( - iconNames[i], - colorFilter: ColorFilter.mode( - Colors.blueGrey[(i + 1) * 100] ?? Colors.blueGrey, - BlendMode.srcIn, - ), - matchTextDirection: true, - ), - ), - ); - } - - for (final String uriName in uriNames) { - _painters.add( - SvgPicture.network( - uriName, - placeholderBuilder: (BuildContext context) => Container( - padding: const EdgeInsets.all(30.0), - child: const CircularProgressIndicator(), - ), - ), - ); - } - // Shows an example of an SVG image that will fetch a raster image from a URL. - _painters.add(SvgPicture.string(''' - - -''')); - } + double _dimension = 60; @override Widget build(BuildContext context) { - if (_dimension > MediaQuery.of(context).size.width - 10.0) { - _dimension = MediaQuery.of(context).size.width - 10.0; - } return Scaffold( appBar: AppBar( title: Text(widget.title), @@ -144,7 +113,7 @@ class _MyHomePageState extends State<_MyHomePage> { max: MediaQuery.of(context).size.width - 10.0, value: _dimension, onChanged: (double val) { - setState(() => _dimension = val); + setState(() => _dimension = min(MediaQuery.of(context).size.width - 10.0, val)); }, ), Expanded( @@ -154,7 +123,86 @@ class _MyHomePageState extends State<_MyHomePage> { padding: const EdgeInsets.all(4.0), mainAxisSpacing: 4.0, crossAxisSpacing: 4.0, - children: _painters.toList(), + children: [ + ..._assetNames.map( + (String e) => SvgPicture.asset( + e, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (BuildContext context, Object error, StackTrace stackTrace) => Container( + color: Colors.brown, + width: 10, + height: 10, + ), + ), + ), + ..._iconNames.map( + (String e) => Directionality( + textDirection: TextDirection.ltr, + child: SvgPicture.asset( + e, + colorFilter: ColorFilter.mode( + Colors.blueGrey[(_iconNames.indexOf(e) + 1) * 100] ?? Colors.blueGrey, + BlendMode.srcIn, + ), + matchTextDirection: true, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (BuildContext context, Object error, StackTrace stackTrace) => Container( + color: Colors.yellow, + width: 10, + height: 10, + ), + ), + ), + ), + ..._uriNames.map( + (String e) => SvgPicture.network( + e, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (BuildContext context, Object error, StackTrace stackTrace) => Container( + color: Colors.red, + width: 10, + height: 10, + ), + ), + ), + ..._uriFailedNames.map( + (String e) => SvgPicture.network( + e, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (BuildContext context, Object error, StackTrace stackTrace) => Container( + color: Colors.deepPurple, + width: 10, + height: 10, + ), + ), + ), + ..._stringNames.map( + (String e) => SvgPicture.string( + e, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (BuildContext context, Object error, StackTrace stackTrace) => Container( + color: Colors.pinkAccent, + width: 10, + height: 10, + ), + ), + ), + ], ), ), ]), diff --git a/third_party/packages/flutter_svg/lib/src/loaders.dart b/third_party/packages/flutter_svg/lib/src/loaders.dart index 2996cc11f64..06edbce75a5 100644 --- a/third_party/packages/flutter_svg/lib/src/loaders.dart +++ b/third_party/packages/flutter_svg/lib/src/loaders.dart @@ -152,20 +152,29 @@ abstract class SvgLoader extends BytesLoader { final SvgTheme theme = getTheme(context); return prepareMessage(context).then((T? message) { return compute((T? message) { - return vg - .encodeSvg( - xml: provideSvg(message), - theme: theme.toVgTheme(), - colorMapper: colorMapper == null - ? null - : _DelegateVgColorMapper(colorMapper!), - debugName: 'Svg loader', - enableClippingOptimizer: false, - enableMaskingOptimizer: false, - enableOverdrawOptimizer: false, - ) - .buffer - .asByteData(); + try { + debugPrint('SvgLoader._load.provideSvg: empty'); + final String xml = provideSvg(message); + if (xml.isEmpty) { + return Future.value(ByteData(0)); + } else { + return vg + .encodeSvg( + xml: xml, + theme: theme.toVgTheme(), + colorMapper: colorMapper == null ? null : _DelegateVgColorMapper(colorMapper!), + debugName: 'Svg loader', + enableClippingOptimizer: false, + enableMaskingOptimizer: false, + enableOverdrawOptimizer: false, + ) + .buffer + .asByteData(); + } + } catch (e) { + debugPrint('SvgLoader._load.error: $e'); + return Future.value(ByteData(0)); + } }, message, debugLabel: 'Load Bytes'); }); } @@ -373,15 +382,19 @@ class SvgAssetLoader extends SvgLoader { } @override - Future prepareMessage(BuildContext? context) { - return _resolveBundle(context).load( - packageName == null ? assetName : 'packages/$packageName/$assetName', - ); + Future prepareMessage(BuildContext? context) async { + try { + return await _resolveBundle(context).load( + packageName == null ? assetName : 'packages/$packageName/$assetName', + ); + } catch (e) { + debugPrint('SvgAssetLoader.prepareMessage.error: $e'); + return Future.value(); + } } @override - String provideSvg(ByteData? message) => - utf8.decode(message!.buffer.asUint8List(), allowMalformed: true); + String provideSvg(ByteData? message) => utf8.decode(message!.buffer.asUint8List(), allowMalformed: true); @override SvgCacheKey cacheKey(BuildContext? context) { @@ -437,13 +450,18 @@ class SvgNetworkLoader extends SvgLoader { @override Future prepareMessage(BuildContext? context) async { - final http.Client client = _httpClient ?? http.Client(); - return (await client.get(Uri.parse(url), headers: headers)).bodyBytes; + try { + final http.Client client = _httpClient ?? http.Client(); + final http.Response res = await client.get(Uri.parse(url), headers: headers); + return res.bodyBytes; + } catch (e) { + debugPrint('SvgNetworkLoader.prepareMessage.error: $e'); + return null; + } } @override - String provideSvg(Uint8List? message) => - utf8.decode(message!, allowMalformed: true); + String provideSvg(Uint8List? message) => message == null ? '' : utf8.decode(message, allowMalformed: true); @override int get hashCode => Object.hash(url, headers, theme, colorMapper); diff --git a/third_party/packages/flutter_svg/lib/svg.dart b/third_party/packages/flutter_svg/lib/svg.dart index 964852a5787..25cd6ab3e0d 100644 --- a/third_party/packages/flutter_svg/lib/svg.dart +++ b/third_party/packages/flutter_svg/lib/svg.dart @@ -86,6 +86,7 @@ class SvgPicture extends StatelessWidget { this.semanticsLabel, this.excludeFromSemantics = false, this.clipBehavior = Clip.hardEdge, + this.errorBuilder, @Deprecated( 'No code should use this parameter. It never was implemented properly. ' 'The SVG theme must be set on the bytesLoader.') @@ -93,6 +94,7 @@ class SvgPicture extends StatelessWidget { @Deprecated('This no longer does anything.') bool cacheColorFilter = false, }); + /// Instantiates a widget that renders an SVG picture from an [AssetBundle]. /// /// The key will be derived from the `assetName`, `package`, and `bundle` @@ -190,6 +192,7 @@ class SvgPicture extends StatelessWidget { @Deprecated('Use colorFilter instead.') ui.BlendMode colorBlendMode = ui.BlendMode.srcIn, @Deprecated('This no longer does anything.') bool cacheColorFilter = false, + this.errorBuilder, }) : bytesLoader = SvgAssetLoader( assetName, packageName: package, @@ -251,6 +254,7 @@ class SvgPicture extends StatelessWidget { @Deprecated('This no longer does anything.') bool cacheColorFilter = false, SvgTheme? theme, http.Client? httpClient, + this.errorBuilder, }) : bytesLoader = SvgNetworkLoader( url, headers: headers, @@ -308,6 +312,7 @@ class SvgPicture extends StatelessWidget { this.clipBehavior = Clip.hardEdge, SvgTheme? theme, @Deprecated('This no longer does anything.') bool cacheColorFilter = false, + this.errorBuilder, }) : bytesLoader = SvgFileLoader(file, theme: theme), colorFilter = colorFilter ?? _getColorFilter(color, colorBlendMode); @@ -357,6 +362,7 @@ class SvgPicture extends StatelessWidget { this.clipBehavior = Clip.hardEdge, SvgTheme? theme, @Deprecated('This no longer does anything.') bool cacheColorFilter = false, + this.errorBuilder, }) : bytesLoader = SvgBytesLoader(bytes, theme: theme), colorFilter = colorFilter ?? _getColorFilter(color, colorBlendMode); @@ -406,6 +412,7 @@ class SvgPicture extends StatelessWidget { this.clipBehavior = Clip.hardEdge, SvgTheme? theme, @Deprecated('This no longer does anything.') bool cacheColorFilter = false, + this.errorBuilder, }) : bytesLoader = SvgStringLoader(string, theme: theme), colorFilter = colorFilter ?? _getColorFilter(color, colorBlendMode); @@ -490,6 +497,9 @@ class SvgPicture extends StatelessWidget { /// The color filter, if any, to apply to this widget. final ColorFilter? colorFilter; + /// The widget to show when failed to fetch, decode, and parse the SVG data. + final SvgPictureErrorWidgetBuilder? errorBuilder; + @override Widget build(BuildContext context) { return createCompatVectorGraphic( @@ -505,6 +515,7 @@ class SvgPicture extends StatelessWidget { placeholderBuilder: placeholderBuilder, clipViewbox: !allowDrawingOutsideViewBox, matchTextDirection: matchTextDirection, + errorBuilder: errorBuilder, ); } @@ -567,3 +578,10 @@ class SvgPicture extends StatelessWidget { )); } } + +/// The signature that [VectorGraphic.errorBuilder] uses to report exceptions. +typedef SvgPictureErrorWidgetBuilder = Widget Function( + BuildContext context, + Object error, + StackTrace stackTrace, +); diff --git a/third_party/packages/flutter_svg/pubspec.yaml b/third_party/packages/flutter_svg/pubspec.yaml index 5ac508491f6..f744598352f 100644 --- a/third_party/packages/flutter_svg/pubspec.yaml +++ b/third_party/packages/flutter_svg/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_svg description: An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files. repository: https://github.com/flutter/packages/tree/main/third_party/packages/flutter_svg issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+flutter_svg%22 -version: 2.0.11 +version: 2.0.12 environment: sdk: ^3.4.0 @@ -13,7 +13,7 @@ dependencies: sdk: flutter http: ^1.0.0 # See https://github.com/flutter/flutter/issues/157626 - vector_graphics: ">=1.1.11+1 <= 1.1.12" + vector_graphics: ">=1.1.14" vector_graphics_codec: ">=1.1.11+1 <= 1.1.12" vector_graphics_compiler: ">=1.1.11+1 <= 1.1.12" diff --git a/third_party/packages/flutter_svg/test/svg_picture_error_test.dart b/third_party/packages/flutter_svg/test/svg_picture_error_test.dart new file mode 100644 index 00000000000..2034b9ff56f --- /dev/null +++ b/third_party/packages/flutter_svg/test/svg_picture_error_test.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +void main() { + group('SvgPicture.string - use placeHolder or errorWidget if an error causes', () { + setUp(() {}); + tearDown(() {}); + testWidgets('load an empty string', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.string('')); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + }); + testWidgets('show placeholder', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.string( + 'an invalid svg format string', + placeholderBuilder: buildPlaceHolderWidget, + )); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expect(find.text('placeholder'), findsOneWidget); + }); + testWidgets('show errorWidget', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.string( + 'an invalid svg format string', + placeholderBuilder: buildPlaceHolderWidget, + errorBuilder: buildErrorWidget, + )); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expectOneErrorWidget(tester); + }); + }); + + group('SvgPicture.asset - use placeHolder or errorWidget if an error causes', () { + late FakeAssetBundle assetBundle; + setUp(() { + assetBundle = FakeAssetBundle(); + }); + tearDown(() {}); + testWidgets('load an empty asset', (WidgetTester tester) async { + await tester.pumpWidget(DefaultAssetBundle( + bundle: assetBundle, + child: SvgPicture.asset( + 'empty', + ))); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + }); + testWidgets('show placeholder', (WidgetTester tester) async { + await tester.pumpWidget(DefaultAssetBundle( + bundle: assetBundle, + child: SvgPicture.asset( + 'an invalid asset', + placeholderBuilder: buildPlaceHolderWidget, + ))); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expect(find.text('placeholder'), findsOneWidget); + }); + testWidgets('show errorWidget', (WidgetTester tester) async { + await tester.pumpWidget(DefaultAssetBundle( + bundle: assetBundle, + child: SvgPicture.asset( + 'an invalid asset', + placeholderBuilder: buildPlaceHolderWidget, + errorBuilder: buildErrorWidget, + ))); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expectOneErrorWidget(tester); + }); + }); + + group('SvgPicture.network - use placeHolder or errorWidget if an error causes', () { + late FakeHttpClient httpClient = FakeHttpClient(); + setUp(() { + httpClient = FakeHttpClient(); + }); + tearDown(() {}); + testWidgets('http exception', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.network('/404', httpClient: httpClient)); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + }); + testWidgets('wrong svg format - show placeholder', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.network( + '/200', + placeholderBuilder: buildPlaceHolderWidget, + httpClient: httpClient, + )); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expect(find.text('placeholder'), findsOneWidget); + }); + testWidgets('show placeholder - show errorWidget', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.network( + '/200', + placeholderBuilder: buildPlaceHolderWidget, + errorBuilder: buildErrorWidget, + httpClient: httpClient, + )); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expectOneErrorWidget(tester); + }); + }); +} + +void expectOneSvgPicture(WidgetTester tester) => expect(find.byType(SvgPicture), findsOneWidget); +void expectOneErrorWidget(WidgetTester tester) => expect(find.text('error'), findsOneWidget); + +Widget buildPlaceHolderWidget(BuildContext context) => const Text('placeholder', textDirection: TextDirection.ltr); +Widget buildErrorWidget(BuildContext context, Object error, StackTrace stackTrace) => + const Text('error', textDirection: TextDirection.ltr); + +class FakeAssetBundle extends Fake implements AssetBundle { + @override + Future load(String key) async { + if (key == 'empty') { + return Future.value(ByteData(0)); + } + throw Exception('error'); + } +} + +class FakeHttpClient extends Fake implements http.Client { + FakeHttpClient(); + + @override + Future get(Uri url, {Map? headers}) async { + debugPrint('FakeHttpClient.get: ${url.path}'); + if (url.path == '/404') { + return Future.value(http.Response('', HttpStatus.notFound)); + } else if (url.path == '/200') { + return Future.value(http.Response('''invalid svg format''', HttpStatus.ok)); + } else { + throw Exception('$url is invalid'); + } + } +}