diff --git a/.github/file-filters.yml b/.github/file-filters.yml index f86f14edad..4da3356a47 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -5,4 +5,5 @@ high_risk_code: &high_risk_code - "flutter/lib/src/integrations/native_app_start_integration.dart" - "flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt" - "flutter/ios/Classes/SentryFlutterPluginApple.swift" - + - "flutter/lib/src/screenshot/recorder.dart" + - "flutter/lib/src/screenshot/widget_filter.dart" diff --git a/CHANGELOG.md b/CHANGELOG.md index f216ff084f..2fc074e605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ ### Features +- Support for screenshot PII content masking ([#2361](https://github.com/getsentry/sentry-dart/pull/2361)) + By default, masking is enabled for SessionReplay. To also enable it for screenshots captured with events, you can specify `options.experimental.privacy`: + ```dart + await SentryFlutter.init( + (options) { + ... + // the defaults are: + options.experimental.privacy.maskAllText = true; + options.experimental.privacy.maskAllImages = true; + options.experimental.privacy.maskAssetImages = false; + // you cal also set up custom masking, for example: + options.experimental.privacy.mask(); + }, + appRunner: () => runApp(MyApp()), + ); + ``` + Actually, just accessing this field will cause it to be initialized with the default settings to mask all text and images: + ```dart + await SentryFlutter.init( + (options) { + ... + // this has a side-effect of creating the default privacy configuration, thus enabling Screenshot masking: + options.experimental.privacy; + }, + appRunner: () => runApp(MyApp()), + ); + ``` - Linux native error & obfuscation support ([#2431](https://github.com/getsentry/sentry-dart/pull/2431)) - Improve Device context on plain Dart and Flutter desktop apps ([#2441](https://github.com/getsentry/sentry-dart/pull/2441)) diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index df44ef46fb..454be356f6 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -75,7 +75,6 @@ Future setupSentry( options.sendDefaultPii = true; options.reportSilentFlutterErrors = true; options.attachScreenshot = true; - options.screenshotQuality = SentryScreenshotQuality.low; options.attachViewHierarchy = true; // We can enable Sentry debug logging during development. This is likely // going to log too much for your app, but can be useful when figuring out @@ -92,6 +91,10 @@ Future setupSentry( options.experimental.replay.sessionSampleRate = 1.0; options.experimental.replay.onErrorSampleRate = 1.0; + // This has a side-effect of creating the default privacy configuration, + // thus enabling Screenshot masking. No need to actually change it. + options.experimental.privacy; + _isIntegrationTest = isIntegrationTest; if (_isIntegrationTest) { options.dist = '1'; diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index 66cae76cac..48b0572976 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 912d92382d..724d74e0d5 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -1,20 +1,31 @@ import 'dart:async'; -import 'dart:math'; import 'dart:typed_data'; import 'dart:ui'; -import 'package:flutter/rendering.dart'; import 'package:meta/meta.dart'; -import 'package:sentry/sentry.dart'; -import '../screenshot/sentry_screenshot_widget.dart'; -import '../sentry_flutter_options.dart'; +import '../../sentry_flutter.dart'; import '../renderer/renderer.dart'; +import '../screenshot/recorder.dart'; +import '../screenshot/recorder_config.dart'; import 'package:flutter/widgets.dart' as widget; class ScreenshotEventProcessor implements EventProcessor { final SentryFlutterOptions _options; - ScreenshotEventProcessor(this._options); + late final ScreenshotRecorder _recorder; + + ScreenshotEventProcessor(this._options) { + final targetResolution = _options.screenshotQuality.targetResolution(); + + _recorder = ScreenshotRecorder( + ScreenshotRecorderConfig( + width: targetResolution, + height: targetResolution, + ), + _options, + isReplayRecorder: false, + ); + } @override Future apply(SentryEvent event, Hint hint) async { @@ -77,84 +88,36 @@ class ScreenshotEventProcessor implements EventProcessor { return event; } - final bytes = await createScreenshot(); - if (bytes != null) { - hint.screenshot = SentryAttachment.fromScreenshotData(bytes); + Uint8List? screenshotData = await createScreenshot(); + + if (screenshotData != null) { + hint.screenshot = SentryAttachment.fromScreenshotData(screenshotData); } + return event; } @internal 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; - } - } - return null; + return screenshotData; } - 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..b1d9db3dd3 100644 --- a/flutter/lib/src/integrations/screenshot_integration.dart +++ b/flutter/lib/src/integrations/screenshot_integration.dart @@ -3,7 +3,7 @@ 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.attach] is true class ScreenshotIntegration implements Integration { SentryFlutterOptions? _options; ScreenshotEventProcessor? _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/recorder_config.dart b/flutter/lib/src/replay/recorder_config.dart deleted file mode 100644 index 9649a33823..0000000000 --- a/flutter/lib/src/replay/recorder_config.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:math'; - -import 'package:meta/meta.dart'; - -@internal -class ScreenshotRecorderConfig { - final int? width; - final int? height; - - const ScreenshotRecorderConfig({this.width, this.height}); - - double getPixelRatio(double srcWidth, double srcHeight) { - assert((width == null) == (height == null)); - if (width == null || height == null) { - return 1.0; - } - 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/scheduled_recorder.dart b/flutter/lib/src/replay/scheduled_recorder.dart index c575278a74..70d8ff390c 100644 --- a/flutter/lib/src/replay/scheduled_recorder.dart +++ b/flutter/lib/src/replay/scheduled_recorder.dart @@ -4,8 +4,8 @@ import 'dart:ui'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; -import 'recorder.dart'; -import 'recorder_config.dart'; +import '../screenshot/recorder.dart'; +import 'scheduled_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..8f3c3addb2 --- /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; + + 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 68% rename from flutter/lib/src/replay/recorder.dart rename to flutter/lib/src/screenshot/recorder.dart index f15b79a072..2d8dfffa67 100644 --- a/flutter/lib/src/replay/recorder.dart +++ b/flutter/lib/src/screenshot/recorder.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart' as widgets; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; @@ -17,12 +18,23 @@ class ScreenshotRecorder { final ScreenshotRecorderConfig config; @protected final SentryFlutterOptions options; + final String _logName; WidgetFilter? _widgetFilter; - bool warningLogged = false; - - ScreenshotRecorder(this.config, this.options) { - final maskingConfig = options.experimental.replay.buildMaskingConfig(); - if (maskingConfig.length > 0) { + bool _warningLogged = false; + + // TODO: remove in the next major release, see recorder_test.dart. + @visibleForTesting + bool get hasWidgetFilter => _widgetFilter != null; + + // TODO: remove [isReplayRecorder] parameter in the next major release, see _SentryFlutterExperimentalOptions. + ScreenshotRecorder(this.config, this.options, {bool isReplayRecorder = true}) + : _logName = isReplayRecorder ? 'ReplayRecorder' : 'ScreenshotRecorder' { + // see `options.experimental.privacy` docs for details + final privacyOptions = isReplayRecorder + ? options.experimental.privacyForReplay + : options.experimental.privacyForScreenshots; + final maskingConfig = privacyOptions?.buildMaskingConfig(); + if (maskingConfig != null && maskingConfig.length > 0) { _widgetFilter = WidgetFilter(maskingConfig, options.logger); } } @@ -31,12 +43,10 @@ class ScreenshotRecorder { final context = sentryScreenshotWidgetGlobalKey.currentContext; final renderObject = context?.findRenderObject() as RenderRepaintBoundary?; if (context == null || renderObject == null) { - if (!warningLogged) { - options.logger( - SentryLevel.warning, - "Replay: SentryScreenshotWidget is not attached. " - "Skipping replay capture."); - warningLogged = true; + if (!_warningLogged) { + options.logger(SentryLevel.warning, + "$_logName: SentryScreenshotWidget is not attached, skipping capture."); + _warningLogged = true; } return; } @@ -49,7 +59,9 @@ class ScreenshotRecorder { // On iOS, the screenshot resolution is not adjusted. final srcWidth = renderObject.size.width; final srcHeight = renderObject.size.height; - final pixelRatio = config.getPixelRatio(srcWidth, srcHeight); + + final pixelRatio = config.getPixelRatio(srcWidth, srcHeight) ?? + widgets.MediaQuery.of(context).devicePixelRatio; // First, we synchronously capture the image and enumerate widgets on the main UI loop. final futureImage = renderObject.toImage(pixelRatio: pixelRatio); @@ -87,7 +99,7 @@ class ScreenshotRecorder { try { await callback(finalImage); } finally { - finalImage.dispose(); + finalImage.dispose(); // image needs to be disposed manually } } finally { picture.dispose(); @@ -95,10 +107,11 @@ class ScreenshotRecorder { options.logger( SentryLevel.debug, - "Replay: captured a screenshot in ${watch.elapsedMilliseconds}" + "$_logName: captured a screenshot in ${watch.elapsedMilliseconds}" " ms ($blockingTime ms blocking)."); } catch (e, stackTrace) { - options.logger(SentryLevel.error, "Replay: failed to capture screenshot.", + options.logger( + SentryLevel.error, "$_logName: failed to capture screenshot.", exception: e, stackTrace: stackTrace); if (options.automatedTestMode) { rethrow; diff --git a/flutter/lib/src/screenshot/recorder_config.dart b/flutter/lib/src/screenshot/recorder_config.dart new file mode 100644 index 0000000000..922a7cd62a --- /dev/null +++ b/flutter/lib/src/screenshot/recorder_config.dart @@ -0,0 +1,22 @@ +import 'dart:math'; + +import 'package:meta/meta.dart'; + +@internal +class ScreenshotRecorderConfig { + final int? width; + final int? height; + + ScreenshotRecorderConfig({ + this.width, + this.height, + }); + + double? getPixelRatio(double srcWidth, double srcHeight) { + assert((width == null) == (height == null)); + if (width == null || height == null) { + return null; + } + return min(width! / srcWidth, height! / srcHeight); + } +} diff --git a/flutter/lib/src/screenshot/sentry_screenshot_quality.dart b/flutter/lib/src/screenshot/sentry_screenshot_quality.dart index d42e622966..9b36add9d6 100644 --- a/flutter/lib/src/screenshot/sentry_screenshot_quality.dart +++ b/flutter/lib/src/screenshot/sentry_screenshot_quality.dart @@ -8,7 +8,7 @@ enum SentryScreenshotQuality { int? targetResolution() { switch (this) { case SentryScreenshotQuality.full: - return null; // Keep current scale + return null; // Uses the device pixel ratio to scale the screenshot case SentryScreenshotQuality.high: return 1920; case SentryScreenshotQuality.medium: 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 23aefdd849..6459eb55bc 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -12,8 +12,8 @@ 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_privacy_options.dart'; import 'sentry_replay_options.dart'; import 'user_interaction/sentry_user_interaction_widget.dart'; @@ -183,16 +183,17 @@ class SentryFlutterOptions extends SentryOptions { /// Automatically attaches a screenshot when capturing an error or exception. /// - /// Requires adding the [SentryScreenshotWidget] to the widget tree. + /// Requires adding the [SentryWidget] to the widget tree. /// Example: - /// runApp(SentryScreenshotWidget(child: App())); - /// The [SentryScreenshotWidget] has to be the root widget of the app. + /// runApp(SentryWidget(child: App())); + /// The [SentryWidget] has to be the root widget of the app. bool attachScreenshot = false; /// The quality of the attached screenshot SentryScreenshotQuality screenshotQuality = SentryScreenshotQuality.high; /// Only attach a screenshot when the app is resumed. + /// See https://docs.sentry.io/platforms/flutter/troubleshooting/#screenshot-integration-background-crash bool attachScreenshotOnlyWhenResumed = false; /// Sets a callback which is executed before capturing screenshots. Only @@ -385,6 +386,35 @@ class SentryFlutterOptions extends SentryOptions { class _SentryFlutterExperimentalOptions { /// Replay recording configuration. final replay = SentryReplayOptions(); + + /// Privacy configuration for masking sensitive data in screenshots and Session Replay. + /// Screen content masking is: + /// - enabled by default for SessionReplay + /// - disabled by default for screenshots captured with events. + /// In order to mask screenshots captured with events, access or change + /// this property in your application: `options.experimental.privacy`. + /// Doing so will indicate that you want to configure privacy settings and + /// will enable screenshot masking alongside the default replay masking. + /// Note: this will change in a future SDK major release to enable screenshot + /// masking by default for all captures. + SentryPrivacyOptions get privacy { + // If the user explicitly sets the privacy setting, we use that. + // Otherwise, we use the default settings, which is no masking for screenshots + // and full masking for session replay. + // This property must only by accessed by user code otherwise it defeats the purpose. + _privacy ??= SentryPrivacyOptions(); + return _privacy!; + } + + /// TODO: remove when default masking value are synced with SS & SR in the next major release + SentryPrivacyOptions? _privacy; + + @meta.internal + SentryPrivacyOptions? get privacyForScreenshots => _privacy; + + @meta.internal + SentryPrivacyOptions get privacyForReplay => + _privacy ?? SentryPrivacyOptions(); } /// Callback being executed in [ScreenshotEventProcessor], deciding if a diff --git a/flutter/lib/src/sentry_privacy_options.dart b/flutter/lib/src/sentry_privacy_options.dart new file mode 100644 index 0000000000..7873116b9b --- /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 masked. + /// 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 masked. + /// 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/test/replay/recorder_config_test.dart b/flutter/test/replay/recorder_config_test.dart deleted file mode 100644 index d884073e91..0000000000 --- a/flutter/test/replay/recorder_config_test.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/src/replay/recorder_config.dart'; - -void main() async { - group('$ScreenshotRecorderConfig', () { - test('defaults', () { - var sut = ScreenshotRecorderConfig(); - expect(sut.height, isNull); - expect(sut.width, isNull); - }); - - test('pixel ratio calculation', () { - expect(ScreenshotRecorderConfig().getPixelRatio(100, 100), 1.0); - expect( - ScreenshotRecorderConfig(width: 5, height: 10) - .getPixelRatio(100, 100), - 0.05); - expect( - ScreenshotRecorderConfig(width: 20, height: 10) - .getPixelRatio(100, 100), - 0.1); - }); - }); -} diff --git a/flutter/test/replay/recorder_test.dart b/flutter/test/replay/recorder_test.dart deleted file mode 100644 index 2df4334c5b..0000000000 --- a/flutter/test/replay/recorder_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -// For some reason, this test is not working in the browser but that's OK, we -// don't support video recording anyway. -@TestOn('vm') -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 '../mocks.dart'; -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')); - }); -} - -class _Fixture { - late final ScreenshotRecorder sut; - - _Fixture._() { - sut = ScreenshotRecorder( - ScreenshotRecorderConfig(), - defaultTestOptions()..bindingUtils = TestBindingWrapper(), - ); - } - - static Future<_Fixture> create(WidgetTester tester) async { - final fixture = _Fixture._(); - await pumpTestElement(tester); - return fixture; - } - - Future capture() async { - String? captured; - await sut.capture((Image image) async { - captured = "${image.width}x${image.height}"; - }); - return captured; - } -} diff --git a/flutter/test/replay/replay_native_test.dart b/flutter/test/replay/replay_native_test.dart index aad2f3a037..3b35f53c2e 100644 --- a/flutter/test/replay/replay_native_test.dart +++ b/flutter/test/replay/replay_native_test.dart @@ -17,7 +17,7 @@ import 'package:sentry_flutter/src/replay/integration.dart'; import '../mocks.dart'; import '../mocks.mocks.dart'; -import 'test_widget.dart'; +import '../screenshot/test_widget.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/flutter/test/replay/scheduled_recorder_test.dart b/flutter/test/replay/scheduled_recorder_test.dart index 7ace54c18e..603c2b24b2 100644 --- a/flutter/test/replay/scheduled_recorder_test.dart +++ b/flutter/test/replay/scheduled_recorder_test.dart @@ -7,10 +7,10 @@ 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'; +import '../screenshot/test_widget.dart'; void main() async { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/flutter/test/replay/masking_config_test.dart b/flutter/test/screenshot/masking_config_test.dart similarity index 94% rename from flutter/test/replay/masking_config_test.dart rename to flutter/test/screenshot/masking_config_test.dart index 46e6a99261..90333c5fe0 100644 --- a/flutter/test/replay/masking_config_test.dart +++ b/flutter/test/screenshot/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/screenshot/recorder_config_test.dart b/flutter/test/screenshot/recorder_config_test.dart new file mode 100644 index 0000000000..d640a7ddb3 --- /dev/null +++ b/flutter/test/screenshot/recorder_config_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/screenshot/recorder_config.dart'; + +void main() async { + group('$ScreenshotRecorderConfig', () { + test('defaults', () { + var sut = ScreenshotRecorderConfig(); + expect(sut.height, isNull); + expect(sut.width, isNull); + }); + }); + + test('pixel ratio calculation', () { + expect(ScreenshotRecorderConfig().getPixelRatio(100, 100), null); + expect( + ScreenshotRecorderConfig(width: 5, height: 10).getPixelRatio(100, 100), + 0.05); + expect( + ScreenshotRecorderConfig(width: 20, height: 10).getPixelRatio(100, 100), + 0.1); + }); +} diff --git a/flutter/test/screenshot/recorder_test.dart b/flutter/test/screenshot/recorder_test.dart new file mode 100644 index 0000000000..4ecb78cff6 --- /dev/null +++ b/flutter/test/screenshot/recorder_test.dart @@ -0,0 +1,127 @@ +// For some reason, this test is not working in the browser but that's OK, we +// don't support video recording anyway. +@TestOn('vm') +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'; + +import '../mocks.dart'; +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + // with `tester.binding.setSurfaceSize` you are setting the `logical resolution` + // not the `device screen resolution`. + // The `device screen resolution = logical resolution * devicePixelRatio` + + testWidgets('captures full resolution images - portrait', (tester) async { + await tester.binding.setSurfaceSize(Size(2000, 4000)); + final fixture = await _Fixture.create(tester); + + //devicePixelRatio is 3.0 therefore the resolution multiplied by 3 + expect(fixture.capture(), completion('6000x12000')); + }); + + testWidgets('captures full resolution images - landscape', (tester) async { + await tester.binding.setSurfaceSize(Size(4000, 2000)); + final fixture = await _Fixture.create(tester); + + //devicePixelRatio is 3.0 therefore the resolution multiplied by 3 + expect(fixture.capture(), completion('12000x6000')); + }); + + testWidgets('captures high resolution images - portrait', (tester) async { + await tester.binding.setSurfaceSize(Size(2000, 4000)); + final targetResolution = SentryScreenshotQuality.high.targetResolution(); + final fixture = await _Fixture.create(tester, + width: targetResolution, height: targetResolution); + + expect(fixture.capture(), completion('960x1920')); + }); + + testWidgets('captures high resolution images - landscape', (tester) async { + await tester.binding.setSurfaceSize(Size(4000, 2000)); + final targetResolution = SentryScreenshotQuality.high.targetResolution(); + final fixture = await _Fixture.create(tester, + width: targetResolution, height: targetResolution); + + expect(fixture.capture(), completion('1920x960')); + }); + + testWidgets('captures medium resolution images', (tester) async { + await tester.binding.setSurfaceSize(Size(2000, 4000)); + final targetResolution = SentryScreenshotQuality.medium.targetResolution(); + final fixture = await _Fixture.create(tester, + width: targetResolution, height: targetResolution); + + expect(fixture.capture(), completion('640x1280')); + }); + + testWidgets('captures low resolution images', (tester) async { + await tester.binding.setSurfaceSize(Size(2000, 4000)); + final targetResolution = SentryScreenshotQuality.low.targetResolution(); + final fixture = await _Fixture.create(tester, + width: targetResolution, height: targetResolution); + + expect(fixture.capture(), completion('427x854')); + }); + + // TODO: remove in the next major release, see _SentryFlutterExperimentalOptions. + group('Widget filter is used based on config or application', () { + test('Uses widget filter by default for Replay', () { + final sut = ScreenshotRecorder( + ScreenshotRecorderConfig(), + defaultTestOptions(), + ); + expect(sut.hasWidgetFilter, isTrue); + }); + + test('Does not use widget filter by default for Screenshots', () { + final sut = ScreenshotRecorder( + ScreenshotRecorderConfig(), defaultTestOptions(), + isReplayRecorder: false); + expect(sut.hasWidgetFilter, isFalse); + }); + + test( + 'Uses widget filter for Screenshots when privacy configured explicitly', + () { + final sut = ScreenshotRecorder(ScreenshotRecorderConfig(), + defaultTestOptions()..experimental.privacy.maskAllText = false, + isReplayRecorder: false); + expect(sut.hasWidgetFilter, isTrue); + }); + }); +} + +class _Fixture { + late final ScreenshotRecorder sut; + + _Fixture({int? width, int? height}) { + sut = ScreenshotRecorder( + ScreenshotRecorderConfig(width: width, height: height), + defaultTestOptions()..bindingUtils = TestBindingWrapper(), + ); + } + + static Future<_Fixture> create(WidgetTester tester, + {int? width, int? height}) async { + final fixture = _Fixture(width: width, height: height); + await pumpTestElement(tester); + return fixture; + } + + Future capture() async { + String? captured; + await sut.capture((Image image) async { + captured = "${image.width}x${image.height}"; + }); + return captured; + } +} 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..3f406fc007 --- /dev/null +++ b/flutter/test/screenshot/sentry_screenshot_quality_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +// ignore_for_file: deprecated_member_use + +void main() async { + group('$SentryScreenshotQuality', () { + test('test quality: full', () { + final sut = SentryScreenshotQuality.full; + expect(sut.targetResolution(), isNull); + }); + + test('test quality: high', () { + final sut = SentryScreenshotQuality.high; + final res = sut.targetResolution()!; + expect(res, 1920); + }); + + test('test quality: medium', () { + final sut = SentryScreenshotQuality.medium; + final res = sut.targetResolution()!; + expect(res, 1280); + }); + + test('test quality: low', () { + final sut = SentryScreenshotQuality.low; + final res = sut.targetResolution()!; + expect(res, 854); + }); + }); +} diff --git a/flutter/test/replay/test_widget.dart b/flutter/test/screenshot/test_widget.dart similarity index 100% rename from flutter/test/replay/test_widget.dart rename to flutter/test/screenshot/test_widget.dart diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/screenshot/widget_filter_test.dart similarity index 96% rename from flutter/test/replay/widget_filter_test.dart rename to flutter/test/screenshot/widget_filter_test.dart index ad76e9bfaa..a5bab11671 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/screenshot/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}) {}); };