From 42be7f3c66b3db16730e7daf953592efb76d072c Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Tue, 15 Oct 2024 10:19:04 +0200 Subject: [PATCH 01/13] use view hierachy for screenshots --- flutter/lib/sentry_flutter.dart | 2 +- .../screenshot_event_processor.dart | 168 ++++++++++-------- .../src/native/cocoa/sentry_native_cocoa.dart | 4 +- .../src/native/java/sentry_native_java.dart | 2 +- .../lib/src/replay/scheduled_recorder.dart | 5 +- .../src/replay/scheduled_recorder_config.dart | 11 ++ .../masking_config.dart | 0 .../src/{replay => screenshot}/recorder.dart | 2 +- .../recorder_config.dart | 10 -- .../{replay => screenshot}/widget_filter.dart | 0 flutter/lib/src/sentry_flutter_options.dart | 2 + flutter/lib/src/sentry_replay_options.dart | 100 +---------- .../lib/src/sentry_screenshot_options.dart | 107 +++++++++++ flutter/test/replay/masking_config_test.dart | 2 +- flutter/test/replay/recorder_config_test.dart | 2 +- flutter/test/replay/recorder_test.dart | 4 +- .../test/replay/scheduled_recorder_test.dart | 2 +- flutter/test/replay/widget_filter_test.dart | 2 +- 18 files changed, 236 insertions(+), 189 deletions(-) create mode 100644 flutter/lib/src/replay/scheduled_recorder_config.dart rename flutter/lib/src/{replay => screenshot}/masking_config.dart (100%) rename flutter/lib/src/{replay => screenshot}/recorder.dart (98%) rename flutter/lib/src/{replay => screenshot}/recorder_config.dart (67%) rename flutter/lib/src/{replay => screenshot}/widget_filter.dart (100%) create mode 100644 flutter/lib/src/sentry_screenshot_options.dart diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index c3e604e634..e902455ce6 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -12,7 +12,7 @@ export 'src/sentry_replay_options.dart'; export 'src/flutter_sentry_attachment.dart'; export 'src/sentry_asset_bundle.dart' show SentryAssetBundle; export 'src/integrations/on_error_integration.dart'; -export 'src/replay/masking_config.dart' show SentryMaskingDecision; +export 'src/screenshot/masking_config.dart' show SentryMaskingDecision; export 'src/screenshot/sentry_mask_widget.dart'; export 'src/screenshot/sentry_unmask_widget.dart'; export 'src/screenshot/sentry_screenshot_widget.dart'; diff --git a/flutter/lib/src/event_processor/screenshot_event_processor.dart b/flutter/lib/src/event_processor/screenshot_event_processor.dart index 2b9c80dc05..0e70c8ff5f 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -4,6 +4,8 @@ import 'dart:typed_data'; import 'dart:ui'; import 'package:sentry/sentry.dart'; +import '../screenshot/recorder.dart'; +import '../screenshot/recorder_config.dart'; import '../screenshot/sentry_screenshot_widget.dart'; import '../sentry_flutter_options.dart'; import 'package:flutter/rendering.dart'; @@ -19,6 +21,8 @@ class ScreenshotEventProcessor implements EventProcessor { bool get _hasSentryScreenshotWidget => sentryScreenshotWidgetGlobalKey.currentContext != null; + Uint8List? _screenshotCache; + @override Future apply(SentryEvent event, Hint hint) async { if (event is SentryTransaction) { @@ -75,83 +79,107 @@ class ScreenshotEventProcessor implements EventProcessor { return event; } - final bytes = await _createScreenshot(); - if (bytes != null) { - hint.screenshot = SentryAttachment.fromScreenshotData(bytes); + // ignore: deprecated_member_use + var recorder = ScreenshotRecorder( + ScreenshotRecorderConfig( + width: window.display.size.width.toInt(), + height: window.display.size.height.toInt()), + _options); + + await recorder.capture((Image image) async { + _screenshotCache = await _convertImageToUint8List(image); + }); + + if (_screenshotCache != null) { + hint.screenshot = SentryAttachment.fromScreenshotData(_screenshotCache!); } + _screenshotCache = null; return event; } - Future _createScreenshot() async { - try { - final renderObject = - sentryScreenshotWidgetGlobalKey.currentContext?.findRenderObject(); - if (renderObject is RenderRepaintBoundary) { - // ignore: deprecated_member_use - final pixelRatio = window.devicePixelRatio; - var imageResult = _getImage(renderObject, pixelRatio); - Image image; - if (imageResult is Future) { - image = await imageResult; - } else { - image = imageResult; - } - // At the time of writing there's no other image format available which - // Sentry understands. - - if (image.width == 0 || image.height == 0) { - _options.logger(SentryLevel.debug, - 'View\'s width and height is zeroed, not taking screenshot.'); - return null; - } + // Future _createScreenshot() async { + // try { + // final renderObject = + // sentryScreenshotWidgetGlobalKey.currentContext?.findRenderObject(); + // if (renderObject is RenderRepaintBoundary) { + // // ignore: deprecated_member_use + // final pixelRatio = window.devicePixelRatio; + // var imageResult = _getImage(renderObject, pixelRatio); + // Image image; + // if (imageResult is Future) { + // image = await imageResult; + // } else { + // image = imageResult; + // } + // // At the time of writing there's no other image format available which + // // Sentry understands. + // + // if (image.width == 0 || image.height == 0) { + // _options.logger(SentryLevel.debug, + // 'View\'s width and height is zeroed, not taking screenshot.'); + // return null; + // } + // + // final targetResolution = _options.screenshotQuality.targetResolution(); + // if (targetResolution != null) { + // var ratioWidth = targetResolution / image.width; + // var ratioHeight = targetResolution / image.height; + // var ratio = min(ratioWidth, ratioHeight); + // if (ratio > 0.0 && ratio < 1.0) { + // imageResult = _getImage(renderObject, ratio * pixelRatio); + // if (imageResult is Future) { + // image = await imageResult; + // } else { + // image = imageResult; + // } + // } + // } + // final byteData = await image.toByteData(format: ImageByteFormat.png); + // + // final bytes = byteData?.buffer.asUint8List(); + // if (bytes?.isNotEmpty == true) { + // return bytes; + // } else { + // _options.logger(SentryLevel.debug, + // 'Screenshot is 0 bytes, not attaching the image.'); + // return null; + // } + // } + // } catch (exception, stackTrace) { + // _options.logger( + // SentryLevel.error, + // 'Taking screenshot failed.', + // exception: exception, + // stackTrace: stackTrace, + // ); + // if (_options.automatedTestMode) { + // rethrow; + // } + // } + // return null; + // } - final targetResolution = _options.screenshotQuality.targetResolution(); - if (targetResolution != null) { - var ratioWidth = targetResolution / image.width; - var ratioHeight = targetResolution / image.height; - var ratio = min(ratioWidth, ratioHeight); - if (ratio > 0.0 && ratio < 1.0) { - imageResult = _getImage(renderObject, ratio * pixelRatio); - if (imageResult is Future) { - image = await imageResult; - } else { - image = imageResult; - } - } - } - final byteData = await image.toByteData(format: ImageByteFormat.png); + Future _convertImageToUint8List(Image image) async { + final byteData = await image.toByteData(format: ImageByteFormat.png); - final bytes = byteData?.buffer.asUint8List(); - if (bytes?.isNotEmpty == true) { - return bytes; - } else { - _options.logger(SentryLevel.debug, - 'Screenshot is 0 bytes, not attaching the image.'); - return null; - } - } - } catch (exception, stackTrace) { + final bytes = byteData?.buffer.asUint8List(); + if (bytes?.isNotEmpty == true) { + return bytes; + } else { _options.logger( - SentryLevel.error, - 'Taking screenshot failed.', - exception: exception, - stackTrace: stackTrace, - ); - if (_options.automatedTestMode) { - rethrow; - } - } - return null; - } - - FutureOr _getImage( - RenderRepaintBoundary repaintBoundary, double pixelRatio) { - // This one is a hack to use https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary/toImage.html on versions older than 3.7 and https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary/toImageSync.html on versions equal or newer than 3.7 - try { - return (repaintBoundary as dynamic).toImageSync(pixelRatio: pixelRatio) - as Image; - } on NoSuchMethodError catch (_) { - return repaintBoundary.toImage(pixelRatio: pixelRatio); + SentryLevel.debug, 'Screenshot is 0 bytes, not attaching the image.'); + return null; } } + // + // FutureOr _getImage( + // RenderRepaintBoundary repaintBoundary, double pixelRatio) { + // // This one is a hack to use https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary/toImage.html on versions older than 3.7 and https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary/toImageSync.html on versions equal or newer than 3.7 + // try { + // return (repaintBoundary as dynamic).toImageSync(pixelRatio: pixelRatio) + // as Image; + // } on NoSuchMethodError catch (_) { + // return repaintBoundary.toImage(pixelRatio: pixelRatio); + // } + // } } diff --git a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index a042f325ae..850165fcec 100644 --- a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -6,9 +6,9 @@ import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../event_processor/replay_event_processor.dart'; +import '../../screenshot/recorder.dart'; +import '../../screenshot/recorder_config.dart'; import '../../replay/integration.dart'; -import '../../replay/recorder.dart'; -import '../../replay/recorder_config.dart'; import '../sentry_native_channel.dart'; import 'binding.dart' as cocoa; diff --git a/flutter/lib/src/native/java/sentry_native_java.dart b/flutter/lib/src/native/java/sentry_native_java.dart index 0b8d2eb0f6..91c127de4f 100644 --- a/flutter/lib/src/native/java/sentry_native_java.dart +++ b/flutter/lib/src/native/java/sentry_native_java.dart @@ -7,7 +7,7 @@ import '../../../sentry_flutter.dart'; import '../../event_processor/replay_event_processor.dart'; import '../../replay/integration.dart'; import '../../replay/scheduled_recorder.dart'; -import '../../replay/recorder_config.dart'; +import '../../replay/scheduled_recorder_config.dart'; import '../sentry_native_channel.dart'; // Note: currently this doesn't do anything. Later, it shall be used with diff --git a/flutter/lib/src/replay/scheduled_recorder.dart b/flutter/lib/src/replay/scheduled_recorder.dart index c575278a74..5c040fc96e 100644 --- a/flutter/lib/src/replay/scheduled_recorder.dart +++ b/flutter/lib/src/replay/scheduled_recorder.dart @@ -2,10 +2,11 @@ import 'dart:async'; import 'dart:ui'; import 'package:meta/meta.dart'; +import 'package:sentry_flutter/src/replay/scheduled_recorder_config.dart'; import '../../sentry_flutter.dart'; -import 'recorder.dart'; -import 'recorder_config.dart'; +import '../screenshot/recorder.dart'; +import '../screenshot/recorder_config.dart'; import 'scheduler.dart'; @internal diff --git a/flutter/lib/src/replay/scheduled_recorder_config.dart b/flutter/lib/src/replay/scheduled_recorder_config.dart new file mode 100644 index 0000000000..67c5d672ef --- /dev/null +++ b/flutter/lib/src/replay/scheduled_recorder_config.dart @@ -0,0 +1,11 @@ +import '../screenshot/recorder_config.dart'; + +class ScheduledScreenshotRecorderConfig extends ScreenshotRecorderConfig { + final int frameRate; + + const ScheduledScreenshotRecorderConfig({ + super.width, + super.height, + required this.frameRate, + }); +} diff --git a/flutter/lib/src/replay/masking_config.dart b/flutter/lib/src/screenshot/masking_config.dart similarity index 100% rename from flutter/lib/src/replay/masking_config.dart rename to flutter/lib/src/screenshot/masking_config.dart diff --git a/flutter/lib/src/replay/recorder.dart b/flutter/lib/src/screenshot/recorder.dart similarity index 98% rename from flutter/lib/src/replay/recorder.dart rename to flutter/lib/src/screenshot/recorder.dart index f15b79a072..716dffa141 100644 --- a/flutter/lib/src/replay/recorder.dart +++ b/flutter/lib/src/screenshot/recorder.dart @@ -87,7 +87,7 @@ class ScreenshotRecorder { try { await callback(finalImage); } finally { - finalImage.dispose(); + finalImage.dispose(); // image needs to be disposed manually } } finally { picture.dispose(); diff --git a/flutter/lib/src/replay/recorder_config.dart b/flutter/lib/src/screenshot/recorder_config.dart similarity index 67% rename from flutter/lib/src/replay/recorder_config.dart rename to flutter/lib/src/screenshot/recorder_config.dart index 9649a33823..ff39ddf163 100644 --- a/flutter/lib/src/replay/recorder_config.dart +++ b/flutter/lib/src/screenshot/recorder_config.dart @@ -17,13 +17,3 @@ class ScreenshotRecorderConfig { return min(width! / srcWidth, height! / srcHeight); } } - -class ScheduledScreenshotRecorderConfig extends ScreenshotRecorderConfig { - final int frameRate; - - const ScheduledScreenshotRecorderConfig({ - super.width, - super.height, - required this.frameRate, - }); -} diff --git a/flutter/lib/src/replay/widget_filter.dart b/flutter/lib/src/screenshot/widget_filter.dart similarity index 100% rename from flutter/lib/src/replay/widget_filter.dart rename to flutter/lib/src/screenshot/widget_filter.dart diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index e400aa3536..073668e00b 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -15,6 +15,7 @@ import 'event_processor/screenshot_event_processor.dart'; import 'screenshot/sentry_screenshot_widget.dart'; import 'sentry_flutter.dart'; import 'sentry_replay_options.dart'; +import 'sentry_screenshot_options.dart'; import 'user_interaction/sentry_user_interaction_widget.dart'; /// This class adds options which are only available in a Flutter environment. @@ -380,6 +381,7 @@ class SentryFlutterOptions extends SentryOptions { class _SentryFlutterExperimentalOptions { /// Replay recording configuration. final replay = SentryReplayOptions(); + final screenshot = SentryScreenshotOptions(); } /// Callback being executed in [ScreenshotEventProcessor], deciding if a diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart index a6e83fec4f..5b49a9cfc3 100644 --- a/flutter/lib/src/sentry_replay_options.dart +++ b/flutter/lib/src/sentry_replay_options.dart @@ -2,14 +2,15 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import 'replay/masking_config.dart'; -import 'replay/widget_filter.dart'; +import 'screenshot/masking_config.dart'; +import 'screenshot/widget_filter.dart'; import 'screenshot/sentry_mask_widget.dart'; import 'screenshot/sentry_unmask_widget.dart'; +import 'sentry_screenshot_options.dart'; /// Configuration of the experimental replay feature. @experimental -class SentryReplayOptions { +class SentryReplayOptions extends SentryScreenshotOptions { double? _sessionSampleRate; /// A percentage of sessions in which a replay will be created. @@ -32,110 +33,17 @@ class SentryReplayOptions { _onErrorSampleRate = value; } - /// Mask all text content. Draws a rectangle of text bounds with text color - /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. - /// Default is enabled. - var maskAllText = true; - @Deprecated('Use maskAllText instead') bool get redactAllText => maskAllText; set redactAllText(bool value) => maskAllText = value; - /// Mask content of all images. Draws a rectangle of image bounds with image's - /// dominant color on top. Currently, only [Image] widgets are redacted. - /// Default is enabled (except for asset images, see [maskAssetImages]). - var maskAllImages = true; - @Deprecated('Use maskAllImages instead') bool get redactAllImages => maskAllImages; set redactAllImages(bool value) => maskAllImages = value; - /// Redact asset images coming from the root asset bundle. - var maskAssetImages = false; - final _userMaskingRules = []; - @internal - SentryMaskingConfig buildMaskingConfig() { - // First, we collect rules defined by the user (so they're applied first). - final rules = _userMaskingRules.toList(); - - // Then, we apply rules for [SentryMask] and [SentryUnmask]. - rules.add(const SentryMaskingConstantRule( - SentryMaskingDecision.mask)); - rules.add(const SentryMaskingConstantRule( - SentryMaskingDecision.unmask)); - - // Then, we apply apply rules based on the configuration. - if (maskAllImages) { - if (maskAssetImages) { - rules.add( - const SentryMaskingConstantRule(SentryMaskingDecision.mask)); - } else { - rules - .add(const SentryMaskingCustomRule(_maskImagesExceptAssets)); - } - } else { - assert(!maskAssetImages, - "maskAssetImages can't be true if maskAllImages is false"); - } - if (maskAllText) { - rules.add( - const SentryMaskingConstantRule(SentryMaskingDecision.mask)); - rules.add(const SentryMaskingConstantRule( - SentryMaskingDecision.mask)); - } - return SentryMaskingConfig(rules); - } - - /// Mask given widget type [T] (or subclasses of [T]) in the replay. - /// Note: masking rules are called in the order they're added so if a previous - /// rule already makes a decision, this rule won't be called. - void mask() { - assert(T != SentryMask); - assert(T != SentryUnmask); - _userMaskingRules - .add(SentryMaskingConstantRule(SentryMaskingDecision.mask)); - } - - /// Unmask given widget type [T] (or subclasses of [T]) in the replay. This is - /// useful to explicitly show certain widgets that would otherwise be masked - /// by other rules, for example default [maskAllText] or [maskAllImages]. - /// The [SentryMaskingDecision.unmask] will apply to the widget and its children, - /// so no other rules will be checked for the children. - /// Note: masking rules are called in the order they're added so if a previous - /// rule already makes a decision, this rule won't be called. - void unmask() { - assert(T != SentryMask); - assert(T != SentryUnmask); - _userMaskingRules - .add(SentryMaskingConstantRule(SentryMaskingDecision.unmask)); - } - - /// Provide a custom callback to decide whether to mask the widget of class - /// [T] (or subclasses of [T]). - /// Note: masking rules are called in the order they're added so if a previous - /// rule already makes a decision, this rule won't be called. - void maskCallback( - SentryMaskingDecision Function(Element, T) shouldMask) { - assert(T != SentryMask); - assert(T != SentryUnmask); - _userMaskingRules.add(SentryMaskingCustomRule(shouldMask)); - } - @internal bool get isEnabled => ((sessionSampleRate ?? 0) > 0) || ((onErrorSampleRate ?? 0) > 0); } - -SentryMaskingDecision _maskImagesExceptAssets(Element element, Widget widget) { - if (widget is Image) { - final image = widget.image; - if (image is AssetBundleImageProvider) { - if (WidgetFilter.isBuiltInAssetImage(image, rootBundle)) { - return SentryMaskingDecision.continueProcessing; - } - } - } - return SentryMaskingDecision.mask; -} diff --git a/flutter/lib/src/sentry_screenshot_options.dart b/flutter/lib/src/sentry_screenshot_options.dart new file mode 100644 index 0000000000..c42860be06 --- /dev/null +++ b/flutter/lib/src/sentry_screenshot_options.dart @@ -0,0 +1,107 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import 'screenshot/masking_config.dart'; +import 'screenshot/widget_filter.dart'; +import 'screenshot/sentry_mask_widget.dart'; +import 'screenshot/sentry_unmask_widget.dart'; + +/// Configuration of the experimental screenshot feature. +@experimental +class SentryScreenshotOptions { + /// Mask all text content. Draws a rectangle of text bounds with text color + /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. + /// Default is enabled. + var maskAllText = true; + + /// Mask content of all images. Draws a rectangle of image bounds with image's + /// dominant color on top. Currently, only [Image] widgets are redacted. + /// Default is enabled (except for asset images, see [maskAssetImages]). + var maskAllImages = true; + + /// Redact asset images coming from the root asset bundle. + var maskAssetImages = false; + + final _userMaskingRules = []; + + @internal + SentryMaskingConfig buildMaskingConfig() { + // First, we collect rules defined by the user (so they're applied first). + final rules = _userMaskingRules.toList(); + + // Then, we apply rules for [SentryMask] and [SentryUnmask]. + rules.add(const SentryMaskingConstantRule( + SentryMaskingDecision.mask)); + rules.add(const SentryMaskingConstantRule( + SentryMaskingDecision.unmask)); + + // Then, we apply apply rules based on the configuration. + if (maskAllImages) { + if (maskAssetImages) { + rules.add( + const SentryMaskingConstantRule(SentryMaskingDecision.mask)); + } else { + rules + .add(const SentryMaskingCustomRule(_maskImagesExceptAssets)); + } + } else { + assert(!maskAssetImages, + "maskAssetImages can't be true if maskAllImages is false"); + } + if (maskAllText) { + rules.add( + const SentryMaskingConstantRule(SentryMaskingDecision.mask)); + rules.add(const SentryMaskingConstantRule( + SentryMaskingDecision.mask)); + } + return SentryMaskingConfig(rules); + } + + /// Mask given widget type [T] (or subclasses of [T]) in the replay. + /// Note: masking rules are called in the order they're added so if a previous + /// rule already makes a decision, this rule won't be called. + void mask() { + assert(T != SentryMask); + assert(T != SentryUnmask); + _userMaskingRules + .add(SentryMaskingConstantRule(SentryMaskingDecision.mask)); + } + + /// Unmask given widget type [T] (or subclasses of [T]) in the replay. This is + /// useful to explicitly show certain widgets that would otherwise be masked + /// by other rules, for example default [maskAllText] or [maskAllImages]. + /// The [SentryMaskingDecision.unmask] will apply to the widget and its children, + /// so no other rules will be checked for the children. + /// Note: masking rules are called in the order they're added so if a previous + /// rule already makes a decision, this rule won't be called. + void unmask() { + assert(T != SentryMask); + assert(T != SentryUnmask); + _userMaskingRules + .add(SentryMaskingConstantRule(SentryMaskingDecision.unmask)); + } + + /// Provide a custom callback to decide whether to mask the widget of class + /// [T] (or subclasses of [T]). + /// Note: masking rules are called in the order they're added so if a previous + /// rule already makes a decision, this rule won't be called. + void maskCallback( + SentryMaskingDecision Function(Element, T) shouldMask) { + assert(T != SentryMask); + assert(T != SentryUnmask); + _userMaskingRules.add(SentryMaskingCustomRule(shouldMask)); + } +} + +SentryMaskingDecision _maskImagesExceptAssets(Element element, Widget widget) { + if (widget is Image) { + final image = widget.image; + if (image is AssetBundleImageProvider) { + if (WidgetFilter.isBuiltInAssetImage(image, rootBundle)) { + return SentryMaskingDecision.continueProcessing; + } + } + } + return SentryMaskingDecision.mask; +} diff --git a/flutter/test/replay/masking_config_test.dart b/flutter/test/replay/masking_config_test.dart index 46e6a99261..4323521541 100644 --- a/flutter/test/replay/masking_config_test.dart +++ b/flutter/test/replay/masking_config_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/replay/masking_config.dart'; +import 'package:sentry_flutter/src/screenshot/masking_config.dart'; import 'test_widget.dart'; diff --git a/flutter/test/replay/recorder_config_test.dart b/flutter/test/replay/recorder_config_test.dart index d884073e91..7aa4612f7e 100644 --- a/flutter/test/replay/recorder_config_test.dart +++ b/flutter/test/replay/recorder_config_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/src/replay/recorder_config.dart'; +import 'package:sentry_flutter/src/screenshot/recorder_config.dart'; void main() async { group('$ScreenshotRecorderConfig', () { diff --git a/flutter/test/replay/recorder_test.dart b/flutter/test/replay/recorder_test.dart index 2df4334c5b..efef4137dd 100644 --- a/flutter/test/replay/recorder_test.dart +++ b/flutter/test/replay/recorder_test.dart @@ -6,8 +6,8 @@ library dart_test; import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/src/replay/recorder.dart'; -import 'package:sentry_flutter/src/replay/recorder_config.dart'; +import 'package:sentry_flutter/src/screenshot/recorder.dart'; +import 'package:sentry_flutter/src/screenshot/recorder_config.dart'; import '../mocks.dart'; import 'test_widget.dart'; diff --git a/flutter/test/replay/scheduled_recorder_test.dart b/flutter/test/replay/scheduled_recorder_test.dart index 7ace54c18e..59cf991fc6 100644 --- a/flutter/test/replay/scheduled_recorder_test.dart +++ b/flutter/test/replay/scheduled_recorder_test.dart @@ -7,7 +7,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/src/replay/scheduled_recorder.dart'; -import 'package:sentry_flutter/src/replay/recorder_config.dart'; +import 'package:sentry_flutter/src/replay/scheduled_recorder_config.dart'; import '../mocks.dart'; import 'test_widget.dart'; diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart index ad76e9bfaa..3d72c5a3e2 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/replay/widget_filter_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/replay/widget_filter.dart'; +import 'package:sentry_flutter/src/screenshot/widget_filter.dart'; import 'test_widget.dart'; From 49a413b86e3fa5c120cae0ec24e5435b182d0e9e Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Tue, 15 Oct 2024 16:42:16 +0200 Subject: [PATCH 02/13] fix tests and distinguish between ScheduledScreenshotRecorderConfig and ScreenshotRecorderConfig. --- .../screenshot_event_processor.dart | 4 +- flutter/lib/src/screenshot/recorder.dart | 23 +++++++-- .../lib/src/screenshot/recorder_config.dart | 9 +++- .../screenshot/sentry_screenshot_quality.dart | 26 ++++++++++ flutter/test/replay/recorder_test.dart | 50 +++++++++++++++++-- .../sentry_screenshot_quality_test.dart | 45 +++++++++++++++++ 6 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 flutter/test/screenshot/sentry_screenshot_quality_test.dart diff --git a/flutter/lib/src/event_processor/screenshot_event_processor.dart b/flutter/lib/src/event_processor/screenshot_event_processor.dart index 0e70c8ff5f..2f72d7f743 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -81,9 +81,7 @@ class ScreenshotEventProcessor implements EventProcessor { // ignore: deprecated_member_use var recorder = ScreenshotRecorder( - ScreenshotRecorderConfig( - width: window.display.size.width.toInt(), - height: window.display.size.height.toInt()), + ScreenshotRecorderConfig(quality: _options.screenshotQuality), _options); await recorder.capture((Image image) async { diff --git a/flutter/lib/src/screenshot/recorder.dart b/flutter/lib/src/screenshot/recorder.dart index 716dffa141..e6e6dc5c6f 100644 --- a/flutter/lib/src/screenshot/recorder.dart +++ b/flutter/lib/src/screenshot/recorder.dart @@ -5,6 +5,8 @@ import 'package:flutter/rendering.dart'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; +import '../replay/scheduled_recorder_config.dart'; +import 'masking_config.dart'; import 'recorder_config.dart'; import 'widget_filter.dart'; @@ -21,7 +23,13 @@ class ScreenshotRecorder { bool warningLogged = false; ScreenshotRecorder(this.config, this.options) { - final maskingConfig = options.experimental.replay.buildMaskingConfig(); + SentryMaskingConfig maskingConfig; + if (config is ScheduledScreenshotRecorderConfig) { + maskingConfig = options.experimental.replay.buildMaskingConfig(); + } else { + maskingConfig = options.experimental.screenshot.buildMaskingConfig(); + } + if (maskingConfig.length > 0) { _widgetFilter = WidgetFilter(maskingConfig, options.logger); } @@ -82,8 +90,17 @@ class ScreenshotRecorder { final picture = recorder.endRecording(); try { - final finalImage = await picture.toImage( - (srcWidth * pixelRatio).round(), (srcHeight * pixelRatio).round()); + Image finalImage; + if (config is ScheduledScreenshotRecorderConfig) { + finalImage = await picture.toImage((srcWidth * pixelRatio).round(), + (srcHeight * pixelRatio).round()); + } else { + final targetHeight = config.quality + .calculateHeight(srcWidth.toInt(), srcHeight.toInt()); + final targetWidth = config.quality + .calculateWidth(srcWidth.toInt(), srcHeight.toInt()); + finalImage = await picture.toImage(targetWidth, targetHeight); + } try { await callback(finalImage); } finally { diff --git a/flutter/lib/src/screenshot/recorder_config.dart b/flutter/lib/src/screenshot/recorder_config.dart index ff39ddf163..64a1d7d183 100644 --- a/flutter/lib/src/screenshot/recorder_config.dart +++ b/flutter/lib/src/screenshot/recorder_config.dart @@ -2,12 +2,19 @@ import 'dart:math'; import 'package:meta/meta.dart'; +import '../../sentry_flutter.dart'; + @internal class ScreenshotRecorderConfig { final int? width; final int? height; + final SentryScreenshotQuality quality; - const ScreenshotRecorderConfig({this.width, this.height}); + const ScreenshotRecorderConfig({ + this.width, + this.height, + this.quality = SentryScreenshotQuality.full, + }); double getPixelRatio(double srcWidth, double srcHeight) { assert((width == null) == (height == null)); diff --git a/flutter/lib/src/screenshot/sentry_screenshot_quality.dart b/flutter/lib/src/screenshot/sentry_screenshot_quality.dart index d42e622966..2b97791983 100644 --- a/flutter/lib/src/screenshot/sentry_screenshot_quality.dart +++ b/flutter/lib/src/screenshot/sentry_screenshot_quality.dart @@ -17,4 +17,30 @@ enum SentryScreenshotQuality { return 854; } } + + int calculateHeight(int width, int height) { + if (this == SentryScreenshotQuality.full) { + return height; + } else { + if (height > width) { + return targetResolution()!; + } else { + var ratio = targetResolution()! / width; + return (height * ratio).round(); + } + } + } + + int calculateWidth(int width, int height) { + if (this == SentryScreenshotQuality.full) { + return width; + } else { + if (width > height) { + return targetResolution()!; + } else { + var ratio = targetResolution()! / height; + return (width * ratio).round(); + } + } + } } diff --git a/flutter/test/replay/recorder_test.dart b/flutter/test/replay/recorder_test.dart index efef4137dd..fd9987cff6 100644 --- a/flutter/test/replay/recorder_test.dart +++ b/flutter/test/replay/recorder_test.dart @@ -6,6 +6,7 @@ library dart_test; import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/screenshot/recorder.dart'; import 'package:sentry_flutter/src/screenshot/recorder_config.dart'; @@ -19,20 +20,61 @@ void main() async { final fixture = await _Fixture.create(tester); expect(fixture.capture(), completion('800x600')); }); + + testWidgets('captures full resolution images - portrait', (tester) async { + await tester.binding.setSurfaceSize(Size(2000, 4000)); + final fixture = await _Fixture.create(tester); + expect(fixture.capture(), completion('2000x4000')); + }); + + testWidgets('captures full resolution images - landscape', (tester) async { + await tester.binding.setSurfaceSize(Size(4000, 2000)); + final fixture = await _Fixture.create(tester); + expect(fixture.capture(), completion('4000x2000')); + }); + + testWidgets('captures high resolution images - portrait', (tester) async { + await tester.binding.setSurfaceSize(Size(2000, 4000)); + final fixture = + await _Fixture.create(tester, quality: SentryScreenshotQuality.high); + expect(fixture.capture(), completion('960x1920')); + }); + + testWidgets('captures high resolution images - landscape', (tester) async { + await tester.binding.setSurfaceSize(Size(4000, 2000)); + final fixture = + await _Fixture.create(tester, quality: SentryScreenshotQuality.high); + expect(fixture.capture(), completion('1920x960')); + }); + + testWidgets('captures medium resolution images', (tester) async { + await tester.binding.setSurfaceSize(Size(2000, 4000)); + final fixture = + await _Fixture.create(tester, quality: SentryScreenshotQuality.medium); + expect(fixture.capture(), completion('640x1280')); + }); + + testWidgets('captures low resolution images', (tester) async { + await tester.binding.setSurfaceSize(Size(2000, 4000)); + final fixture = + await _Fixture.create(tester, quality: SentryScreenshotQuality.low); + expect(fixture.capture(), completion('427x854')); + }); } class _Fixture { late final ScreenshotRecorder sut; - _Fixture._() { + _Fixture({SentryScreenshotQuality quality = SentryScreenshotQuality.full}) { sut = ScreenshotRecorder( - ScreenshotRecorderConfig(), + ScreenshotRecorderConfig(quality: quality), defaultTestOptions()..bindingUtils = TestBindingWrapper(), ); } - static Future<_Fixture> create(WidgetTester tester) async { - final fixture = _Fixture._(); + static Future<_Fixture> create(WidgetTester tester, + {SentryScreenshotQuality quality = SentryScreenshotQuality.full}) async { + final fixture = _Fixture(quality: quality); await pumpTestElement(tester); return fixture; } diff --git a/flutter/test/screenshot/sentry_screenshot_quality_test.dart b/flutter/test/screenshot/sentry_screenshot_quality_test.dart new file mode 100644 index 0000000000..a4663bdf0f --- /dev/null +++ b/flutter/test/screenshot/sentry_screenshot_quality_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +void main() async { + group('$SentryScreenshotQuality', () { + test('test quality: full', () { + final sut = SentryScreenshotQuality.full; + expect(sut.targetResolution(), isNull); + expect(sut.calculateHeight(2000, 4000), 4000); + expect(sut.calculateWidth(2000, 4000), 2000); + expect(sut.calculateHeight(4000, 2000), 2000); + expect(sut.calculateWidth(4000, 2000), 4000); + }); + + test('test quality: high', () { + final sut = SentryScreenshotQuality.high; + final res = sut.targetResolution()!; + expect(res, 1920); + expect(sut.calculateHeight(2000, 4000), res); + expect(sut.calculateWidth(2000, 4000), res / 2); + expect(sut.calculateHeight(4000, 2000), res / 2); + expect(sut.calculateWidth(4000, 2000), res); + }); + + test('test quality: medium', () { + final sut = SentryScreenshotQuality.medium; + final res = sut.targetResolution()!; + expect(res, 1280); + expect(sut.calculateHeight(2000, 4000), res); + expect(sut.calculateWidth(2000, 4000), res / 2); + expect(sut.calculateHeight(4000, 2000), res / 2); + expect(sut.calculateWidth(4000, 2000), res); + }); + + test('test quality: low', () { + final sut = SentryScreenshotQuality.low; + final res = sut.targetResolution()!; + expect(res, 854); + expect(sut.calculateHeight(2000, 4000), res); + expect(sut.calculateWidth(2000, 4000), res / 2); + expect(sut.calculateHeight(4000, 2000), res / 2); + expect(sut.calculateWidth(4000, 2000), res); + }); + }); +} From fdc7d6d0cc06a244314a57c72dbf94a4ddc01c58 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Wed, 16 Oct 2024 16:06:49 +0200 Subject: [PATCH 03/13] removed unused imports --- .../screenshot_event_processor.dart | 81 +------------------ .../lib/src/replay/scheduled_recorder.dart | 3 +- flutter/lib/src/sentry_replay_options.dart | 8 -- 3 files changed, 4 insertions(+), 88 deletions(-) diff --git a/flutter/lib/src/event_processor/screenshot_event_processor.dart b/flutter/lib/src/event_processor/screenshot_event_processor.dart index 2f72d7f743..b53d614e6f 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'dart:typed_data'; import 'dart:ui'; @@ -8,7 +7,6 @@ import '../screenshot/recorder.dart'; import '../screenshot/recorder_config.dart'; import '../screenshot/sentry_screenshot_widget.dart'; import '../sentry_flutter_options.dart'; -import 'package:flutter/rendering.dart'; import '../renderer/renderer.dart'; import 'package:flutter/widgets.dart' as widget; @@ -21,8 +19,6 @@ class ScreenshotEventProcessor implements EventProcessor { bool get _hasSentryScreenshotWidget => sentryScreenshotWidgetGlobalKey.currentContext != null; - Uint8List? _screenshotCache; - @override Future apply(SentryEvent event, Hint hint) async { if (event is SentryTransaction) { @@ -84,6 +80,8 @@ class ScreenshotEventProcessor implements EventProcessor { ScreenshotRecorderConfig(quality: _options.screenshotQuality), _options); + Uint8List? _screenshotCache; + await recorder.capture((Image image) async { _screenshotCache = await _convertImageToUint8List(image); }); @@ -91,72 +89,10 @@ class ScreenshotEventProcessor implements EventProcessor { if (_screenshotCache != null) { hint.screenshot = SentryAttachment.fromScreenshotData(_screenshotCache!); } - _screenshotCache = null; + return event; } - // Future _createScreenshot() async { - // try { - // final renderObject = - // sentryScreenshotWidgetGlobalKey.currentContext?.findRenderObject(); - // if (renderObject is RenderRepaintBoundary) { - // // ignore: deprecated_member_use - // final pixelRatio = window.devicePixelRatio; - // var imageResult = _getImage(renderObject, pixelRatio); - // Image image; - // if (imageResult is Future) { - // image = await imageResult; - // } else { - // image = imageResult; - // } - // // At the time of writing there's no other image format available which - // // Sentry understands. - // - // if (image.width == 0 || image.height == 0) { - // _options.logger(SentryLevel.debug, - // 'View\'s width and height is zeroed, not taking screenshot.'); - // return null; - // } - // - // final targetResolution = _options.screenshotQuality.targetResolution(); - // if (targetResolution != null) { - // var ratioWidth = targetResolution / image.width; - // var ratioHeight = targetResolution / image.height; - // var ratio = min(ratioWidth, ratioHeight); - // if (ratio > 0.0 && ratio < 1.0) { - // imageResult = _getImage(renderObject, ratio * pixelRatio); - // if (imageResult is Future) { - // image = await imageResult; - // } else { - // image = imageResult; - // } - // } - // } - // final byteData = await image.toByteData(format: ImageByteFormat.png); - // - // final bytes = byteData?.buffer.asUint8List(); - // if (bytes?.isNotEmpty == true) { - // return bytes; - // } else { - // _options.logger(SentryLevel.debug, - // 'Screenshot is 0 bytes, not attaching the image.'); - // return null; - // } - // } - // } catch (exception, stackTrace) { - // _options.logger( - // SentryLevel.error, - // 'Taking screenshot failed.', - // exception: exception, - // stackTrace: stackTrace, - // ); - // if (_options.automatedTestMode) { - // rethrow; - // } - // } - // return null; - // } - Future _convertImageToUint8List(Image image) async { final byteData = await image.toByteData(format: ImageByteFormat.png); @@ -169,15 +105,4 @@ class ScreenshotEventProcessor implements EventProcessor { return null; } } - // - // FutureOr _getImage( - // RenderRepaintBoundary repaintBoundary, double pixelRatio) { - // // This one is a hack to use https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary/toImage.html on versions older than 3.7 and https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary/toImageSync.html on versions equal or newer than 3.7 - // try { - // return (repaintBoundary as dynamic).toImageSync(pixelRatio: pixelRatio) - // as Image; - // } on NoSuchMethodError catch (_) { - // return repaintBoundary.toImage(pixelRatio: pixelRatio); - // } - // } } diff --git a/flutter/lib/src/replay/scheduled_recorder.dart b/flutter/lib/src/replay/scheduled_recorder.dart index 5c040fc96e..a8a29a276f 100644 --- a/flutter/lib/src/replay/scheduled_recorder.dart +++ b/flutter/lib/src/replay/scheduled_recorder.dart @@ -2,11 +2,10 @@ import 'dart:async'; import 'dart:ui'; import 'package:meta/meta.dart'; -import 'package:sentry_flutter/src/replay/scheduled_recorder_config.dart'; +import 'scheduled_recorder_config.dart'; import '../../sentry_flutter.dart'; import '../screenshot/recorder.dart'; -import '../screenshot/recorder_config.dart'; import 'scheduler.dart'; @internal diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart index 5b49a9cfc3..4cf6d664ca 100644 --- a/flutter/lib/src/sentry_replay_options.dart +++ b/flutter/lib/src/sentry_replay_options.dart @@ -1,11 +1,5 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import 'screenshot/masking_config.dart'; -import 'screenshot/widget_filter.dart'; -import 'screenshot/sentry_mask_widget.dart'; -import 'screenshot/sentry_unmask_widget.dart'; import 'sentry_screenshot_options.dart'; /// Configuration of the experimental replay feature. @@ -41,8 +35,6 @@ class SentryReplayOptions extends SentryScreenshotOptions { bool get redactAllImages => maskAllImages; set redactAllImages(bool value) => maskAllImages = value; - final _userMaskingRules = []; - @internal bool get isEnabled => ((sessionSampleRate ?? 0) > 0) || ((onErrorSampleRate ?? 0) > 0); From 981039c3839b662cc22403bca88c8258f75a7669 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Wed, 16 Oct 2024 16:11:40 +0200 Subject: [PATCH 04/13] remove unused test --- flutter/test/replay/recorder_test.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/flutter/test/replay/recorder_test.dart b/flutter/test/replay/recorder_test.dart index fd9987cff6..b01fd2872c 100644 --- a/flutter/test/replay/recorder_test.dart +++ b/flutter/test/replay/recorder_test.dart @@ -16,11 +16,6 @@ import 'test_widget.dart'; void main() async { TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('captures images', (tester) async { - final fixture = await _Fixture.create(tester); - expect(fixture.capture(), completion('800x600')); - }); - testWidgets('captures full resolution images - portrait', (tester) async { await tester.binding.setSurfaceSize(Size(2000, 4000)); final fixture = await _Fixture.create(tester); From 4671c53e3a929890af6f1db6b1a102a82754735a Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Wed, 16 Oct 2024 16:51:15 +0200 Subject: [PATCH 05/13] add changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f93e41b2..2e34c42ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Switching from traditional screenshot to view hierarchy for screenshots which allows redacting ([#2361](https://github.com/getsentry/sentry-dart/pull/2361)) + ## 8.10.0-beta.2 ### Fixes From f7221910ce3921286c53e3eb09a79af67ef01833 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Tue, 22 Oct 2024 14:55:42 +0200 Subject: [PATCH 06/13] rename variable --- .../src/event_processor/screenshot_event_processor.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flutter/lib/src/event_processor/screenshot_event_processor.dart b/flutter/lib/src/event_processor/screenshot_event_processor.dart index b53d614e6f..8b4140f75d 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -80,14 +80,14 @@ class ScreenshotEventProcessor implements EventProcessor { ScreenshotRecorderConfig(quality: _options.screenshotQuality), _options); - Uint8List? _screenshotCache; + Uint8List? _screenshotData; await recorder.capture((Image image) async { - _screenshotCache = await _convertImageToUint8List(image); + _screenshotData = await _convertImageToUint8List(image); }); - if (_screenshotCache != null) { - hint.screenshot = SentryAttachment.fromScreenshotData(_screenshotCache!); + if (_screenshotData != null) { + hint.screenshot = SentryAttachment.fromScreenshotData(_screenshotData!); } return event; From 22f22b04b0a307204d8471c7140dd9d6ace78fd6 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Tue, 22 Oct 2024 14:55:51 +0200 Subject: [PATCH 07/13] add internal --- flutter/lib/src/screenshot/sentry_screenshot_quality.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flutter/lib/src/screenshot/sentry_screenshot_quality.dart b/flutter/lib/src/screenshot/sentry_screenshot_quality.dart index 2b97791983..2110a1ec49 100644 --- a/flutter/lib/src/screenshot/sentry_screenshot_quality.dart +++ b/flutter/lib/src/screenshot/sentry_screenshot_quality.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + /// The quality of the attached screenshot enum SentryScreenshotQuality { full, @@ -18,6 +20,7 @@ enum SentryScreenshotQuality { } } + @internal int calculateHeight(int width, int height) { if (this == SentryScreenshotQuality.full) { return height; @@ -31,6 +34,7 @@ enum SentryScreenshotQuality { } } + @internal int calculateWidth(int width, int height) { if (this == SentryScreenshotQuality.full) { return width; From 9bb14ad7b14e8133db84cfa4ebc796ca39f38cff Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Thu, 24 Oct 2024 17:01:55 +0200 Subject: [PATCH 08/13] split into screenshot, screenreplay and redaction options --- flutter/lib/src/screenshot/recorder.dart | 28 ++-- flutter/lib/src/sentry_flutter.dart | 16 +++ flutter/lib/src/sentry_flutter_options.dart | 35 +++-- flutter/lib/src/sentry_redaction_options.dart | 111 +++++++++++++++ flutter/lib/src/sentry_replay_options.dart | 11 +- .../lib/src/sentry_screenshot_options.dart | 130 ++++-------------- flutter/test/replay/masking_config_test.dart | 27 ++-- .../test/replay/scheduled_recorder_test.dart | 1 + flutter/test/replay/widget_filter_test.dart | 7 +- 9 files changed, 206 insertions(+), 160 deletions(-) create mode 100644 flutter/lib/src/sentry_redaction_options.dart diff --git a/flutter/lib/src/screenshot/recorder.dart b/flutter/lib/src/screenshot/recorder.dart index e6e6dc5c6f..e8eb3f0038 100644 --- a/flutter/lib/src/screenshot/recorder.dart +++ b/flutter/lib/src/screenshot/recorder.dart @@ -3,9 +3,9 @@ import 'dart:ui'; import 'package:flutter/rendering.dart'; import 'package:meta/meta.dart'; +import '../sentry_redaction_options.dart'; import '../../sentry_flutter.dart'; -import '../replay/scheduled_recorder_config.dart'; import 'masking_config.dart'; import 'recorder_config.dart'; import 'widget_filter.dart'; @@ -23,12 +23,11 @@ class ScreenshotRecorder { bool warningLogged = false; ScreenshotRecorder(this.config, this.options) { - SentryMaskingConfig maskingConfig; - if (config is ScheduledScreenshotRecorderConfig) { - maskingConfig = options.experimental.replay.buildMaskingConfig(); - } else { - maskingConfig = options.experimental.screenshot.buildMaskingConfig(); - } + /// TODO: Rewrite when default redaction value are synced with SS & SR + final SentryMaskingConfig maskingConfig = + (options.experimental.sentryRedactingOptions ?? + SentryRedactingOptions()) + .buildMaskingConfig(); if (maskingConfig.length > 0) { _widgetFilter = WidgetFilter(maskingConfig, options.logger); @@ -91,16 +90,11 @@ class ScreenshotRecorder { try { Image finalImage; - if (config is ScheduledScreenshotRecorderConfig) { - finalImage = await picture.toImage((srcWidth * pixelRatio).round(), - (srcHeight * pixelRatio).round()); - } else { - final targetHeight = config.quality - .calculateHeight(srcWidth.toInt(), srcHeight.toInt()); - final targetWidth = config.quality - .calculateWidth(srcWidth.toInt(), srcHeight.toInt()); - finalImage = await picture.toImage(targetWidth, targetHeight); - } + final targetHeight = + config.quality.calculateHeight(srcWidth.toInt(), srcHeight.toInt()); + final targetWidth = + config.quality.calculateWidth(srcWidth.toInt(), srcHeight.toInt()); + finalImage = await picture.toImage(targetWidth, targetHeight); try { await callback(finalImage); } finally { diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index e63366c3f7..33b7cbf289 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -23,6 +23,7 @@ import 'native/native_scope_observer.dart'; import 'native/sentry_native_binding.dart'; import 'profiling.dart'; import 'renderer/renderer.dart'; +import 'sentry_redaction_options.dart'; import 'span_frame_metrics_collector.dart'; import 'version.dart'; import 'view_hierarchy/view_hierarchy_integration.dart'; @@ -149,6 +150,8 @@ mixin SentryFlutter { SentryFlutterOptions options, bool isOnErrorSupported, ) { + _setRedactionOptions(options); + final integrations = []; final platformChecker = options.platformChecker; @@ -243,6 +246,19 @@ mixin SentryFlutter { options.sdk = sdk; } + static void _setRedactionOptions(SentryFlutterOptions options) { + if (options.experimental.sentryRedactingOptions != null) { + return; + } else if (options.screenshot.attachScreenshot == true && + !options.experimental.replay.isEnabled) { + options.experimental.sentryRedactingOptions = SentryRedactingOptions() + ..maskAllText = false + ..maskAllImages = false; + } else { + options.experimental.sentryRedactingOptions = SentryRedactingOptions(); + } + } + /// Reports the time it took for the screen to be fully displayed. /// This requires the [SentryFlutterOptions.enableTimeToFullDisplayTracing] option to be set to `true`. static Future reportFullyDisplayed() async { diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 073668e00b..e9c9f034ce 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:flutter/services.dart'; @@ -11,9 +9,9 @@ import 'binding_wrapper.dart'; import 'navigation/time_to_display_tracker.dart'; import 'renderer/renderer.dart'; import 'screenshot/sentry_screenshot_quality.dart'; -import 'event_processor/screenshot_event_processor.dart'; import 'screenshot/sentry_screenshot_widget.dart'; import 'sentry_flutter.dart'; +import 'sentry_redaction_options.dart'; import 'sentry_replay_options.dart'; import 'sentry_screenshot_options.dart'; import 'user_interaction/sentry_user_interaction_widget.dart'; @@ -188,18 +186,32 @@ class SentryFlutterOptions extends SentryOptions { /// Example: /// runApp(SentryScreenshotWidget(child: App())); /// The [SentryScreenshotWidget] has to be the root widget of the app. - bool attachScreenshot = false; + @Deprecated('Use `screenshot.attachScreenshot` instead') + bool get attachScreenshot => screenshot.attachScreenshot; + set attachScreenshot(bool value) => screenshot.attachScreenshot = value; /// The quality of the attached screenshot - SentryScreenshotQuality screenshotQuality = SentryScreenshotQuality.high; + @Deprecated('Use `screenshot.screenshotQuality` instead') + SentryScreenshotQuality get screenshotQuality => screenshot.screenshotQuality; + set screenshotQuality(SentryScreenshotQuality value) => + screenshot.screenshotQuality = value; /// Only attach a screenshot when the app is resumed. - bool attachScreenshotOnlyWhenResumed = false; + @Deprecated('Use `screenshot.attachScreenshotOnlyWhenResumed` instead') + bool get attachScreenshotOnlyWhenResumed => + screenshot.attachScreenshotOnlyWhenResumed; + set attachScreenshotOnlyWhenResumed(bool value) => + screenshot.attachScreenshotOnlyWhenResumed = value; /// Sets a callback which is executed before capturing screenshots. Only /// relevant if `attachScreenshot` is set to true. When false is returned /// from the function, no screenshot will be attached. - BeforeScreenshotCallback? beforeScreenshot; + @Deprecated('Use `screenshot.beforeScreenshot` instead') + BeforeScreenshotCallback? get beforeScreenshot => screenshot.beforeScreenshot; + set beforeScreenshot(BeforeScreenshotCallback? value) => + screenshot.beforeScreenshot = value; + + final screenshot = SentryScreenshotOptions(); /// Enable or disable automatic breadcrumbs for User interactions Using [Listener] /// @@ -381,12 +393,5 @@ class SentryFlutterOptions extends SentryOptions { class _SentryFlutterExperimentalOptions { /// Replay recording configuration. final replay = SentryReplayOptions(); - final screenshot = SentryScreenshotOptions(); + SentryRedactingOptions? sentryRedactingOptions; } - -/// Callback being executed in [ScreenshotEventProcessor], deciding if a -/// screenshot should be recorded and attached. -typedef BeforeScreenshotCallback = FutureOr Function( - SentryEvent event, { - Hint? hint, -}); diff --git a/flutter/lib/src/sentry_redaction_options.dart b/flutter/lib/src/sentry_redaction_options.dart new file mode 100644 index 0000000000..0438cf3d85 --- /dev/null +++ b/flutter/lib/src/sentry_redaction_options.dart @@ -0,0 +1,111 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import '../sentry_flutter.dart'; +import 'screenshot/masking_config.dart'; +import 'screenshot/widget_filter.dart'; + +/// Configuration of the experimental screenshot feature. +class SentryRedactingOptions { + /// Mask all text content. Draws a rectangle of text bounds with text color + /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. + /// Default is enabled. + @experimental + var maskAllText = true; + + /// Mask content of all images. Draws a rectangle of image bounds with image's + /// dominant color on top. Currently, only [Image] widgets are redacted. + /// Default is enabled (except for asset images, see [maskAssetImages]). + @experimental + var maskAllImages = true; + + /// Redact asset images coming from the root asset bundle. + @experimental + var maskAssetImages = false; + + final _userMaskingRules = []; + + @internal + SentryMaskingConfig buildMaskingConfig() { + // First, we collect rules defined by the user (so they're applied first). + final rules = _userMaskingRules.toList(); + + // Then, we apply rules for [SentryMask] and [SentryUnmask]. + rules.add(const SentryMaskingConstantRule( + SentryMaskingDecision.mask)); + rules.add(const SentryMaskingConstantRule( + SentryMaskingDecision.unmask)); + + // Then, we apply apply rules based on the configuration. + if (maskAllImages) { + if (maskAssetImages) { + rules.add( + const SentryMaskingConstantRule(SentryMaskingDecision.mask)); + } else { + rules + .add(const SentryMaskingCustomRule(_maskImagesExceptAssets)); + } + } else { + assert(!maskAssetImages, + "maskAssetImages can't be true if maskAllImages is false"); + } + if (maskAllText) { + rules.add( + const SentryMaskingConstantRule(SentryMaskingDecision.mask)); + rules.add(const SentryMaskingConstantRule( + SentryMaskingDecision.mask)); + } + return SentryMaskingConfig(rules); + } + + /// Mask given widget type [T] (or subclasses of [T]) in the replay. + /// Note: masking rules are called in the order they're added so if a previous + /// rule already makes a decision, this rule won't be called. + @experimental + void mask() { + assert(T != SentryMask); + assert(T != SentryUnmask); + _userMaskingRules + .add(SentryMaskingConstantRule(SentryMaskingDecision.mask)); + } + + /// Unmask given widget type [T] (or subclasses of [T]) in the replay. This is + /// useful to explicitly show certain widgets that would otherwise be masked + /// by other rules, for example default [maskAllText] or [maskAllImages]. + /// The [SentryMaskingDecision.unmask] will apply to the widget and its children, + /// so no other rules will be checked for the children. + /// Note: masking rules are called in the order they're added so if a previous + /// rule already makes a decision, this rule won't be called. + @experimental + void unmask() { + assert(T != SentryMask); + assert(T != SentryUnmask); + _userMaskingRules + .add(SentryMaskingConstantRule(SentryMaskingDecision.unmask)); + } + + /// Provide a custom callback to decide whether to mask the widget of class + /// [T] (or subclasses of [T]). + /// Note: masking rules are called in the order they're added so if a previous + /// rule already makes a decision, this rule won't be called. + @experimental + void maskCallback( + SentryMaskingDecision Function(Element, T) shouldMask) { + assert(T != SentryMask); + assert(T != SentryUnmask); + _userMaskingRules.add(SentryMaskingCustomRule(shouldMask)); + } +} + +SentryMaskingDecision _maskImagesExceptAssets(Element element, Widget widget) { + if (widget is Image) { + final image = widget.image; + if (image is AssetBundleImageProvider) { + if (WidgetFilter.isBuiltInAssetImage(image, rootBundle)) { + return SentryMaskingDecision.continueProcessing; + } + } + } + return SentryMaskingDecision.mask; +} diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart index 4cf6d664ca..2631f78554 100644 --- a/flutter/lib/src/sentry_replay_options.dart +++ b/flutter/lib/src/sentry_replay_options.dart @@ -1,10 +1,8 @@ import 'package:meta/meta.dart'; -import 'sentry_screenshot_options.dart'; - /// Configuration of the experimental replay feature. @experimental -class SentryReplayOptions extends SentryScreenshotOptions { +class SentryReplayOptions { double? _sessionSampleRate; /// A percentage of sessions in which a replay will be created. @@ -27,13 +25,6 @@ class SentryReplayOptions extends SentryScreenshotOptions { _onErrorSampleRate = value; } - @Deprecated('Use maskAllText instead') - bool get redactAllText => maskAllText; - set redactAllText(bool value) => maskAllText = value; - - @Deprecated('Use maskAllImages instead') - bool get redactAllImages => maskAllImages; - set redactAllImages(bool value) => maskAllImages = value; @internal bool get isEnabled => diff --git a/flutter/lib/src/sentry_screenshot_options.dart b/flutter/lib/src/sentry_screenshot_options.dart index c42860be06..30eaf7122b 100644 --- a/flutter/lib/src/sentry_screenshot_options.dart +++ b/flutter/lib/src/sentry_screenshot_options.dart @@ -1,107 +1,33 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; +import 'dart:async'; -import 'screenshot/masking_config.dart'; -import 'screenshot/widget_filter.dart'; -import 'screenshot/sentry_mask_widget.dart'; -import 'screenshot/sentry_unmask_widget.dart'; -/// Configuration of the experimental screenshot feature. -@experimental -class SentryScreenshotOptions { - /// Mask all text content. Draws a rectangle of text bounds with text color - /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. - /// Default is enabled. - var maskAllText = true; - - /// Mask content of all images. Draws a rectangle of image bounds with image's - /// dominant color on top. Currently, only [Image] widgets are redacted. - /// Default is enabled (except for asset images, see [maskAssetImages]). - var maskAllImages = true; - - /// Redact asset images coming from the root asset bundle. - var maskAssetImages = false; - - final _userMaskingRules = []; - - @internal - SentryMaskingConfig buildMaskingConfig() { - // First, we collect rules defined by the user (so they're applied first). - final rules = _userMaskingRules.toList(); - - // Then, we apply rules for [SentryMask] and [SentryUnmask]. - rules.add(const SentryMaskingConstantRule( - SentryMaskingDecision.mask)); - rules.add(const SentryMaskingConstantRule( - SentryMaskingDecision.unmask)); +import '../sentry_flutter.dart'; - // Then, we apply apply rules based on the configuration. - if (maskAllImages) { - if (maskAssetImages) { - rules.add( - const SentryMaskingConstantRule(SentryMaskingDecision.mask)); - } else { - rules - .add(const SentryMaskingCustomRule(_maskImagesExceptAssets)); - } - } else { - assert(!maskAssetImages, - "maskAssetImages can't be true if maskAllImages is false"); - } - if (maskAllText) { - rules.add( - const SentryMaskingConstantRule(SentryMaskingDecision.mask)); - rules.add(const SentryMaskingConstantRule( - SentryMaskingDecision.mask)); - } - return SentryMaskingConfig(rules); - } - - /// Mask given widget type [T] (or subclasses of [T]) in the replay. - /// Note: masking rules are called in the order they're added so if a previous - /// rule already makes a decision, this rule won't be called. - void mask() { - assert(T != SentryMask); - assert(T != SentryUnmask); - _userMaskingRules - .add(SentryMaskingConstantRule(SentryMaskingDecision.mask)); - } - - /// Unmask given widget type [T] (or subclasses of [T]) in the replay. This is - /// useful to explicitly show certain widgets that would otherwise be masked - /// by other rules, for example default [maskAllText] or [maskAllImages]. - /// The [SentryMaskingDecision.unmask] will apply to the widget and its children, - /// so no other rules will be checked for the children. - /// Note: masking rules are called in the order they're added so if a previous - /// rule already makes a decision, this rule won't be called. - void unmask() { - assert(T != SentryMask); - assert(T != SentryUnmask); - _userMaskingRules - .add(SentryMaskingConstantRule(SentryMaskingDecision.unmask)); - } - - /// Provide a custom callback to decide whether to mask the widget of class - /// [T] (or subclasses of [T]). - /// Note: masking rules are called in the order they're added so if a previous - /// rule already makes a decision, this rule won't be called. - void maskCallback( - SentryMaskingDecision Function(Element, T) shouldMask) { - assert(T != SentryMask); - assert(T != SentryUnmask); - _userMaskingRules.add(SentryMaskingCustomRule(shouldMask)); - } +/// Configuration of the screenshot feature. +class SentryScreenshotOptions { + /// Automatically attaches a screenshot when capturing an error or exception. + /// + /// Requires adding the [SentryScreenshotWidget] to the widget tree. + /// Example: + /// runApp(SentryScreenshotWidget(child: App())); + /// The [SentryScreenshotWidget] has to be the root widget of the app. + bool attachScreenshot = false; + + /// Sets a callback which is executed before capturing screenshots. Only + /// relevant if `attachScreenshot` is set to true. When false is returned + /// from the function, no screenshot will be attached. + BeforeScreenshotCallback? beforeScreenshot; + + /// Only attach a screenshot when the app is resumed. + bool attachScreenshotOnlyWhenResumed = false; + + /// The quality of the attached screenshot + SentryScreenshotQuality screenshotQuality = SentryScreenshotQuality.high; } -SentryMaskingDecision _maskImagesExceptAssets(Element element, Widget widget) { - if (widget is Image) { - final image = widget.image; - if (image is AssetBundleImageProvider) { - if (WidgetFilter.isBuiltInAssetImage(image, rootBundle)) { - return SentryMaskingDecision.continueProcessing; - } - } - } - return SentryMaskingDecision.mask; -} +/// Callback being executed in [ScreenshotEventProcessor], deciding if a +/// screenshot should be recorded and attached. +typedef BeforeScreenshotCallback = FutureOr Function( + SentryEvent event, { + Hint? hint, +}); diff --git a/flutter/test/replay/masking_config_test.dart b/flutter/test/replay/masking_config_test.dart index 4323521541..1f1a02120e 100644 --- a/flutter/test/replay/masking_config_test.dart +++ b/flutter/test/replay/masking_config_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/screenshot/masking_config.dart'; +import 'package:sentry_flutter/src/sentry_redaction_options.dart'; import 'test_widget.dart'; @@ -114,7 +115,7 @@ void main() async { }); group('$SentryReplayOptions.buildMaskingConfig()', () { - List rulesAsStrings(SentryReplayOptions options) { + List rulesAsStrings(SentryRedactingOptions options) { final config = options.buildMaskingConfig(); return config.rules .map((rule) => rule.toString()) @@ -131,7 +132,7 @@ void main() async { } test('defaults', () { - final sut = SentryReplayOptions(); + final sut = SentryRedactingOptions(); expect(rulesAsStrings(sut), [ ...alwaysEnabledRules, '$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)', @@ -141,7 +142,7 @@ void main() async { }); test('maskAllImages=true & maskAssetImages=true', () { - final sut = SentryReplayOptions() + final sut = SentryRedactingOptions() ..maskAllText = false ..maskAllImages = true ..maskAssetImages = true; @@ -152,7 +153,7 @@ void main() async { }); test('maskAllImages=true & maskAssetImages=false', () { - final sut = SentryReplayOptions() + final sut = SentryRedactingOptions() ..maskAllText = false ..maskAllImages = true ..maskAssetImages = false; @@ -163,7 +164,7 @@ void main() async { }); test('maskAllText=true', () { - final sut = SentryReplayOptions() + final sut = SentryRedactingOptions() ..maskAllText = true ..maskAllImages = false ..maskAssetImages = false; @@ -175,7 +176,7 @@ void main() async { }); test('maskAllText=false', () { - final sut = SentryReplayOptions() + final sut = SentryRedactingOptions() ..maskAllText = false ..maskAllImages = false ..maskAssetImages = false; @@ -190,19 +191,19 @@ void main() async { '$SentryMaskingConstantRule<$EditableText>(mask)' ]; test('mask() takes precedence', () { - final sut = SentryReplayOptions(); + final sut = SentryRedactingOptions(); sut.mask(); expect(rulesAsStrings(sut), ['$SentryMaskingConstantRule<$Image>(mask)', ...defaultRules]); }); test('unmask() takes precedence', () { - final sut = SentryReplayOptions(); + final sut = SentryRedactingOptions(); sut.unmask(); expect(rulesAsStrings(sut), ['$SentryMaskingConstantRule<$Image>(unmask)', ...defaultRules]); }); test('are ordered in the call order', () { - var sut = SentryReplayOptions(); + var sut = SentryRedactingOptions(); sut.mask(); sut.unmask(); expect(rulesAsStrings(sut), [ @@ -210,7 +211,7 @@ void main() async { '$SentryMaskingConstantRule<$Image>(unmask)', ...defaultRules ]); - sut = SentryReplayOptions(); + sut = SentryRedactingOptions(); sut.unmask(); sut.mask(); expect(rulesAsStrings(sut), [ @@ -218,7 +219,7 @@ void main() async { '$SentryMaskingConstantRule<$Image>(mask)', ...defaultRules ]); - sut = SentryReplayOptions(); + sut = SentryRedactingOptions(); sut.unmask(); sut.maskCallback( (Element element, Image widget) => SentryMaskingDecision.mask); @@ -231,7 +232,7 @@ void main() async { ]); }); test('maskCallback() takes precedence', () { - final sut = SentryReplayOptions(); + final sut = SentryRedactingOptions(); sut.maskCallback( (Element element, Image widget) => SentryMaskingDecision.mask); expect(rulesAsStrings(sut), [ @@ -240,7 +241,7 @@ void main() async { ]); }); test('User cannot add $SentryMask and $SentryUnmask rules', () { - final sut = SentryReplayOptions(); + final sut = SentryRedactingOptions(); expect(sut.mask, throwsA(isA())); expect(sut.mask, throwsA(isA())); expect(sut.unmask, throwsA(isA())); diff --git a/flutter/test/replay/scheduled_recorder_test.dart b/flutter/test/replay/scheduled_recorder_test.dart index 59cf991fc6..b6cb228f57 100644 --- a/flutter/test/replay/scheduled_recorder_test.dart +++ b/flutter/test/replay/scheduled_recorder_test.dart @@ -16,6 +16,7 @@ void main() async { TestWidgetsFlutterBinding.ensureInitialized(); testWidgets('captures images', (tester) async { + await tester.binding.setSurfaceSize(Size(1000, 750)); final fixture = await _Fixture.create(tester); expect(fixture.capturedImages, isEmpty); await fixture.nextFrame(); diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart index 3d72c5a3e2..56ca79d63b 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/replay/widget_filter_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/screenshot/widget_filter.dart'; +import 'package:sentry_flutter/src/sentry_redaction_options.dart'; import 'test_widget.dart'; @@ -16,9 +17,9 @@ void main() async { final otherBundle = TestAssetBundle(); final createSut = ({bool redactImages = false, bool redactText = false}) { - final replayOptions = SentryReplayOptions(); - replayOptions.redactAllImages = redactImages; - replayOptions.redactAllText = redactText; + final replayOptions = SentryRedactingOptions(); + replayOptions.maskAllImages = redactImages; + replayOptions.maskAllText = redactText; return WidgetFilter(replayOptions.buildMaskingConfig(), (level, message, {exception, logger, stackTrace}) {}); }; From 0969b7b9f65e0bcfab250da21f33933cd07a53ba Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Thu, 24 Oct 2024 17:18:29 +0200 Subject: [PATCH 09/13] fix comments --- flutter/lib/src/sentry_redaction_options.dart | 2 +- flutter/lib/src/sentry_screenshot_options.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/flutter/lib/src/sentry_redaction_options.dart b/flutter/lib/src/sentry_redaction_options.dart index 0438cf3d85..cd33a0a144 100644 --- a/flutter/lib/src/sentry_redaction_options.dart +++ b/flutter/lib/src/sentry_redaction_options.dart @@ -6,7 +6,7 @@ import '../sentry_flutter.dart'; import 'screenshot/masking_config.dart'; import 'screenshot/widget_filter.dart'; -/// Configuration of the experimental screenshot feature. +/// Configuration of the experimental redaction feature. class SentryRedactingOptions { /// Mask all text content. Draws a rectangle of text bounds with text color /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. diff --git a/flutter/lib/src/sentry_screenshot_options.dart b/flutter/lib/src/sentry_screenshot_options.dart index 30eaf7122b..66aae00b11 100644 --- a/flutter/lib/src/sentry_screenshot_options.dart +++ b/flutter/lib/src/sentry_screenshot_options.dart @@ -1,6 +1,5 @@ import 'dart:async'; - import '../sentry_flutter.dart'; /// Configuration of the screenshot feature. From c2daf00485c7d8e934e952c2695bad4a7d06b297 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Fri, 25 Oct 2024 11:25:10 +0200 Subject: [PATCH 10/13] export redaction options and remove unused dependencies --- flutter/lib/sentry_flutter.dart | 1 + flutter/lib/src/screenshot/recorder.dart | 1 - flutter/lib/src/sentry_flutter.dart | 5 ++--- flutter/lib/src/sentry_replay_options.dart | 1 - flutter/test/replay/masking_config_test.dart | 1 - flutter/test/replay/widget_filter_test.dart | 1 - 6 files changed, 3 insertions(+), 7 deletions(-) diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index e902455ce6..20080c0c91 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -9,6 +9,7 @@ export 'src/navigation/sentry_navigator_observer.dart'; export 'src/sentry_flutter.dart'; export 'src/sentry_flutter_options.dart'; export 'src/sentry_replay_options.dart'; +export 'src/sentry_redaction_options.dart'; export 'src/flutter_sentry_attachment.dart'; export 'src/sentry_asset_bundle.dart' show SentryAssetBundle; export 'src/integrations/on_error_integration.dart'; diff --git a/flutter/lib/src/screenshot/recorder.dart b/flutter/lib/src/screenshot/recorder.dart index e8eb3f0038..5a3fdbac7e 100644 --- a/flutter/lib/src/screenshot/recorder.dart +++ b/flutter/lib/src/screenshot/recorder.dart @@ -3,7 +3,6 @@ import 'dart:ui'; import 'package:flutter/rendering.dart'; import 'package:meta/meta.dart'; -import '../sentry_redaction_options.dart'; import '../../sentry_flutter.dart'; import 'masking_config.dart'; diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 33b7cbf289..4f796310a6 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -23,7 +23,6 @@ import 'native/native_scope_observer.dart'; import 'native/sentry_native_binding.dart'; import 'profiling.dart'; import 'renderer/renderer.dart'; -import 'sentry_redaction_options.dart'; import 'span_frame_metrics_collector.dart'; import 'version.dart'; import 'view_hierarchy/view_hierarchy_integration.dart'; @@ -100,6 +99,8 @@ mixin SentryFlutter { // ignore: invalid_use_of_internal_member runZonedGuardedOnError: runZonedGuardedOnError, ); + // TODO: Remove when we synced SS and SR configurations and have a single default configuration + _setRedactionOptions(options); if (_native != null) { // ignore: invalid_use_of_internal_member @@ -150,8 +151,6 @@ mixin SentryFlutter { SentryFlutterOptions options, bool isOnErrorSupported, ) { - _setRedactionOptions(options); - final integrations = []; final platformChecker = options.platformChecker; diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart index 2631f78554..72d758c46d 100644 --- a/flutter/lib/src/sentry_replay_options.dart +++ b/flutter/lib/src/sentry_replay_options.dart @@ -25,7 +25,6 @@ class SentryReplayOptions { _onErrorSampleRate = value; } - @internal bool get isEnabled => ((sessionSampleRate ?? 0) > 0) || ((onErrorSampleRate ?? 0) > 0); diff --git a/flutter/test/replay/masking_config_test.dart b/flutter/test/replay/masking_config_test.dart index 1f1a02120e..ed840cadfe 100644 --- a/flutter/test/replay/masking_config_test.dart +++ b/flutter/test/replay/masking_config_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/screenshot/masking_config.dart'; -import 'package:sentry_flutter/src/sentry_redaction_options.dart'; import 'test_widget.dart'; diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart index 56ca79d63b..00a1683a4a 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/replay/widget_filter_test.dart @@ -3,7 +3,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/screenshot/widget_filter.dart'; -import 'package:sentry_flutter/src/sentry_redaction_options.dart'; import 'test_widget.dart'; From 3606202d1de69ea75ce13f3143918ef57928dd80 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Fri, 25 Oct 2024 11:33:53 +0200 Subject: [PATCH 11/13] renaming to SentryPrivacyOptions --- flutter/lib/sentry_flutter.dart | 2 +- flutter/lib/src/screenshot/recorder.dart | 3 +-- flutter/lib/src/sentry_flutter.dart | 6 ++--- flutter/lib/src/sentry_flutter_options.dart | 6 +++-- ...tions.dart => sentry_privacy_options.dart} | 4 +-- flutter/test/replay/masking_config_test.dart | 26 +++++++++---------- flutter/test/replay/widget_filter_test.dart | 2 +- 7 files changed, 25 insertions(+), 24 deletions(-) rename flutter/lib/src/{sentry_redaction_options.dart => sentry_privacy_options.dart} (97%) diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index 20080c0c91..8c7ca8785a 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -9,7 +9,7 @@ export 'src/navigation/sentry_navigator_observer.dart'; export 'src/sentry_flutter.dart'; export 'src/sentry_flutter_options.dart'; export 'src/sentry_replay_options.dart'; -export 'src/sentry_redaction_options.dart'; +export 'src/sentry_privacy_options.dart'; export 'src/flutter_sentry_attachment.dart'; export 'src/sentry_asset_bundle.dart' show SentryAssetBundle; export 'src/integrations/on_error_integration.dart'; diff --git a/flutter/lib/src/screenshot/recorder.dart b/flutter/lib/src/screenshot/recorder.dart index 5a3fdbac7e..93e8313b1f 100644 --- a/flutter/lib/src/screenshot/recorder.dart +++ b/flutter/lib/src/screenshot/recorder.dart @@ -24,8 +24,7 @@ class ScreenshotRecorder { ScreenshotRecorder(this.config, this.options) { /// TODO: Rewrite when default redaction value are synced with SS & SR final SentryMaskingConfig maskingConfig = - (options.experimental.sentryRedactingOptions ?? - SentryRedactingOptions()) + (options.experimental.privacy ?? SentryPrivacyOptions()) .buildMaskingConfig(); if (maskingConfig.length > 0) { diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 4f796310a6..6f58245b0b 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -246,15 +246,15 @@ mixin SentryFlutter { } static void _setRedactionOptions(SentryFlutterOptions options) { - if (options.experimental.sentryRedactingOptions != null) { + if (options.experimental.privacy != null) { return; } else if (options.screenshot.attachScreenshot == true && !options.experimental.replay.isEnabled) { - options.experimental.sentryRedactingOptions = SentryRedactingOptions() + options.experimental.privacy = SentryPrivacyOptions() ..maskAllText = false ..maskAllImages = false; } else { - options.experimental.sentryRedactingOptions = SentryRedactingOptions(); + options.experimental.privacy = SentryPrivacyOptions(); } } diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index e9c9f034ce..4e18de821f 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:meta/meta.dart' as meta; import 'package:sentry/sentry.dart'; import 'package:flutter/widgets.dart'; +import 'sentry_privacy_options.dart'; import 'binding_wrapper.dart'; import 'navigation/time_to_display_tracker.dart'; @@ -11,7 +12,6 @@ import 'renderer/renderer.dart'; import 'screenshot/sentry_screenshot_quality.dart'; import 'screenshot/sentry_screenshot_widget.dart'; import 'sentry_flutter.dart'; -import 'sentry_redaction_options.dart'; import 'sentry_replay_options.dart'; import 'sentry_screenshot_options.dart'; import 'user_interaction/sentry_user_interaction_widget.dart'; @@ -393,5 +393,7 @@ class SentryFlutterOptions extends SentryOptions { class _SentryFlutterExperimentalOptions { /// Replay recording configuration. final replay = SentryReplayOptions(); - SentryRedactingOptions? sentryRedactingOptions; + + /// Privacy configuration for masking sensitive data in the Screenshot and Session Replay. + SentryPrivacyOptions? privacy; } diff --git a/flutter/lib/src/sentry_redaction_options.dart b/flutter/lib/src/sentry_privacy_options.dart similarity index 97% rename from flutter/lib/src/sentry_redaction_options.dart rename to flutter/lib/src/sentry_privacy_options.dart index cd33a0a144..74fd99468f 100644 --- a/flutter/lib/src/sentry_redaction_options.dart +++ b/flutter/lib/src/sentry_privacy_options.dart @@ -6,8 +6,8 @@ import '../sentry_flutter.dart'; import 'screenshot/masking_config.dart'; import 'screenshot/widget_filter.dart'; -/// Configuration of the experimental redaction feature. -class SentryRedactingOptions { +/// Configuration of the experimental privacy feature. +class SentryPrivacyOptions { /// Mask all text content. Draws a rectangle of text bounds with text color /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. /// Default is enabled. diff --git a/flutter/test/replay/masking_config_test.dart b/flutter/test/replay/masking_config_test.dart index ed840cadfe..90333c5fe0 100644 --- a/flutter/test/replay/masking_config_test.dart +++ b/flutter/test/replay/masking_config_test.dart @@ -114,7 +114,7 @@ void main() async { }); group('$SentryReplayOptions.buildMaskingConfig()', () { - List rulesAsStrings(SentryRedactingOptions options) { + List rulesAsStrings(SentryPrivacyOptions options) { final config = options.buildMaskingConfig(); return config.rules .map((rule) => rule.toString()) @@ -131,7 +131,7 @@ void main() async { } test('defaults', () { - final sut = SentryRedactingOptions(); + final sut = SentryPrivacyOptions(); expect(rulesAsStrings(sut), [ ...alwaysEnabledRules, '$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)', @@ -141,7 +141,7 @@ void main() async { }); test('maskAllImages=true & maskAssetImages=true', () { - final sut = SentryRedactingOptions() + final sut = SentryPrivacyOptions() ..maskAllText = false ..maskAllImages = true ..maskAssetImages = true; @@ -152,7 +152,7 @@ void main() async { }); test('maskAllImages=true & maskAssetImages=false', () { - final sut = SentryRedactingOptions() + final sut = SentryPrivacyOptions() ..maskAllText = false ..maskAllImages = true ..maskAssetImages = false; @@ -163,7 +163,7 @@ void main() async { }); test('maskAllText=true', () { - final sut = SentryRedactingOptions() + final sut = SentryPrivacyOptions() ..maskAllText = true ..maskAllImages = false ..maskAssetImages = false; @@ -175,7 +175,7 @@ void main() async { }); test('maskAllText=false', () { - final sut = SentryRedactingOptions() + final sut = SentryPrivacyOptions() ..maskAllText = false ..maskAllImages = false ..maskAssetImages = false; @@ -190,19 +190,19 @@ void main() async { '$SentryMaskingConstantRule<$EditableText>(mask)' ]; test('mask() takes precedence', () { - final sut = SentryRedactingOptions(); + final sut = SentryPrivacyOptions(); sut.mask(); expect(rulesAsStrings(sut), ['$SentryMaskingConstantRule<$Image>(mask)', ...defaultRules]); }); test('unmask() takes precedence', () { - final sut = SentryRedactingOptions(); + final sut = SentryPrivacyOptions(); sut.unmask(); expect(rulesAsStrings(sut), ['$SentryMaskingConstantRule<$Image>(unmask)', ...defaultRules]); }); test('are ordered in the call order', () { - var sut = SentryRedactingOptions(); + var sut = SentryPrivacyOptions(); sut.mask(); sut.unmask(); expect(rulesAsStrings(sut), [ @@ -210,7 +210,7 @@ void main() async { '$SentryMaskingConstantRule<$Image>(unmask)', ...defaultRules ]); - sut = SentryRedactingOptions(); + sut = SentryPrivacyOptions(); sut.unmask(); sut.mask(); expect(rulesAsStrings(sut), [ @@ -218,7 +218,7 @@ void main() async { '$SentryMaskingConstantRule<$Image>(mask)', ...defaultRules ]); - sut = SentryRedactingOptions(); + sut = SentryPrivacyOptions(); sut.unmask(); sut.maskCallback( (Element element, Image widget) => SentryMaskingDecision.mask); @@ -231,7 +231,7 @@ void main() async { ]); }); test('maskCallback() takes precedence', () { - final sut = SentryRedactingOptions(); + final sut = SentryPrivacyOptions(); sut.maskCallback( (Element element, Image widget) => SentryMaskingDecision.mask); expect(rulesAsStrings(sut), [ @@ -240,7 +240,7 @@ void main() async { ]); }); test('User cannot add $SentryMask and $SentryUnmask rules', () { - final sut = SentryRedactingOptions(); + final sut = SentryPrivacyOptions(); expect(sut.mask, throwsA(isA())); expect(sut.mask, throwsA(isA())); expect(sut.unmask, throwsA(isA())); diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart index 00a1683a4a..a5bab11671 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/replay/widget_filter_test.dart @@ -16,7 +16,7 @@ void main() async { final otherBundle = TestAssetBundle(); final createSut = ({bool redactImages = false, bool redactText = false}) { - final replayOptions = SentryRedactingOptions(); + final replayOptions = SentryPrivacyOptions(); replayOptions.maskAllImages = redactImages; replayOptions.maskAllText = redactText; return WidgetFilter(replayOptions.buildMaskingConfig(), From 35ebb0bd63ef512afe408baf38f766a0cc800967 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Tue, 29 Oct 2024 09:57:00 +0100 Subject: [PATCH 12/13] add explanation for setRedactionOptions --- flutter/lib/src/sentry_flutter.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 6f58245b0b..79b0856da2 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -245,6 +245,14 @@ mixin SentryFlutter { options.sdk = sdk; } + /// Screen redaction was previously introduced with the SessionReplay feature. + /// Screen redaction is enabled by default for SessionReplay. + /// As we also to use this feature for Screenshot, which previously was not + /// capable of redacting the screenshot, we need to disable redaction for Screenshot by default + /// so we don`t break the existing behavior. + /// As we have only one central place to configure the redaction, + /// we need to set the redaction options to full fill the above default settings. + /// The plan is to unify this behaviour with the next major release. static void _setRedactionOptions(SentryFlutterOptions options) { if (options.experimental.privacy != null) { return; From 57a0823d3c7d252a1085cece3c53afa03d981c34 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Tue, 29 Oct 2024 10:30:11 +0100 Subject: [PATCH 13/13] fix deprecation warnings --- .../src/event_processor/screenshot_event_processor.dart | 7 ++++--- flutter/lib/src/integrations/screenshot_integration.dart | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/flutter/lib/src/event_processor/screenshot_event_processor.dart b/flutter/lib/src/event_processor/screenshot_event_processor.dart index 8b4140f75d..3139b1f8bf 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -30,7 +30,7 @@ class ScreenshotEventProcessor implements EventProcessor { _hasSentryScreenshotWidget) { return event; } - final beforeScreenshot = _options.beforeScreenshot; + final beforeScreenshot = _options.screenshot.beforeScreenshot; if (beforeScreenshot != null) { try { final result = beforeScreenshot(event, hint: hint); @@ -67,7 +67,7 @@ class ScreenshotEventProcessor implements EventProcessor { return event; } - if (_options.attachScreenshotOnlyWhenResumed && + if (_options.screenshot.attachScreenshotOnlyWhenResumed && widget.WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) { _options.logger(SentryLevel.debug, @@ -77,7 +77,8 @@ class ScreenshotEventProcessor implements EventProcessor { // ignore: deprecated_member_use var recorder = ScreenshotRecorder( - ScreenshotRecorderConfig(quality: _options.screenshotQuality), + ScreenshotRecorderConfig( + quality: _options.screenshot.screenshotQuality), _options); Uint8List? _screenshotData; diff --git a/flutter/lib/src/integrations/screenshot_integration.dart b/flutter/lib/src/integrations/screenshot_integration.dart index 10cf60228a..bafe4b696e 100644 --- a/flutter/lib/src/integrations/screenshot_integration.dart +++ b/flutter/lib/src/integrations/screenshot_integration.dart @@ -3,14 +3,14 @@ import '../event_processor/screenshot_event_processor.dart'; import '../sentry_flutter_options.dart'; /// Adds [ScreenshotEventProcessor] to options event processors if -/// [SentryFlutterOptions.attachScreenshot] is true +/// [SentryFlutterOptions.screenshot.attachScreenshot] is true class ScreenshotIntegration implements Integration { SentryFlutterOptions? _options; ScreenshotEventProcessor? _screenshotEventProcessor; @override void call(Hub hub, SentryFlutterOptions options) { - if (options.attachScreenshot) { + if (options.screenshot.attachScreenshot) { _options = options; final screenshotEventProcessor = ScreenshotEventProcessor(options); options.addEventProcessor(screenshotEventProcessor);