diff --git a/packages/datadog_session_replay/example/golden_test/goldens/global_mask_all_icons.png b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_all_icons.png new file mode 100644 index 00000000..9370e6aa Binary files /dev/null and b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_all_icons.png differ diff --git a/packages/datadog_session_replay/example/golden_test/goldens/global_mask_non-asset_icons.png b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_non-asset_icons.png new file mode 100644 index 00000000..31f3e43e Binary files /dev/null and b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_non-asset_icons.png differ diff --git a/packages/datadog_session_replay/example/golden_test/goldens/global_mask_none_icons.png b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_none_icons.png new file mode 100644 index 00000000..30a58050 Binary files /dev/null and b/packages/datadog_session_replay/example/golden_test/goldens/global_mask_none_icons.png differ diff --git a/packages/datadog_session_replay/example/golden_test/goldens/override_mask_all_icons.png b/packages/datadog_session_replay/example/golden_test/goldens/override_mask_all_icons.png new file mode 100644 index 00000000..32ff6b6e Binary files /dev/null and b/packages/datadog_session_replay/example/golden_test/goldens/override_mask_all_icons.png differ diff --git a/packages/datadog_session_replay/example/golden_test/icon_masking_golden_test.dart b/packages/datadog_session_replay/example/golden_test/icon_masking_golden_test.dart new file mode 100644 index 00000000..e0db6eda --- /dev/null +++ b/packages/datadog_session_replay/example/golden_test/icon_masking_golden_test.dart @@ -0,0 +1,122 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +// ignore_for_file: invalid_use_of_visible_for_testing_member + +import 'package:datadog_common_test/datadog_common_test.dart'; +import 'package:datadog_session_replay/datadog_session_replay.dart'; +import 'package:datadog_session_replay/src/capture/capture_node.dart'; +import 'package:datadog_session_replay/src/capture/recorder.dart'; +import 'package:datadog_session_replay/src/datadog_session_replay_platform_interface.dart'; +import 'package:datadog_session_replay/src/rum_context.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'golden_test_helpers.dart'; +import 'mock_platform.dart'; + +void main() { + late SessionReplayRecorder recorder; + late RUMContext context; + late MockDatadogSessionReplayPlatform platform; + + setUp(() { + recorder = SessionReplayRecorder( + defaultCapturePrivacy: TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskNone, + ), + touchPrivacyLevel: TouchPrivacyLevel.show, + ); + platform = MockDatadogSessionReplayPlatform(); + DatadogSessionReplayPlatform.instance = platform; + + registerFallbackValue( + CapturedViewAttributes(paintBounds: Rect.zero, scaleX: 1.0, scaleY: 1.0), + ); + + context = RUMContext( + applicationId: randomString(), + sessionId: randomString(), + ); + recorder.updateContext(context); + }); + + tearDown(() { + platform.clearImages(); + }); + + Widget createIconFixture() { + return Padding( + padding: EdgeInsets.all(12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: const [ + Icon(Icons.favorite, color: Colors.red, size: 48), + Icon(Icons.home, color: Colors.blue, size: 48), + Icon(Icons.settings, size: 48), + ], + ), + ); + } + + testWidgets('global mask all icons', (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, + imagePrivacyLevel: ImagePrivacyLevel.maskAll, + ); + + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Mask All Icons')), + body: createIconFixture(), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('global mask none icons', (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, + imagePrivacyLevel: ImagePrivacyLevel.maskNone, + ); + + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Mask No Icons')), + body: createIconFixture(), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('global mask non-asset icons', (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, + imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, + ); + + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Mask Non-Asset Icons')), + body: createIconFixture(), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); + + testWidgets('override mask all icons', (tester) async { + final fixture = MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Override Mask All Icons')), + body: SessionReplayPrivacy( + imagePrivacyLevel: ImagePrivacyLevel.maskAll, + child: createIconFixture(), + ), + ), + ); + await snapshotTest(tester, recorder, fixture); + }); +} diff --git a/packages/datadog_session_replay/example/lib/screens/images_screen.dart b/packages/datadog_session_replay/example/lib/screens/images_screen.dart index a0fa44fe..79b22eb8 100644 --- a/packages/datadog_session_replay/example/lib/screens/images_screen.dart +++ b/packages/datadog_session_replay/example/lib/screens/images_screen.dart @@ -19,6 +19,17 @@ class _ImagesScreenState extends State { body: SingleChildScrollView( child: Column( children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: const [ + Icon(Icons.favorite, color: Colors.red, size: 32), + Icon(Icons.home, color: Colors.blue, size: 32), + Icon(Icons.settings, size: 32), + ], + ), + ), Image.asset('assets/dd_logo_v_rgb.png'), Image.network( 'https://placehold.co/200x200.png', diff --git a/packages/datadog_session_replay/lib/datadog_session_replay.dart b/packages/datadog_session_replay/lib/datadog_session_replay.dart index e10d7fe8..d8da17b0 100644 --- a/packages/datadog_session_replay/lib/datadog_session_replay.dart +++ b/packages/datadog_session_replay/lib/datadog_session_replay.dart @@ -193,6 +193,15 @@ class DatadogSessionReplayConfiguration { /// Defaults to approximately 800×800 decoded pixels. int maxImagePixelBudget; + /// Logical size (dp) at which icon glyphs are rasterized for session replay. + /// + /// The replay player scales the bitmap to the widget's on-screen bounds. + /// Smaller values reduce CPU/GPU work and memory; larger values improve + /// sharpness when icons are displayed big. + /// + /// Defaults to `20`. + double iconRasterLogicalSize; + DatadogSessionReplayConfiguration({ required this.replaySampleRate, this.textAndInputPrivacyLevel = TextAndInputPrivacyLevel.maskAll, @@ -203,6 +212,7 @@ class DatadogSessionReplayConfiguration { this.fontFamilyTransform = const FontFamilyTransformConfig(), this.imageDownscaling = ImageDownscaling.disabled, this.maxImagePixelBudget = defaultMaxImagePixelBudget, + this.iconRasterLogicalSize = 20.0, }); } diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/icon_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/icon_recorder.dart new file mode 100644 index 00000000..8644310a --- /dev/null +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/icon_recorder.dart @@ -0,0 +1,279 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import '../../../datadog_session_replay.dart'; +import '../../datadog_session_replay_platform_interface.dart'; +import '../capture_node.dart'; +import '../recorder.dart'; +import '../view_tree_snapshot.dart'; +import 'image_recorder.dart'; + +// Match [image_recorder.dart] placeholder caption threshold. +const int _iconLabelMinWidth = 125; + +@immutable +class _IconCacheKey { + final int codePoint; + final String fontFamily; + final String? fontPackage; + final int colorSignature; + + const _IconCacheKey({ + required this.codePoint, + required this.fontFamily, + required this.fontPackage, + required this.colorSignature, + }); + + @override + bool operator ==(Object other) { + return other is _IconCacheKey && + other.codePoint == codePoint && + other.fontFamily == fontFamily && + other.fontPackage == fontPackage && + other.colorSignature == colorSignature; + } + + @override + int get hashCode => Object.hash( + codePoint, + fontFamily, + fontPackage, + colorSignature, + ); +} + +class IconRecorder implements ElementRecorder { + final KeyGenerator keyGenerator; + final double iconRasterLogicalSize; + + final Map<_IconCacheKey, int> _resourceKeyCache = {}; + final Map<_IconCacheKey, Future> _inFlight = {}; + + IconRecorder( + this.keyGenerator, { + this.iconRasterLogicalSize = 20.0, + }); + + @override + bool accepts(Widget widget) => widget is Icon; + + @override + CaptureNodeSemantics? captureSemantics( + Element element, + CapturedViewAttributes attributes, + TreeCapturePrivacy capturePrivacy, + ) { + final widget = element.widget; + if (widget is! Icon) return null; + + final elementId = keyGenerator.keyForElement(element); + + if (capturePrivacy.imagePrivacyLevel == ImagePrivacyLevel.maskAll) { + return _iconPlaceholder(elementId, attributes); + } + + final iconData = widget.icon; + if (iconData == null) { + return _iconPlaceholder(elementId, attributes); + } + final theme = IconTheme.of(element); + final color = widget.color ?? theme.color ?? const Color(0xFF000000); + final fontFamily = iconData.fontFamily; + if (fontFamily == null || fontFamily.isEmpty) { + return _iconPlaceholder(elementId, attributes); + } + + final dpr = _devicePixelRatio(element); + final rasterLogicalSize = iconRasterLogicalSize; + final widthPx = math.max(1, (rasterLogicalSize * dpr).ceil()); + final heightPx = widthPx; + + final cacheKey = _IconCacheKey( + codePoint: iconData.codePoint, + fontFamily: fontFamily, + fontPackage: iconData.fontPackage, + colorSignature: color.hashCode, + ); + + final cachedKey = _resourceKeyCache[cacheKey]; + if (cachedKey != null) { + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [ + ResourceImageNode( + attributes, + wireframeId: elementId, + resourceKey: cachedKey, + ), + ], + ); + } + + final textDirection = widget.textDirection ?? + Directionality.maybeOf(element) ?? + TextDirection.ltr; + + return AdditionalProcessingElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + process: () => _ensureRasterized( + elementId: elementId, + attributes: attributes, + cacheKey: cacheKey, + iconData: iconData, + color: color, + widthPx: widthPx, + heightPx: heightPx, + textDirection: textDirection, + ), + ); + } + + Future _ensureRasterized({ + required int elementId, + required CapturedViewAttributes attributes, + required _IconCacheKey cacheKey, + required IconData iconData, + required Color color, + required int widthPx, + required int heightPx, + required TextDirection textDirection, + }) async { + final cached = _resourceKeyCache[cacheKey]; + if (cached != null) { + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [ + ResourceImageNode( + attributes, + wireframeId: elementId, + resourceKey: cached, + ), + ], + ); + } + + final future = _inFlight.putIfAbsent( + cacheKey, + () => _performRaster( + cacheKey: cacheKey, + iconData: iconData, + color: color, + widthPx: widthPx, + heightPx: heightPx, + textDirection: textDirection, + ).whenComplete(() { + _inFlight.remove(cacheKey); + }), + ); + + final resourceKey = await future; + if (resourceKey == null) { + return _iconPlaceholder(elementId, attributes); + } + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [ + ResourceImageNode( + attributes, + wireframeId: elementId, + resourceKey: resourceKey, + ), + ], + ); + } + + Future _performRaster({ + required _IconCacheKey cacheKey, + required IconData iconData, + required Color color, + required int widthPx, + required int heightPx, + required TextDirection textDirection, + }) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + final fontSize = widthPx.toDouble(); + + final textPainter = TextPainter( + text: TextSpan( + text: String.fromCharCode(iconData.codePoint), + style: TextStyle( + fontFamily: iconData.fontFamily, + package: iconData.fontPackage, + fontSize: fontSize, + height: 1.0, + color: color, + inherit: false, + ), + ), + textDirection: textDirection, + textScaler: TextScaler.noScaling, + )..layout(); + + textPainter.paint(canvas, Offset.zero); + textPainter.dispose(); + + final picture = recorder.endRecording(); + final ui.Image raster; + try { + raster = await picture.toImage(widthPx, heightPx); + } catch (_) { + return null; + } finally { + picture.dispose(); + } + + try { + final byteData = + await raster.toByteData(format: ui.ImageByteFormat.rawRgba); + if (byteData == null) return null; + + final resourceKey = keyGenerator.keyForImage(raster); + await DatadogSessionReplayPlatform.instance.saveImageForProcessing( + resourceKey, + raster.width, + raster.height, + byteData, + ); + _resourceKeyCache[cacheKey] = resourceKey; + return resourceKey; + } finally { + raster.dispose(); + } + } + + static SpecificElement _iconPlaceholder( + int elementId, + CapturedViewAttributes attributes, + ) { + return SpecificElement( + subtreeStrategy: CaptureNodeSubtreeStrategy.ignore, + nodes: [ + PlaceholderNode( + attributes, + wireframeId: elementId, + caption: 'Icon', + minWidth: _iconLabelMinWidth, + ), + ], + ); + } + + static double _devicePixelRatio(Element element) { + final view = View.maybeOf(element); + if (view != null) { + return view.devicePixelRatio; + } + final views = WidgetsBinding.instance.platformDispatcher.views; + if (views.isEmpty) return 1.0; + return views.first.devicePixelRatio; + } +} diff --git a/packages/datadog_session_replay/lib/src/capture/recorder.dart b/packages/datadog_session_replay/lib/src/capture/recorder.dart index f4352141..341426dd 100644 --- a/packages/datadog_session_replay/lib/src/capture/recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/recorder.dart @@ -20,6 +20,7 @@ import 'element_recorders/cupertino_widgets/cupertino_slider_recorder.dart'; import 'element_recorders/cupertino_widgets/cupertino_switch_recorder.dart'; import 'element_recorders/custom_paint_recorder.dart'; import 'element_recorders/editable_text_recorder.dart'; +import 'element_recorders/icon_recorder.dart'; import 'element_recorders/image_recorder.dart'; import 'element_recorders/material_widgets/checkbox_recorder.dart'; import 'element_recorders/material_widgets/radio_recorder.dart'; @@ -155,6 +156,7 @@ class SessionReplayRecorder { required TouchPrivacyLevel touchPrivacyLevel, ImageDownscaling imageDownscaling = ImageDownscaling.disabled, int maxImagePixelBudget = defaultMaxImagePixelBudget, + double iconRasterLogicalSize = 20.0, }) : this._( KeyGenerator(), timeProvider, @@ -162,6 +164,7 @@ class SessionReplayRecorder { touchPrivacyLevel, imageDownscaling, maxImagePixelBudget, + iconRasterLogicalSize, ); SessionReplayRecorder._( @@ -171,10 +174,15 @@ class SessionReplayRecorder { this._touchPrivacyLevel, ImageDownscaling imageDownscaling, int maxImagePixelBudget, + double iconRasterLogicalSize, ) { _elementRecorders.addAll([ ContainerRecorder(keyGenerator), TextElementRecorder(keyGenerator), + IconRecorder( + keyGenerator, + iconRasterLogicalSize: iconRasterLogicalSize, + ), EditableTextRecorder(keyGenerator), InputDecoratorRecorder(keyGenerator), ImageRecorder( @@ -271,20 +279,29 @@ class SessionReplayRecorder { final addedProcessingTimelineTask = TimelineTask() ..start('Datadog SR Capture Processing'); - // Process anything that needs additional processing + // Process anything that needs additional processing (parallelize so + // multiple icons/images don't serialize their async work). final nodes = []; - for (var s in capturedSemantics) { - try { + final resolved = await Future.wait( + capturedSemantics.map((s) async { if (s is AdditionalProcessingElement) { - s = await s.process(); + try { + return await s.process(); + } catch (e, st) { + DatadogSessionReplayPlatform.instance.telemetryError( + 'Exception during session replay capture: $e', + e.runtimeType.toString(), + st.toString(), + ); + return null; + } } + return s; + }), + ); + for (final s in resolved) { + if (s != null) { nodes.addAll(s.nodes); - } catch (e, st) { - DatadogSessionReplayPlatform.instance.telemetryError( - 'Exception during session replay capture: $e', - e.runtimeType.toString(), - st.toString(), - ); } } addedProcessingTimelineTask.finish(); diff --git a/packages/datadog_session_replay/lib/src/datadog_session_replay.dart b/packages/datadog_session_replay/lib/src/datadog_session_replay.dart index 7ccd5023..53620cb9 100644 --- a/packages/datadog_session_replay/lib/src/datadog_session_replay.dart +++ b/packages/datadog_session_replay/lib/src/datadog_session_replay.dart @@ -70,6 +70,7 @@ class DatadogSessionReplay { touchPrivacyLevel: _configuration.touchPrivacyLevel, imageDownscaling: _configuration.imageDownscaling, maxImagePixelBudget: _configuration.maxImagePixelBudget, + iconRasterLogicalSize: _configuration.iconRasterLogicalSize, ); void addElement(Key key, Element e) { diff --git a/packages/datadog_session_replay/test/capture/icon_recorder_test.dart b/packages/datadog_session_replay/test/capture/icon_recorder_test.dart new file mode 100644 index 00000000..c0ff478c --- /dev/null +++ b/packages/datadog_session_replay/test/capture/icon_recorder_test.dart @@ -0,0 +1,354 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:datadog_common_test/datadog_common_test.dart'; +import 'package:datadog_session_replay/datadog_session_replay.dart'; +import 'package:datadog_session_replay/src/capture/capture_node.dart' + show CapturedViewAttributes; +import 'package:datadog_session_replay/src/capture/element_recorders/common_nodes.dart'; +import 'package:datadog_session_replay/src/capture/element_recorders/icon_recorder.dart'; +import 'package:datadog_session_replay/src/capture/element_recorders/image_recorder.dart'; +import 'package:datadog_session_replay/src/capture/element_recorders/text_recorder.dart'; +import 'package:datadog_session_replay/src/capture/recorder.dart'; +import 'package:datadog_session_replay/src/datadog_session_replay_platform_interface.dart'; +import 'package:datadog_session_replay/src/rum_context.dart'; +import 'package:datadog_session_replay/src/sr_data_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'simple_test_capture.dart'; + +class MockDatadogSessionReplayPlatform extends Mock + with MockPlatformInterfaceMixin + implements DatadogSessionReplayPlatform {} + +void main() { + late MockDatadogSessionReplayPlatform platform; + late SessionReplayRecorder recorder; + late RUMContext context; + + setUp(() { + final keyGenerator = KeyGenerator(); + platform = MockDatadogSessionReplayPlatform(); + DatadogSessionReplayPlatform.instance = platform; + recorder = SessionReplayRecorder.withCustomRecorders( + [ + IconRecorder(keyGenerator), + TextElementRecorder(keyGenerator), + ], + defaultCapturePrivacy: TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskNone, + ), + touchPrivacyLevel: TouchPrivacyLevel.show, + ); + + registerFallbackValue( + CapturedViewAttributes(paintBounds: Rect.zero, scaleX: 1.0, scaleY: 1.0), + ); + registerFallbackValue(ByteData(1)); + + context = RUMContext( + applicationId: randomString(), + sessionId: randomString(), + ); + recorder.updateContext(context); + + when(() => platform.saveImageForProcessing(any(), any(), any(), any())) + .thenAnswer((_) => Future.value()); + when(() => platform.resourceIdForKey(any())).thenReturn('rid'); + }); + + testWidgets( + 'captures Icon as ResourceImageNode and saves bytes once for two identical icons', + (tester) async { + const iconSize = 32.0; + + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: const Stack( + children: [ + Positioned( + top: 10, + left: 10, + width: iconSize, + height: iconSize, + child: Icon(Icons.favorite, color: Colors.red, size: iconSize), + ), + Positioned( + top: 10, + left: 70, + width: iconSize, + height: iconSize, + child: Icon(Icons.favorite, color: Colors.red, size: iconSize), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture1; + await tester.runAsync(() async { + capture1 = await recorder.performCapture(); + }); + expect(capture1, isNotNull); + expect(capture1!.viewTreeSnapshot.nodes.length, 2); + expect( + capture1!.viewTreeSnapshot.nodes.every((n) => n is ResourceImageNode), + isTrue); + final key1 = + (capture1!.viewTreeSnapshot.nodes[0] as ResourceImageNode).resourceKey; + final key2 = + (capture1!.viewTreeSnapshot.nodes[1] as ResourceImageNode).resourceKey; + expect(key1, key2); + + final expectedPx = math.max( + 1, + (20.0 * tester.view.devicePixelRatio).ceil(), + ); + + CaptureResult? capture2; + await tester.runAsync(() async { + capture2 = await recorder.performCapture(); + }); + expect(capture2, isNotNull); + expect(capture2!.viewTreeSnapshot.nodes.length, 2); + expect( + (capture2!.viewTreeSnapshot.nodes[0] as ResourceImageNode).resourceKey, + key1); + expect( + (capture2!.viewTreeSnapshot.nodes[1] as ResourceImageNode).resourceKey, + key1); + + // One rasterization for two identical icons; second capture is cache-only. + verify( + () => platform.saveImageForProcessing( + key1, + expectedPx, + expectedPx, + any(), + ), + ).called(1); + }); + + testWidgets('different icon color triggers second saveImageForProcessing', + (tester) async { + const iconSize = 28.0; + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: Stack( + children: [ + Positioned( + top: 10, + left: 10, + width: iconSize, + height: iconSize, + child: + const Icon(Icons.star, color: Colors.amber, size: iconSize), + ), + Positioned( + top: 10, + left: 60, + width: iconSize, + height: iconSize, + child: + const Icon(Icons.star, color: Colors.green, size: iconSize), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + await tester.runAsync(() async { + await recorder.performCapture(); + }); + + verify( + () => platform.saveImageForProcessing(any(), any(), any(), any()), + ).called(2); + }); + + testWidgets( + 'same icon at two display sizes saves image once (canonical raster cache)', + (tester) async { + const smallSize = 20.0; + const largeSize = 32.0; + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: Stack( + children: [ + Positioned( + top: 10, + left: 10, + width: largeSize, + height: largeSize, + child: const Icon(Icons.favorite, + color: Colors.red, size: largeSize), + ), + Positioned( + top: 10, + left: 60, + width: smallSize, + height: smallSize, + child: const Icon(Icons.favorite, + color: Colors.red, size: smallSize), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture; + await tester.runAsync(() async { + capture = await recorder.performCapture(); + }); + expect(capture, isNotNull); + expect(capture!.viewTreeSnapshot.nodes.length, 2); + expect(capture!.viewTreeSnapshot.nodes.every((n) => n is ResourceImageNode), + isTrue); + final key1 = + (capture!.viewTreeSnapshot.nodes[0] as ResourceImageNode).resourceKey; + final key2 = + (capture!.viewTreeSnapshot.nodes[1] as ResourceImageNode).resourceKey; + expect(key1, key2); + + final expectedPx = math.max( + 1, + (20.0 * tester.view.devicePixelRatio).ceil(), + ); + verify( + () => platform.saveImageForProcessing( + key1, + expectedPx, + expectedPx, + any(), + ), + ).called(1); + }); + + testWidgets('maskAll shows Icon placeholder and does not save image', + (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskSensitiveInputs, + imagePrivacyLevel: ImagePrivacyLevel.maskAll, + ); + + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: Stack( + children: [ + Positioned( + top: 20, + left: 20, + width: 40, + height: 40, + child: const Icon(Icons.info, size: 40), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture; + await tester.runAsync(() async { + capture = await recorder.performCapture(); + }); + + expect(capture, isNotNull); + final wf = capture!.viewTreeSnapshot.nodes.single.buildWireframes().single; + expect(wf, isA()); + // Caption only when width >= PlaceholderNode minWidth (125); 40px is intentionally narrow. + expect((wf as SRPlaceholderWireframe).label, isNull); + verifyNever( + () => platform.saveImageForProcessing(any(), any(), any(), any())); + }); + + testWidgets('maskNonAssetsOnly still captures Material Icon', (tester) async { + recorder.defaultTreeCapturePrivacy = TreeCapturePrivacy( + textAndInputPrivacyLevel: TextAndInputPrivacyLevel.maskAll, + imagePrivacyLevel: ImagePrivacyLevel.maskNonAssetsOnly, + ); + + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: const Stack( + children: [ + Positioned( + top: 12, + left: 12, + width: 24, + height: 24, + child: Icon(Icons.check_circle, size: 24), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture; + await tester.runAsync(() async { + capture = await recorder.performCapture(); + }); + + expect(capture!.viewTreeSnapshot.nodes.single, isA()); + verify(() => platform.saveImageForProcessing(any(), any(), any(), any())) + .called(1); + }); + + testWidgets( + 'Icon subtree is ignored so inner RichText is not captured as text', + (tester) async { + final tree = MaterialApp( + home: SimpleTestCapture( + key: const Key('key'), + recorder: recorder, + child: const Stack( + children: [ + Positioned( + top: 8, + left: 8, + width: 48, + height: 48, + child: Icon(Icons.ac_unit, size: 48), + ), + ], + ), + ), + ); + await tester.pumpWidget(tree); + + CaptureResult? capture; + await tester.runAsync(() async { + capture = await recorder.performCapture(); + }); + + expect(capture, isNotNull); + expect(capture!.viewTreeSnapshot.nodes.length, 1); + expect(capture!.viewTreeSnapshot.nodes.single, isA()); + expect( + capture!.viewTreeSnapshot.nodes.whereType(), + isEmpty, + ); + }); +} diff --git a/packages/datadog_session_replay/test/datadog_session_replay_test.dart b/packages/datadog_session_replay/test/datadog_session_replay_test.dart index dc59d02f..d91d9da5 100644 --- a/packages/datadog_session_replay/test/datadog_session_replay_test.dart +++ b/packages/datadog_session_replay/test/datadog_session_replay_test.dart @@ -38,6 +38,12 @@ void main() { expect(c.imageDownscaling, ImageDownscaling.disabled); }); + test('DatadogSessionReplayConfiguration default iconRasterLogicalSize is 20', + () { + final c = DatadogSessionReplayConfiguration(replaySampleRate: 100.0); + expect(c.iconRasterLogicalSize, 20.0); + }); + group('DatadogSessionReplay', () { final mockPlatform = MockDatadogSessionReplayPlatform(); final mockInternalLogger = MockInternalLogger();