diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e86ff0f4..36388860e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Enhancements - Cache parsed DSN ([#2365](https://github.com/getsentry/sentry-dart/pull/2365)) +- 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 diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index c3e604e634..8c7ca8785a 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -9,10 +9,11 @@ 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_privacy_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..3139b1f8bf 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -1,12 +1,12 @@ import 'dart:async'; -import 'dart:math'; 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'; import '../renderer/renderer.dart'; import 'package:flutter/widgets.dart' as widget; @@ -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, @@ -75,83 +75,35 @@ class ScreenshotEventProcessor implements EventProcessor { return event; } - final bytes = await _createScreenshot(); - if (bytes != null) { - hint.screenshot = SentryAttachment.fromScreenshotData(bytes); - } - return event; - } + // ignore: deprecated_member_use + var recorder = ScreenshotRecorder( + ScreenshotRecorderConfig( + quality: _options.screenshot.screenshotQuality), + _options); - 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. + Uint8List? _screenshotData; - 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); + await recorder.capture((Image image) async { + _screenshotData = await _convertImageToUint8List(image); + }); - 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; - } + if (_screenshotData != null) { + hint.screenshot = SentryAttachment.fromScreenshotData(_screenshotData!); } - return null; + + return event; } - 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); + 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; } } } 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); 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..a8a29a276f 100644 --- a/flutter/lib/src/replay/scheduled_recorder.dart +++ b/flutter/lib/src/replay/scheduled_recorder.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'dart:ui'; import 'package:meta/meta.dart'; +import 'scheduled_recorder_config.dart'; import '../../sentry_flutter.dart'; -import 'recorder.dart'; -import 'recorder_config.dart'; +import '../screenshot/recorder.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 83% rename from flutter/lib/src/replay/recorder.dart rename to flutter/lib/src/screenshot/recorder.dart index f15b79a072..93e8313b1f 100644 --- a/flutter/lib/src/replay/recorder.dart +++ b/flutter/lib/src/screenshot/recorder.dart @@ -5,6 +5,7 @@ import 'package:flutter/rendering.dart'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; +import 'masking_config.dart'; import 'recorder_config.dart'; import 'widget_filter.dart'; @@ -21,7 +22,11 @@ class ScreenshotRecorder { bool warningLogged = false; ScreenshotRecorder(this.config, this.options) { - final maskingConfig = options.experimental.replay.buildMaskingConfig(); + /// TODO: Rewrite when default redaction value are synced with SS & SR + final SentryMaskingConfig maskingConfig = + (options.experimental.privacy ?? SentryPrivacyOptions()) + .buildMaskingConfig(); + if (maskingConfig.length > 0) { _widgetFilter = WidgetFilter(maskingConfig, options.logger); } @@ -82,12 +87,16 @@ class ScreenshotRecorder { final picture = recorder.endRecording(); try { - final finalImage = await picture.toImage( - (srcWidth * pixelRatio).round(), (srcHeight * pixelRatio).round()); + Image finalImage; + 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 { - 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 58% rename from flutter/lib/src/replay/recorder_config.dart rename to flutter/lib/src/screenshot/recorder_config.dart index 9649a33823..64a1d7d183 100644 --- a/flutter/lib/src/replay/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)); @@ -17,13 +24,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/screenshot/sentry_screenshot_quality.dart b/flutter/lib/src/screenshot/sentry_screenshot_quality.dart index d42e622966..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, @@ -17,4 +19,32 @@ enum SentryScreenshotQuality { return 854; } } + + @internal + 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(); + } + } + } + + @internal + 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/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.dart b/flutter/lib/src/sentry_flutter.dart index e63366c3f7..79b0856da2 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -99,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 @@ -243,6 +245,27 @@ 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; + } else if (options.screenshot.attachScreenshot == true && + !options.experimental.replay.isEnabled) { + options.experimental.privacy = SentryPrivacyOptions() + ..maskAllText = false + ..maskAllImages = false; + } else { + options.experimental.privacy = SentryPrivacyOptions(); + } + } + /// 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 e400aa3536..4e18de821f 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -1,20 +1,19 @@ -import 'dart:async'; - import 'package:file/file.dart'; import 'package:file/local.dart'; 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'; 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_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. @@ -187,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] /// @@ -380,11 +393,7 @@ class SentryFlutterOptions extends SentryOptions { class _SentryFlutterExperimentalOptions { /// Replay recording configuration. final replay = SentryReplayOptions(); -} -/// Callback being executed in [ScreenshotEventProcessor], deciding if a -/// screenshot should be recorded and attached. -typedef BeforeScreenshotCallback = FutureOr Function( - SentryEvent event, { - Hint? hint, -}); + /// Privacy configuration for masking sensitive data in the Screenshot and Session Replay. + SentryPrivacyOptions? privacy; +} diff --git a/flutter/lib/src/sentry_privacy_options.dart b/flutter/lib/src/sentry_privacy_options.dart new file mode 100644 index 0000000000..74fd99468f --- /dev/null +++ b/flutter/lib/src/sentry_privacy_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 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. + @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 a6e83fec4f..72d758c46d 100644 --- a/flutter/lib/src/sentry_replay_options.dart +++ b/flutter/lib/src/sentry_replay_options.dart @@ -1,12 +1,5 @@ -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/sentry_mask_widget.dart'; -import 'screenshot/sentry_unmask_widget.dart'; - /// Configuration of the experimental replay feature. @experimental class SentryReplayOptions { @@ -32,110 +25,7 @@ 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..66aae00b11 --- /dev/null +++ b/flutter/lib/src/sentry_screenshot_options.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import '../sentry_flutter.dart'; + +/// 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; +} + +/// 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 46e6a99261..90333c5fe0 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'; @@ -114,7 +114,7 @@ void main() async { }); group('$SentryReplayOptions.buildMaskingConfig()', () { - List rulesAsStrings(SentryReplayOptions 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 = SentryReplayOptions(); + 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 = SentryReplayOptions() + final sut = SentryPrivacyOptions() ..maskAllText = false ..maskAllImages = true ..maskAssetImages = true; @@ -152,7 +152,7 @@ void main() async { }); test('maskAllImages=true & maskAssetImages=false', () { - final sut = SentryReplayOptions() + final sut = SentryPrivacyOptions() ..maskAllText = false ..maskAllImages = true ..maskAssetImages = false; @@ -163,7 +163,7 @@ void main() async { }); test('maskAllText=true', () { - final sut = SentryReplayOptions() + final sut = SentryPrivacyOptions() ..maskAllText = true ..maskAllImages = false ..maskAssetImages = false; @@ -175,7 +175,7 @@ void main() async { }); test('maskAllText=false', () { - final sut = SentryReplayOptions() + 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 = SentryReplayOptions(); + final sut = SentryPrivacyOptions(); sut.mask(); expect(rulesAsStrings(sut), ['$SentryMaskingConstantRule<$Image>(mask)', ...defaultRules]); }); test('unmask() takes precedence', () { - final sut = SentryReplayOptions(); + final sut = SentryPrivacyOptions(); sut.unmask(); expect(rulesAsStrings(sut), ['$SentryMaskingConstantRule<$Image>(unmask)', ...defaultRules]); }); test('are ordered in the call order', () { - var sut = SentryReplayOptions(); + var sut = SentryPrivacyOptions(); sut.mask(); sut.unmask(); expect(rulesAsStrings(sut), [ @@ -210,7 +210,7 @@ void main() async { '$SentryMaskingConstantRule<$Image>(unmask)', ...defaultRules ]); - sut = SentryReplayOptions(); + sut = SentryPrivacyOptions(); sut.unmask(); sut.mask(); expect(rulesAsStrings(sut), [ @@ -218,7 +218,7 @@ void main() async { '$SentryMaskingConstantRule<$Image>(mask)', ...defaultRules ]); - sut = SentryReplayOptions(); + 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 = SentryReplayOptions(); + 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 = SentryReplayOptions(); + final sut = SentryPrivacyOptions(); expect(sut.mask, throwsA(isA())); expect(sut.mask, throwsA(isA())); expect(sut.unmask, throwsA(isA())); 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..b01fd2872c 100644 --- a/flutter/test/replay/recorder_test.dart +++ b/flutter/test/replay/recorder_test.dart @@ -6,8 +6,9 @@ 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/sentry_flutter.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'; @@ -15,24 +16,60 @@ import 'test_widget.dart'; void main() async { TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('captures images', (tester) async { + 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('800x600')); + 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/replay/scheduled_recorder_test.dart b/flutter/test/replay/scheduled_recorder_test.dart index 7ace54c18e..b6cb228f57 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'; @@ -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 ad76e9bfaa..a5bab11671 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'; @@ -16,9 +16,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 = SentryPrivacyOptions(); + replayOptions.maskAllImages = redactImages; + replayOptions.maskAllText = redactText; return WidgetFilter(replayOptions.buildMaskingConfig(), (level, message, {exception, logger, stackTrace}) {}); }; 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); + }); + }); +}