From fa684554d1bf84b9de32fd9095b3a8d6aa0680a1 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 25 Sep 2024 21:24:23 +0200 Subject: [PATCH] refactor: change redaction logic to custom filters --- flutter/lib/src/replay/recorder.dart | 7 +- flutter/lib/src/replay/widget_filter.dart | 75 ++++++++++++++------- flutter/lib/src/sentry_replay_options.dart | 68 ++++++++++++++++++- flutter/test/replay/widget_filter_test.dart | 39 ++++++----- 4 files changed, 140 insertions(+), 49 deletions(-) diff --git a/flutter/lib/src/replay/recorder.dart b/flutter/lib/src/replay/recorder.dart index 847c3a75f6..916fa778e4 100644 --- a/flutter/lib/src/replay/recorder.dart +++ b/flutter/lib/src/replay/recorder.dart @@ -22,11 +22,8 @@ class ScreenshotRecorder { 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); + if (replayOptions.maskingConfig.isNotEmpty) { + _widgetFilter = WidgetFilter(replayOptions.maskingConfig, options.logger); } } diff --git a/flutter/lib/src/replay/widget_filter.dart b/flutter/lib/src/replay/widget_filter.dart index 1f66a42f1a..0b5bf9cab7 100644 --- a/flutter/lib/src/replay/widget_filter.dart +++ b/flutter/lib/src/replay/widget_filter.dart @@ -1,5 +1,4 @@ import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -10,20 +9,13 @@ import '../sentry_asset_bundle.dart'; class WidgetFilter { final items = []; final SentryLogger logger; - final bool redactText; - final bool redactImages; + final Map 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; + WidgetFilter(this.config, this.logger); void obscure(BuildContext context, double pixelRatio, Rect bounds) { _pixelRatio = pixelRatio; @@ -55,21 +47,20 @@ class WidgetFilter { @pragma('vm:prefer-inline') bool _obscureIfNeeded(Element element, Widget widget) { - Color? color; + final maskingConfig = config[widget.runtimeType]; + if (maskingConfig == null) { + return false; + } else if (!maskingConfig.shouldMask(element, widget)) { + logger(SentryLevel.debug, "WidgetFilter skipping: $widget"); + return false; + } - if (redactText && widget is Text) { + Color? color; + if (widget is Text) { color = widget.style?.color; - } else if (redactText && widget is EditableText) { + } else if (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; - } - } + } else if (widget is Image) { color = widget.color; } else { // No other type is currently obscured. @@ -128,9 +119,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,8 +132,8 @@ class WidgetFilter { return false; } return (bundle == null || - bundle == _rootAssetBundle || - (bundle is SentryAssetBundle && bundle.bundle == _rootAssetBundle)); + bundle == rootAssetBundle || + (bundle is SentryAssetBundle && bundle.bundle == rootAssetBundle)); } @pragma('vm:prefer-inline') @@ -165,9 +157,40 @@ class WidgetFilter { } } +@internal class WidgetFilterItem { final Color color; final Rect bounds; const WidgetFilterItem(this.color, this.bounds); } + +@internal +class WidgetFilterMaskingConfig { + static const mask = WidgetFilterMaskingConfig._(1, 'mask'); + static const show = WidgetFilterMaskingConfig._(2, 'mask'); + + final int index; + final String _name; + final bool Function(Element, Widget)? _shouldMask; + + const WidgetFilterMaskingConfig._(this.index, this._name) + : _shouldMask = null; + const WidgetFilterMaskingConfig.custom(this._shouldMask) + : index = 3, + _name = 'custom'; + + @override + String toString() => "$WidgetFilterMaskingConfig.$_name"; + + bool shouldMask(Element element, Widget widget) { + switch (this) { + case mask: + return true; + case show: + return false; + default: + return _shouldMask!(element, widget); + } + } +} diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart index e52fbb2877..33b03f6001 100644 --- a/flutter/lib/src/sentry_replay_options.dart +++ b/flutter/lib/src/sentry_replay_options.dart @@ -1,7 +1,16 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; +import 'replay/widget_filter.dart'; + /// Configuration of the experimental replay feature. class SentryReplayOptions { + SentryReplayOptions() { + redactAllText = true; + redactAllImages = true; + } + double? _sessionSampleRate; /// A percentage of sessions in which a replay will be created. @@ -27,12 +36,67 @@ class SentryReplayOptions { /// Redact 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; + set redactAllText(bool value) { + if (value) { + mask(Text); + mask(EditableText); + } else { + unmask(Text); + unmask(EditableText); + } + } /// Redact all image content. 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; + set redactAllImages(bool value) { + if (value) { + maskIfTrue(Image, (element, widget) { + widget as Image; + if (widget.image is AssetBundleImageProvider) { + final image = widget.image as AssetBundleImageProvider; + if (WidgetFilter.isBuiltInAssetImage(image, rootBundle)) { + return false; + } + } + return true; + }); + } else { + unmask(Image); + } + } + + Map _maskingConfig = {}; + bool _finished = false; + + /// Once accessed, masking confing cannot change anymore. + @internal + Map get maskingConfig { + if (_finished) { + return _maskingConfig; + } + _finished = true; + final result = + Map.unmodifiable(_maskingConfig); + _maskingConfig = result; + return result; + } + + /// Mask given widget type in the replay. + void mask(Type type) { + _maskingConfig[type] = WidgetFilterMaskingConfig.mask; + } + + /// Unmask given widget type in the replay. + void unmask(Type type) { + _maskingConfig.remove(type); + } + + /// Unmask given widget type in the replay if it's masked by default rules + /// [redactAllText] or [redactAllImages]. + void maskIfTrue(Type type, bool Function(Element, Widget) shouldMask) { + _maskingConfig[type] = WidgetFilterMaskingConfig.custom(shouldMask); + } @internal bool get isEnabled => diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart index e5787431bd..9c440b3a41 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/replay/widget_filter_test.dart @@ -13,13 +13,13 @@ void main() async { 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.maskingConfig, + (level, message, {exception, logger, stackTrace}) {}); + }; boundsRect(WidgetFilterItem item) => '${item.bounds.width.floor()}x${item.bounds.height.floor()}'; @@ -75,20 +75,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); }); }