diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 89b83d8bc2..b5192b061d 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -38,9 +38,11 @@ Future main() async { }, // Init your App. appRunner: () => runApp( - DefaultAssetBundle( - bundle: SentryAssetBundle(enableStructuredDataTracing: true), - child: MyApp(), + SentryScreenshot(child: + DefaultAssetBundle( + bundle: SentryAssetBundle(enableStructuredDataTracing: true), + child: MyApp(), + ), ), ), ); diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index 1dfd553789..a17392020f 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -8,3 +8,4 @@ export 'src/sentry_flutter_options.dart'; export 'src/flutter_sentry_attachment.dart'; export 'src/sentry_asset_bundle.dart'; export 'src/integrations/on_error_integration.dart'; +export 'src/screenshot/sentry_screenshot.dart' hide ScreenshotAttachment; diff --git a/flutter/lib/src/screenshot/sentry_screenshot.dart b/flutter/lib/src/screenshot/sentry_screenshot.dart new file mode 100644 index 0000000000..1c1efb2c3d --- /dev/null +++ b/flutter/lib/src/screenshot/sentry_screenshot.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui show ImageByteFormat; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:sentry/sentry.dart'; + +/// Key which is used to identify the [RepaintBoundary] which gets captured +final _gloablKey = GlobalKey(debugLabel: 'sentry_screenshot'); + +/// You can add screenshots of [child] to crash reports by adding this widget. +/// Ideally you are adding it around your app widget like in the following +/// example. +/// ```dart +/// runApp(SentryScreenshot(child: App())); +/// ``` +/// +/// Remarks: +/// - Depending on the place where it's used, you might have a transparent +/// background. +/// - Platform Views currently can't be captured. +/// - It only works on Flutters Canvas Kit Web renderer. For more information +/// see https://flutter.dev/docs/development/tools/web-renderers +/// - You can only have one [SentryScreenshot] widget in your widget tree at all +/// times. +class SentryScreenshot extends StatefulWidget { + const SentryScreenshot({Key? key, required this.child, this.hub}) + : super(key: key); + + final Widget child; + final Hub? hub; + + @override + _SentryScreenshotState createState() => _SentryScreenshotState(); +} + +class _SentryScreenshotState extends State { + Hub get hub => widget.hub ?? HubAdapter(); + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + key: _gloablKey, + child: widget.child, + ); + } + + @override + void initState() { + super.initState(); + hub.configureScope((scope) { + scope.addAttachment(ScreenshotAttachment()); + }); + } + + @override + void dispose() { + hub.configureScope((scope) { + // The following doesn't work + // scope.attachements.remove(ScreenshotAttachment()); + }); + super.dispose(); + } +} + +class ScreenshotAttachment implements SentryAttachment { + ScreenshotAttachment(); + + @override + String attachmentType = SentryAttachment.typeAttachmentDefault; + + @override + String? contentType = 'image/png'; + + @override + String filename = 'screenshot.png'; + + @override + bool addToTransactions = true; + + @override + FutureOr get bytes async { + //return await createScreenshot() ?? Uint8List.fromList([]); + final instance = SchedulerBinding.instance; + if (instance == null) { + return Uint8List.fromList([]); + } + + final _completer = Completer(); + // We add an post frame callback because we aren't able to take a screenshot + // if there's currently a draw in process. + instance.addPostFrameCallback((timeStamp) async { + final image = await createScreenshot(); + _completer.complete(image); + }); + return await _completer.future ?? Uint8List.fromList([]); + } + + @visibleForTesting + Future createScreenshot() async { + try { + final renderObject = _gloablKey.currentContext?.findRenderObject(); + + if (renderObject is RenderRepaintBoundary) { + final image = await renderObject.toImage(pixelRatio: 1); + // At the time of writing there's no other image format available which + // Sentry understands. + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + return bytes?.buffer.asUint8List(); + } + } catch (_) {} + return null; + } + + +} diff --git a/flutter/test/screenshot/screenshot_test.dart b/flutter/test/screenshot/screenshot_test.dart new file mode 100644 index 0000000000..15873a7ae9 --- /dev/null +++ b/flutter/test/screenshot/screenshot_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/screenshot/sentry_screenshot.dart'; + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + test('ScreenshotAttachment default values', () { + final attachment = ScreenshotAttachment(); + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, 'image/png'); + expect(attachment.filename, 'screenshot.png'); + }); + + testWidgets('creates screenshot', (tester) async { + await tester.runAsync(() async { + final widget = SentryScreenshot( + child: MaterialApp( + home: Scaffold( + body: Center( + child: Text('Hello World'), + ), + ), + ), + ); + + await tester.pumpWidget(widget); + + final screenshot = await ScreenshotAttachment().createScreenshot(); + + expect(screenshot, isNotNull); + }); + }); +}