diff --git a/CHANGELOG.md b/CHANGELOG.md index a9833608df..b5a05c6e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ - Emit `transaction.data` inside `contexts.trace.data` ([#2284](https://github.com/getsentry/sentry-dart/pull/2284)) - Blocking app starts if "appLaunchedInForeground" is false. (Android only) ([#2291](https://github.com/getsentry/sentry-dart/pull/2291)) - Windows native error & obfuscation support ([#2286](https://github.com/getsentry/sentry-dart/pull/2286)) +- Replay: user-configurable masking (redaction) for widget classes and specific widget instances. ([#2324](https://github.com/getsentry/sentry-dart/pull/2324)) + Some examples of the configuration: + + ```dart + await SentryFlutter.init( + (options) { + ... + options.experimental.replay.mask(); + options.experimental.replay.unmask(); + options.experimental.replay.maskCallback( + (Element element, Text widget) => + (widget.data?.contains('secret') ?? false) + ? SentryMaskingDecision.mask + : SentryMaskingDecision.continueProcessing); + }, + appRunner: () => runApp(MyApp()), + ); + ``` + + Also, you can wrap any of your widgets with `SentryMask()` or `SentryUnmask()` widgets to mask/unmask them, respectively. For example: + + ```dart +  SentryUnmask(Text('Not secret at all')); + ``` + - Support `captureFeedback` ([#2230](https://github.com/getsentry/sentry-dart/pull/2230)) - Deprecated `Sentry.captureUserFeedback`, use `captureFeedback` instead. - Deprecated `Hub.captureUserFeedback`, use `captureFeedback` instead. diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index d1ee9c080c..c3e604e634 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -12,6 +12,9 @@ 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/sentry_mask_widget.dart'; +export 'src/screenshot/sentry_unmask_widget.dart'; export 'src/screenshot/sentry_screenshot_widget.dart'; export 'src/screenshot/sentry_screenshot_quality.dart'; export 'src/user_interaction/sentry_user_interaction_widget.dart'; diff --git a/flutter/lib/src/replay/masking_config.dart b/flutter/lib/src/replay/masking_config.dart new file mode 100644 index 0000000000..a9f6abbbdc --- /dev/null +++ b/flutter/lib/src/replay/masking_config.dart @@ -0,0 +1,87 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +@internal +class SentryMaskingConfig { + @visibleForTesting + final List rules; + + final int length; + + SentryMaskingConfig(List rules) + // Note: fixed-size list has performance benefits over growable list. + : rules = List.of(rules, growable: false), + length = rules.length; + + SentryMaskingDecision shouldMask( + Element element, T widget) { + for (int i = 0; i < length; i++) { + if (rules[i].appliesTo(widget)) { + // We use a switch here to get lints if more values are added. + switch (rules[i].shouldMask(element, widget)) { + case SentryMaskingDecision.mask: + return SentryMaskingDecision.mask; + case SentryMaskingDecision.unmask: + return SentryMaskingDecision.unmask; + case SentryMaskingDecision.continueProcessing: + // Continue to the next matching rule. + } + } + } + return SentryMaskingDecision.continueProcessing; + } +} + +@experimental +enum SentryMaskingDecision { + /// Mask the widget and its children + mask, + + /// Leave the widget visible, including its children (no more rules will + /// be checked for children). + unmask, + + /// Don't make a decision - continue checking other rules and children. + continueProcessing +} + +@internal +abstract class SentryMaskingRule { + @pragma('vm:prefer-inline') + bool appliesTo(Widget widget) => widget is T; + SentryMaskingDecision shouldMask(Element element, T widget); + + const SentryMaskingRule(); +} + +@internal +class SentryMaskingCustomRule extends SentryMaskingRule { + final SentryMaskingDecision Function(Element element, T widget) callback; + + const SentryMaskingCustomRule(this.callback); + + @override + SentryMaskingDecision shouldMask(Element element, T widget) => + callback(element, widget); + + @override + String toString() => '$SentryMaskingCustomRule<$T>($callback)'; +} + +@internal +class SentryMaskingConstantRule extends SentryMaskingRule { + final SentryMaskingDecision _value; + const SentryMaskingConstantRule(this._value); + + @override + SentryMaskingDecision shouldMask(Element element, T widget) { + // This rule only makes sense with true/false. Continue won't do anything. + assert(_value == SentryMaskingDecision.mask || + _value == SentryMaskingDecision.unmask); + return _value; + } + + @override + String toString() => + '$SentryMaskingConstantRule<$T>(${_value == SentryMaskingDecision.mask ? 'mask' : 'unmask'})'; +} diff --git a/flutter/lib/src/replay/recorder.dart b/flutter/lib/src/replay/recorder.dart index 847c3a75f6..f15b79a072 100644 --- a/flutter/lib/src/replay/recorder.dart +++ b/flutter/lib/src/replay/recorder.dart @@ -21,12 +21,9 @@ class ScreenshotRecorder { bool warningLogged = false; ScreenshotRecorder(this.config, this.options) { - final replayOptions = options.experimental.replay; - if (replayOptions.redactAllText || replayOptions.redactAllImages) { - _widgetFilter = WidgetFilter( - redactText: replayOptions.redactAllText, - redactImages: replayOptions.redactAllImages, - logger: options.logger); + final maskingConfig = options.experimental.replay.buildMaskingConfig(); + if (maskingConfig.length > 0) { + _widgetFilter = WidgetFilter(maskingConfig, options.logger); } } diff --git a/flutter/lib/src/replay/widget_filter.dart b/flutter/lib/src/replay/widget_filter.dart index 1f66a42f1a..32231a0d12 100644 --- a/flutter/lib/src/replay/widget_filter.dart +++ b/flutter/lib/src/replay/widget_filter.dart @@ -1,42 +1,39 @@ import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import '../sentry_asset_bundle.dart'; +import 'masking_config.dart'; @internal class WidgetFilter { final items = []; final SentryLogger logger; - final bool redactText; - final bool redactImages; + final SentryMaskingConfig config; static const _defaultColor = Color.fromARGB(255, 0, 0, 0); late double _pixelRatio; late Rect _bounds; final _warnedWidgets = {}; - final AssetBundle _rootAssetBundle; - WidgetFilter( - {required this.redactText, - required this.redactImages, - required this.logger, - @visibleForTesting AssetBundle? rootAssetBundle}) - : _rootAssetBundle = rootAssetBundle ?? rootBundle; + /// Used to test _obscureElementOrParent + @visibleForTesting + bool throwInObscure = false; + + WidgetFilter(this.config, this.logger); void obscure(BuildContext context, double pixelRatio, Rect bounds) { _pixelRatio = pixelRatio; _bounds = bounds; items.clear(); if (context is Element) { - _obscure(context); + _process(context); } else { - context.visitChildElements(_obscure); + context.visitChildElements(_process); } } - void _obscure(Element element) { + void _process(Element element) { final widget = element.widget; if (!_isVisible(widget)) { @@ -47,47 +44,64 @@ class WidgetFilter { return; } - final obscured = _obscureIfNeeded(element, widget); - if (!obscured) { - element.visitChildElements(_obscure); + final decision = config.shouldMask(element, widget); + switch (decision) { + case SentryMaskingDecision.mask: + final item = _obscureElementOrParent(element, widget); + if (item != null) { + items.add(item); + } + break; + case SentryMaskingDecision.unmask: + logger(SentryLevel.debug, "WidgetFilter unmasked: $widget"); + break; + case SentryMaskingDecision.continueProcessing: + // If this element should not be obscured, visit and check its children. + element.visitChildElements(_process); + break; } } + /// Determine the color and bounding box of the widget. + /// If the widget is offscreen, returns null. + /// If the widget cannot be obscured, obscures the parent. @pragma('vm:prefer-inline') - bool _obscureIfNeeded(Element element, Widget widget) { - Color? color; - - if (redactText && widget is Text) { - color = widget.style?.color; - } else if (redactText && widget is EditableText) { - color = widget.style.color; - } else if (redactImages && widget is Image) { - if (widget.image is AssetBundleImageProvider) { - final image = widget.image as AssetBundleImageProvider; - if (isBuiltInAssetImage(image)) { - logger(SentryLevel.debug, - "WidgetFilter skipping asset: $widget ($image)."); - return false; + WidgetFilterItem? _obscureElementOrParent(Element element, Widget widget) { + while (true) { + try { + return _obscure(element, widget); + } catch (e, stackTrace) { + final parent = element.parent; + if (!_warnedWidgets.contains(widget.hashCode)) { + _warnedWidgets.add(widget.hashCode); + logger( + SentryLevel.warning, + 'WidgetFilter cannot mask widget $widget: $e.' + 'Obscuring the parent instead: ${parent?.widget}.', + stackTrace: stackTrace); } + if (parent == null) { + return WidgetFilterItem(_defaultColor, _bounds); + } + element = parent; + widget = element.widget; } - color = widget.color; - } else { - // No other type is currently obscured. - return false; - } - - final renderObject = element.renderObject; - if (renderObject is! RenderBox) { - _cantObscure(widget, "its renderObject is not a RenderBox"); - return false; } + } - var rect = _boundingBox(renderObject); + /// Determine the color and bounding box of the widget. + /// If the widget is offscreen, returns null. + /// This function may throw in which case the caller is responsible for + /// calling it again on the parent element. + @pragma('vm:prefer-inline') + WidgetFilterItem? _obscure(Element element, Widget widget) { + final RenderBox renderBox = element.renderObject as RenderBox; + var rect = _boundingBox(renderBox); // If it's a clipped render object, use parent's offset and size. // This helps with text fields which often have oversized render objects. - if (renderObject.parent is RenderStack) { - final renderStack = (renderObject.parent as RenderStack); + if (renderBox.parent is RenderStack) { + final renderStack = (renderBox.parent as RenderStack); final clipBehavior = renderStack.clipBehavior; if (clipBehavior == Clip.hardEdge || clipBehavior == Clip.antiAlias || @@ -102,19 +116,37 @@ class WidgetFilter { logger(SentryLevel.debug, "WidgetFilter skipping offscreen: $widget"); return true; }()); - return false; + return null; } - items.add(WidgetFilterItem(color ?? _defaultColor, rect)); assert(() { - logger(SentryLevel.debug, "WidgetFilter obscuring: $widget"); + logger(SentryLevel.debug, "WidgetFilter masking: $widget"); return true; }()); - return true; + Color? color; + if (widget is Text) { + color = (widget).style?.color; + } else if (widget is EditableText) { + color = (widget).style.color; + } else if (widget is Image) { + color = (widget).color; + } + + // test-only code + assert(() { + if (throwInObscure) { + throwInObscure = false; + return false; + } + return true; + }()); + + return WidgetFilterItem(color ?? _defaultColor, rect); } // We cut off some widgets early because they're not visible at all. + @pragma('vm:prefer-inline') bool _isVisible(Widget widget) { if (widget is Visibility) { return widget.visible; @@ -128,9 +160,10 @@ class WidgetFilter { return true; } - @visibleForTesting + @internal @pragma('vm:prefer-inline') - bool isBuiltInAssetImage(AssetBundleImageProvider image) { + static bool isBuiltInAssetImage( + AssetBundleImageProvider image, AssetBundle rootAssetBundle) { late final AssetBundle? bundle; if (image is AssetImage) { bundle = image.bundle; @@ -140,17 +173,8 @@ class WidgetFilter { return false; } return (bundle == null || - bundle == _rootAssetBundle || - (bundle is SentryAssetBundle && bundle.bundle == _rootAssetBundle)); - } - - @pragma('vm:prefer-inline') - void _cantObscure(Widget widget, String message) { - if (!_warnedWidgets.contains(widget.hashCode)) { - _warnedWidgets.add(widget.hashCode); - logger(SentryLevel.warning, - "WidgetFilter cannot obscure widget $widget: $message"); - } + bundle == rootAssetBundle || + (bundle is SentryAssetBundle && bundle.bundle == rootAssetBundle)); } @pragma('vm:prefer-inline') @@ -165,9 +189,21 @@ class WidgetFilter { } } +@internal class WidgetFilterItem { final Color color; final Rect bounds; const WidgetFilterItem(this.color, this.bounds); } + +extension on Element { + Element? get parent { + Element? result; + visitAncestorElements((el) { + result = el; + return false; + }); + return result; + } +} diff --git a/flutter/lib/src/screenshot/sentry_mask_widget.dart b/flutter/lib/src/screenshot/sentry_mask_widget.dart new file mode 100644 index 0000000000..d750fe7f1b --- /dev/null +++ b/flutter/lib/src/screenshot/sentry_mask_widget.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +/// Wrapping your widget in [SentryMask] will mask it when capturing replays. +@experimental +class SentryMask extends StatelessWidget { + final Widget child; + + const SentryMask(this.child, {super.key}); + + @override + Widget build(BuildContext context) => child; +} diff --git a/flutter/lib/src/screenshot/sentry_unmask_widget.dart b/flutter/lib/src/screenshot/sentry_unmask_widget.dart new file mode 100644 index 0000000000..cf1fa52b5d --- /dev/null +++ b/flutter/lib/src/screenshot/sentry_unmask_widget.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +/// Wrapping your widget in [SentryUnmask] will unmask it when capturing replays. +@experimental +class SentryUnmask extends StatelessWidget { + final Widget child; + + const SentryUnmask(this.child, {super.key}); + + @override + Widget build(BuildContext context) => child; +} diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart index e52fbb2877..a6e83fec4f 100644 --- a/flutter/lib/src/sentry_replay_options.dart +++ b/flutter/lib/src/sentry_replay_options.dart @@ -1,6 +1,14 @@ +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 { double? _sessionSampleRate; @@ -24,17 +32,110 @@ class SentryReplayOptions { _onErrorSampleRate = value; } - /// Redact all text content. Draws a rectangle of text bounds with text color + /// 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 redactAllText = true; + var maskAllText = true; + + @Deprecated('Use maskAllText instead') + bool get redactAllText => maskAllText; + set redactAllText(bool value) => maskAllText = value; - /// Redact all image content. Draws a rectangle of image bounds with image's + /// 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. - var redactAllImages = true; + /// 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/masking_config_test.dart b/flutter/test/replay/masking_config_test.dart new file mode 100644 index 0000000000..46e6a99261 --- /dev/null +++ b/flutter/test/replay/masking_config_test.dart @@ -0,0 +1,287 @@ +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 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + final alwaysEnabledRules = [ + '$SentryMaskingConstantRule<$SentryMask>(mask)', + '$SentryMaskingConstantRule<$SentryUnmask>(unmask)', + ]; + + testWidgets('will not mask if there are no rules', (tester) async { + final sut = SentryMaskingConfig([]); + final element = await pumpTestElement(tester); + expect(sut.rules, isEmpty); + expect(sut.length, 0); + expect(sut.shouldMask(element, element.widget), + SentryMaskingDecision.continueProcessing); + }); + + for (final value in [ + SentryMaskingDecision.mask, + SentryMaskingDecision.unmask + ]) { + group('$SentryMaskingConstantRule($value)', () { + testWidgets('will mask widget by type', (tester) async { + final sut = + SentryMaskingConfig([SentryMaskingConstantRule(value)]); + final rootElement = await pumpTestElement(tester); + final element = rootElement.findFirstOfType(); + expect(sut.shouldMask(element, element.widget), value); + }); + + testWidgets('will mask subtype widget by type', (tester) async { + final sut = + SentryMaskingConfig([SentryMaskingConstantRule(value)]); + final rootElement = await pumpTestElement(tester); + final element = rootElement.findFirstOfType(); + expect(sut.shouldMask(element, element.widget), value); + }); + + testWidgets('will not mask widget of a different type', (tester) async { + final sut = + SentryMaskingConfig([SentryMaskingConstantRule(value)]); + final rootElement = await pumpTestElement(tester); + final element = rootElement.findFirstOfType(); + expect(sut.shouldMask(element, element.widget), + SentryMaskingDecision.continueProcessing); + }, skip: value == SentryMaskingDecision.unmask); + }); + } + + group('$SentryMaskingCustomRule', () { + testWidgets('only called for specified type', (tester) async { + final called = {}; + final sut = SentryMaskingConfig([ + SentryMaskingCustomRule((e, w) { + called[w.runtimeType] = (called[w.runtimeType] ?? 0) + 1; + return SentryMaskingDecision.continueProcessing; + }) + ]); + final rootElement = await pumpTestElement(tester); + for (final element in rootElement.findAllChildren()) { + expect(sut.shouldMask(element, element.widget), + SentryMaskingDecision.continueProcessing); + } + // Note: there are actually 5 Image widgets in the tree but when it's + // inside an `Visibility(visible: false)`, it won't be visited. + expect(called, {Image: 4, CustomImageWidget: 1}); + }); + + testWidgets('stops iteration on the first "mask" rule', (tester) async { + final sut = SentryMaskingConfig([ + SentryMaskingCustomRule( + (e, w) => SentryMaskingDecision.continueProcessing), + SentryMaskingCustomRule((e, w) => SentryMaskingDecision.mask), + SentryMaskingCustomRule((e, w) => fail('should not be called')) + ]); + final rootElement = await pumpTestElement(tester); + final element = rootElement.findFirstOfType(); + expect( + sut.shouldMask(element, element.widget), SentryMaskingDecision.mask); + }); + + testWidgets('stops iteration on first "unmask" rule', (tester) async { + final sut = SentryMaskingConfig([ + SentryMaskingCustomRule( + (e, w) => SentryMaskingDecision.continueProcessing), + SentryMaskingCustomRule((e, w) => SentryMaskingDecision.unmask), + SentryMaskingCustomRule((e, w) => fail('should not be called')) + ]); + final rootElement = await pumpTestElement(tester); + final element = rootElement.findFirstOfType(); + expect(sut.shouldMask(element, element.widget), + SentryMaskingDecision.unmask); + }); + + testWidgets('retuns false if no rule matches', (tester) async { + final sut = SentryMaskingConfig([ + SentryMaskingCustomRule( + (e, w) => SentryMaskingDecision.continueProcessing), + SentryMaskingCustomRule( + (e, w) => SentryMaskingDecision.continueProcessing), + ]); + final rootElement = await pumpTestElement(tester); + final element = rootElement.findFirstOfType(); + expect(sut.shouldMask(element, element.widget), + SentryMaskingDecision.continueProcessing); + }); + }); + + group('$SentryReplayOptions.buildMaskingConfig()', () { + List rulesAsStrings(SentryReplayOptions options) { + final config = options.buildMaskingConfig(); + return config.rules + .map((rule) => rule.toString()) + // These normalize the string on VM & js & wasm: + .map((str) => str.replaceAll( + RegExp( + r"SentryMaskingDecision from:? [fF]unction '?_maskImagesExceptAssets[@(].*", + dotAll: true), + 'SentryMaskingDecision)')) + .map((str) => str.replaceAll( + ' from: (element, widget) => masking_config.SentryMaskingDecision.mask', + '')) + .toList(); + } + + test('defaults', () { + final sut = SentryReplayOptions(); + expect(rulesAsStrings(sut), [ + ...alwaysEnabledRules, + '$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)', + '$SentryMaskingConstantRule<$Text>(mask)', + '$SentryMaskingConstantRule<$EditableText>(mask)' + ]); + }); + + test('maskAllImages=true & maskAssetImages=true', () { + final sut = SentryReplayOptions() + ..maskAllText = false + ..maskAllImages = true + ..maskAssetImages = true; + expect(rulesAsStrings(sut), [ + ...alwaysEnabledRules, + '$SentryMaskingConstantRule<$Image>(mask)', + ]); + }); + + test('maskAllImages=true & maskAssetImages=false', () { + final sut = SentryReplayOptions() + ..maskAllText = false + ..maskAllImages = true + ..maskAssetImages = false; + expect(rulesAsStrings(sut), [ + ...alwaysEnabledRules, + '$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)', + ]); + }); + + test('maskAllText=true', () { + final sut = SentryReplayOptions() + ..maskAllText = true + ..maskAllImages = false + ..maskAssetImages = false; + expect(rulesAsStrings(sut), [ + ...alwaysEnabledRules, + '$SentryMaskingConstantRule<$Text>(mask)', + '$SentryMaskingConstantRule<$EditableText>(mask)', + ]); + }); + + test('maskAllText=false', () { + final sut = SentryReplayOptions() + ..maskAllText = false + ..maskAllImages = false + ..maskAssetImages = false; + expect(rulesAsStrings(sut), alwaysEnabledRules); + }); + + group('user rules', () { + final defaultRules = [ + ...alwaysEnabledRules, + '$SentryMaskingCustomRule<$Image>(Closure: (Element, Widget) => SentryMaskingDecision)', + '$SentryMaskingConstantRule<$Text>(mask)', + '$SentryMaskingConstantRule<$EditableText>(mask)' + ]; + test('mask() takes precedence', () { + final sut = SentryReplayOptions(); + sut.mask(); + expect(rulesAsStrings(sut), + ['$SentryMaskingConstantRule<$Image>(mask)', ...defaultRules]); + }); + test('unmask() takes precedence', () { + final sut = SentryReplayOptions(); + sut.unmask(); + expect(rulesAsStrings(sut), + ['$SentryMaskingConstantRule<$Image>(unmask)', ...defaultRules]); + }); + test('are ordered in the call order', () { + var sut = SentryReplayOptions(); + sut.mask(); + sut.unmask(); + expect(rulesAsStrings(sut), [ + '$SentryMaskingConstantRule<$Image>(mask)', + '$SentryMaskingConstantRule<$Image>(unmask)', + ...defaultRules + ]); + sut = SentryReplayOptions(); + sut.unmask(); + sut.mask(); + expect(rulesAsStrings(sut), [ + '$SentryMaskingConstantRule<$Image>(unmask)', + '$SentryMaskingConstantRule<$Image>(mask)', + ...defaultRules + ]); + sut = SentryReplayOptions(); + sut.unmask(); + sut.maskCallback( + (Element element, Image widget) => SentryMaskingDecision.mask); + sut.mask(); + expect(rulesAsStrings(sut), [ + '$SentryMaskingConstantRule<$Image>(unmask)', + '$SentryMaskingCustomRule<$Image>(Closure: ($Element, $Image) => $SentryMaskingDecision)', + '$SentryMaskingConstantRule<$Image>(mask)', + ...defaultRules + ]); + }); + test('maskCallback() takes precedence', () { + final sut = SentryReplayOptions(); + sut.maskCallback( + (Element element, Image widget) => SentryMaskingDecision.mask); + expect(rulesAsStrings(sut), [ + '$SentryMaskingCustomRule<$Image>(Closure: ($Element, $Image) => $SentryMaskingDecision)', + ...defaultRules + ]); + }); + test('User cannot add $SentryMask and $SentryUnmask rules', () { + final sut = SentryReplayOptions(); + expect(sut.mask, throwsA(isA())); + expect(sut.mask, throwsA(isA())); + expect(sut.unmask, throwsA(isA())); + expect(sut.unmask, throwsA(isA())); + expect( + () => sut.maskCallback( + (_, __) => SentryMaskingDecision.mask), + throwsA(isA())); + expect( + () => sut.maskCallback( + (_, __) => SentryMaskingDecision.mask), + throwsA(isA())); + }); + }); + }); +} + +extension on Element { + Element findFirstOfType() { + late Element result; + late void Function(Element) visitor; + visitor = (Element element) { + if (element.widget is T) { + result = element; + } else { + element.visitChildElements(visitor); + } + }; + visitChildren((visitor)); + assert(result.widget is T); + return result; + } + + List findAllChildren() { + final result = []; + late void Function(Element) visitor; + visitor = (Element element) { + result.add(element); + element.visitChildElements(visitor); + }; + visitChildren((visitor)); + return result; + } +} diff --git a/flutter/test/replay/test_widget.dart b/flutter/test/replay/test_widget.dart index d800a3ef12..2e8d257a2a 100644 --- a/flutter/test/replay/test_widget.dart +++ b/flutter/test/replay/test_widget.dart @@ -25,7 +25,7 @@ Future pumpTestElement(WidgetTester tester, onPressed: () {}, child: Text('Button title'), ), - newImage(), + newCustomImage(), // Invisible widgets won't be obscured. Visibility(visible: false, child: Text('Invisible text')), Visibility(visible: false, child: newImage()), @@ -34,6 +34,7 @@ Future pumpTestElement(WidgetTester tester, Offstage(offstage: true, child: Text('Offstage text')), Offstage(offstage: true, child: newImage()), Text(dummyText), + Material(child: TextFormField()), SizedBox( width: 100, height: 20, @@ -77,4 +78,16 @@ Image newImage({double width = 1, double height = 1}) => Image.memory( height: height, ); +CustomImageWidget newCustomImage({double width = 1, double height = 1}) => + CustomImageWidget.memory( + testImageData, + width: width, + height: height, + ); + const dummyText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; + +class CustomImageWidget extends Image { + CustomImageWidget.memory(super.bytes, {super.key, super.width, super.height}) + : super.memory(); +} diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart index e5787431bd..ad76e9bfaa 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/replay/widget_filter_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -7,19 +6,22 @@ import 'package:sentry_flutter/src/replay/widget_filter.dart'; import 'test_widget.dart'; +// Note: these tests predate existance of `SentryMaskingConfig` which now +// takes care of the decision making whether something is masked or not. +// We'll keep these tests although they're not unit-tests anymore. void main() async { TestWidgetsFlutterBinding.ensureInitialized(); const defaultBounds = Rect.fromLTRB(0, 0, 1000, 1000); final rootBundle = TestAssetBundle(); final otherBundle = TestAssetBundle(); - final createSut = - ({bool redactImages = false, bool redactText = false}) => WidgetFilter( - logger: (level, message, {exception, logger, stackTrace}) {}, - redactImages: redactImages, - redactText: redactText, - rootAssetBundle: rootBundle, - ); + final createSut = ({bool redactImages = false, bool redactText = false}) { + final replayOptions = SentryReplayOptions(); + replayOptions.redactAllImages = redactImages; + replayOptions.redactAllText = redactText; + return WidgetFilter(replayOptions.buildMaskingConfig(), + (level, message, {exception, logger, stackTrace}) {}); + }; boundsRect(WidgetFilterItem item) => '${item.bounds.width.floor()}x${item.bounds.height.floor()}'; @@ -29,7 +31,7 @@ void main() async { final sut = createSut(redactText: true); final element = await pumpTestElement(tester); sut.obscure(element, 1.0, defaultBounds); - expect(sut.items.length, 4); + expect(sut.items.length, 5); }); testWidgets('does not redact text when disabled', (tester) async { @@ -51,11 +53,12 @@ void main() async { final sut = createSut(redactText: true); final element = await pumpTestElement(tester); sut.obscure(element, 1.0, defaultBounds); - expect(sut.items.length, 4); + expect(sut.items.length, 5); expect(boundsRect(sut.items[0]), '624x48'); expect(boundsRect(sut.items[1]), '169x20'); expect(boundsRect(sut.items[2]), '800x192'); - expect(boundsRect(sut.items[3]), '50x20'); + expect(boundsRect(sut.items[3]), '800x24'); + expect(boundsRect(sut.items[4]), '50x20'); }); }); @@ -75,20 +78,27 @@ void main() async { testWidgets( 'recognizes ${newAssetImage('').runtimeType} from the root bundle', (tester) async { - final sut = createSut(redactImages: true); - - expect(sut.isBuiltInAssetImage(newAssetImage('')), isTrue); - expect(sut.isBuiltInAssetImage(newAssetImage('', bundle: rootBundle)), + expect(WidgetFilter.isBuiltInAssetImage(newAssetImage(''), rootBundle), + isTrue); + expect( + WidgetFilter.isBuiltInAssetImage( + newAssetImage('', bundle: rootBundle), rootBundle), isTrue); - expect(sut.isBuiltInAssetImage(newAssetImage('', bundle: otherBundle)), + expect( + WidgetFilter.isBuiltInAssetImage( + newAssetImage('', bundle: otherBundle), rootBundle), isFalse); expect( - sut.isBuiltInAssetImage(newAssetImage('', - bundle: SentryAssetBundle(bundle: rootBundle))), + WidgetFilter.isBuiltInAssetImage( + newAssetImage('', + bundle: SentryAssetBundle(bundle: rootBundle)), + rootBundle), isTrue); expect( - sut.isBuiltInAssetImage(newAssetImage('', - bundle: SentryAssetBundle(bundle: otherBundle))), + WidgetFilter.isBuiltInAssetImage( + newAssetImage('', + bundle: SentryAssetBundle(bundle: otherBundle)), + rootBundle), isFalse); }); } @@ -118,6 +128,41 @@ void main() async { expect(boundsRect(sut.items[2]), '50x20'); }); }); + + testWidgets('respects $SentryMask', (tester) async { + final sut = createSut(redactText: false, redactImages: false); + final element = await pumpTestElement(tester, children: [ + SentryMask(Padding(padding: EdgeInsets.all(100), child: Text('foo'))), + ]); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 1); + expect(boundsRect(sut.items[0]), '344x248'); + }); + + testWidgets('respects $SentryUnmask', (tester) async { + final sut = createSut(redactText: true, redactImages: true); + final element = await pumpTestElement(tester, children: [ + SentryUnmask(Text('foo')), + SentryUnmask(newImage()), + SentryUnmask(SentryMask(Text('foo'))), + ]); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items, isEmpty); + }); + + testWidgets('obscureElementOrParent', (tester) async { + final sut = createSut(redactText: true); + final element = await pumpTestElement(tester, children: [ + Padding(padding: EdgeInsets.all(100), child: Text('foo')), + ]); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 1); + expect(boundsRect(sut.items[0]), '144x48'); + sut.throwInObscure = true; + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 1); + expect(boundsRect(sut.items[0]), '344x248'); + }); } class TestAssetBundle extends CachingAssetBundle {