From a4f25ce3d1c962b6dc6205d2e2dd43a612cde4a8 Mon Sep 17 00:00:00 2001 From: thisames Date: Thu, 16 Jan 2025 07:32:33 -0300 Subject: [PATCH] feat: add wrapper support PostHogMaskWidget for Flutter widgets (#153) --- CHANGELOG.md | 2 + example/lib/main.dart | 41 +++++++++++-------- lib/posthog_flutter.dart | 1 + .../replay/element_parsers/element_data.dart | 28 ++++++++++++- .../element_object_parser.dart | 28 ++++++++++++- .../element_parsers/element_parser.dart | 6 +-- lib/src/replay/mask/image_mask_painter.dart | 18 ++++++++ .../replay/mask/posthog_mask_controller.dart | 26 +++++++++++- lib/src/replay/mask/posthog_mask_widget.dart | 35 ++++++++++++++++ .../screenshot/screenshot_capturer.dart | 9 ++++ 10 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 lib/src/replay/mask/posthog_mask_widget.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4608ca1..d5b53eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- chore: add support for session replay manual masking with the PostHogMaskWidget widget ([#153](https://github.com/PostHog/posthog-flutter/pull/153)) + ## 4.9.4 - fix: solve masks out of sync when moving too fast ([#147](https://github.com/PostHog/posthog-flutter/pull/147)) diff --git a/example/lib/main.dart b/example/lib/main.dart index c96f7c0..9ad46bb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -82,7 +82,11 @@ class _InitialScreenState extends State<_InitialScreen> { builder: (context) => const _FirstRoute()), ); }, - child: const Text('Go to Second Route'), + child: const PostHogMaskWidget( + child: Text( + 'Go to Second Route', + ), + ), ), const Padding( padding: EdgeInsets.all(8.0), @@ -204,14 +208,16 @@ class _InitialScreenState extends State<_InitialScreen> { child: const Text("Flush"), ), ElevatedButton( - onPressed: () async { - final result = await _posthogFlutterPlugin.getDistinctId(); - setState(() { - _result = result; - }); - }, - child: const Text("distinctId"), - ), + onPressed: () async { + final result = + await _posthogFlutterPlugin.getDistinctId(); + setState(() { + _result = result; + }); + }, + child: const PostHogMaskWidget( + child: Text("distinctId"), + )), const Divider(), const Padding( padding: EdgeInsets.all(8.0), @@ -254,7 +260,8 @@ class _InitialScreenState extends State<_InitialScreen> { onPressed: () async { await _posthogFlutterPlugin.reloadFeatureFlags(); }, - child: const Text("reloadFeatureFlags"), + child: const PostHogMaskWidget( + child: Text("reloadFeatureFlags")), ), const Divider(), const Padding( @@ -291,7 +298,7 @@ class _FirstRouteState extends State<_FirstRoute> with WidgetsBindingObserver { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('First Route'), + title: const PostHogMaskWidget(child: Text('First Route')), ), body: Center( child: RepaintBoundary( @@ -299,7 +306,7 @@ class _FirstRouteState extends State<_FirstRoute> with WidgetsBindingObserver { mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( - child: const Text('Open route'), + child: const PostHogMaskWidget(child: Text('Open route')), onPressed: () { Navigator.push( context, @@ -310,18 +317,20 @@ class _FirstRouteState extends State<_FirstRoute> with WidgetsBindingObserver { }, ), const SizedBox(height: 20), - const TextField( + const PostHogMaskWidget( + child: TextField( decoration: InputDecoration( labelText: 'Sensitive Text Input', hintText: 'Enter sensitive data', border: OutlineInputBorder(), ), - ), + )), const SizedBox(height: 20), - Image.asset( + PostHogMaskWidget( + child: Image.asset( 'assets/training_posthog.png', height: 200, - ), + )), const SizedBox(height: 20), ], ), diff --git a/lib/posthog_flutter.dart b/lib/posthog_flutter.dart index 8a6da67..7b7d9f7 100644 --- a/lib/posthog_flutter.dart +++ b/lib/posthog_flutter.dart @@ -4,3 +4,4 @@ export 'src/posthog.dart'; export 'src/posthog_config.dart'; export 'src/posthog_observer.dart'; export 'src/posthog_widget_widget.dart'; +export 'src/replay/mask/posthog_mask_widget.dart'; diff --git a/lib/src/replay/element_parsers/element_data.dart b/lib/src/replay/element_parsers/element_data.dart index c65f5dc..5485234 100644 --- a/lib/src/replay/element_parsers/element_data.dart +++ b/lib/src/replay/element_parsers/element_data.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart'; class ElementData { - List? children; Rect rect; String type; + List? children; + Widget? widget; ElementData({ - this.children, required this.rect, required this.type, + this.children, + this.widget, }); void addChildren(ElementData elementData) { @@ -16,6 +19,12 @@ class ElementData { children?.add(elementData); } + List extractMaskWidgetRects() { + final rects = []; + _collectMaskWidgetRects(this, rects); + return rects; + } + List extractRects({bool isRoot = true}) { List rects = []; @@ -35,4 +44,19 @@ class ElementData { } return rects; } + + void _collectMaskWidgetRects(ElementData element, List rectList) { + if (!rectList.contains(element.rect)) { + if (element.widget is PostHogMaskWidget) { + rectList.add(element.rect); + } + } + + final children = element.children; + if (children != null && children.isNotEmpty) { + for (var child in children) { + _collectMaskWidgetRects(child, rectList); + } + } + } } diff --git a/lib/src/replay/element_parsers/element_object_parser.dart b/lib/src/replay/element_parsers/element_object_parser.dart index 45a8d48..3110387 100644 --- a/lib/src/replay/element_parsers/element_object_parser.dart +++ b/lib/src/replay/element_parsers/element_object_parser.dart @@ -1,14 +1,37 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart'; class ElementObjectParser { + final ElementParser _elementParser = ElementParser(); + ElementData? relateRenderObject( ElementData activeElementData, Element element, ) { - if (element.renderObject is RenderBox) { - final String dataType = element.renderObject.runtimeType.toString(); + if (element.widget is PostHogMaskWidget) { + final elementData = _elementParser.relate(element, activeElementData); + + if (elementData != null) { + activeElementData.addChildren(elementData); + return elementData; + } + } + + if (element.widget is Text) { + final elementData = _elementParser.relate(element, activeElementData); + + if (elementData != null) { + activeElementData.addChildren(elementData); + return elementData; + } + } + + if (element.renderObject is RenderImage) { + final dataType = element.renderObject.runtimeType.toString(); final parser = PostHogMaskController.instance.parsers[dataType]; if (parser != null) { @@ -20,6 +43,7 @@ class ElementObjectParser { } } } + return null; } } diff --git a/lib/src/replay/element_parsers/element_parser.dart b/lib/src/replay/element_parsers/element_parser.dart index 92fa44b..f09ed63 100644 --- a/lib/src/replay/element_parsers/element_parser.dart +++ b/lib/src/replay/element_parsers/element_parser.dart @@ -15,9 +15,9 @@ class ElementParser { } final thisElementData = ElementData( - type: element.widget.runtimeType.toString(), - rect: elementRect, - ); + type: element.widget.runtimeType.toString(), + rect: elementRect, + widget: element.widget); return thisElementData; } diff --git a/lib/src/replay/mask/image_mask_painter.dart b/lib/src/replay/mask/image_mask_painter.dart index 506fe7b..fd55373 100644 --- a/lib/src/replay/mask/image_mask_painter.dart +++ b/lib/src/replay/mask/image_mask_painter.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart'; class ImageMaskPainter { void drawMaskedImage( @@ -7,6 +8,9 @@ class ImageMaskPainter { final paint = Paint()..style = PaintingStyle.fill; for (var elementData in items) { paint.color = Colors.black; + if (elementData.widget is PostHogMaskWidget) { + paint.color = Colors.black; + } final scaled = Rect.fromLTRB( elementData.rect.left * pixelRatio, elementData.rect.top * pixelRatio, @@ -15,4 +19,18 @@ class ImageMaskPainter { canvas.drawRect(scaled, paint); } } + + void drawMaskedImageWrapper( + Canvas canvas, List items, double pixelRatio) { + final paint = Paint()..style = PaintingStyle.fill; + for (var rect in items) { + paint.color = Colors.black; + final scaled = Rect.fromLTRB( + rect.left * pixelRatio, + rect.top * pixelRatio, + rect.right * pixelRatio, + rect.bottom * pixelRatio); + canvas.drawRect(scaled, paint); + } + } } diff --git a/lib/src/replay/mask/posthog_mask_controller.dart b/lib/src/replay/mask/posthog_mask_controller.dart index 93dd037..7fd8e37 100644 --- a/lib/src/replay/mask/posthog_mask_controller.dart +++ b/lib/src/replay/mask/posthog_mask_controller.dart @@ -45,7 +45,7 @@ class PostHogMaskController { /// renderable elements. /// List? getCurrentWidgetsElements() { - final BuildContext? context = containerKey.currentContext; + final context = containerKey.currentContext; if (context == null) { printIfDebug('Error: containerKey.currentContext is null.'); @@ -67,4 +67,28 @@ class PostHogMaskController { return null; } } + + List? getPostHogWidgetWrapperElements() { + final context = containerKey.currentContext; + + if (context == null) { + printIfDebug('Error: containerKey.currentContext is null.'); + return null; + } + + try { + final widgetElementsTree = _widgetScraper.parseRenderTree(context); + + if (widgetElementsTree == null) { + printIfDebug('Error: widgetElementsTree is null after parsing.'); + return null; + } + + return widgetElementsTree.extractMaskWidgetRects(); + } catch (e) { + printIfDebug( + 'Error during render tree parsing or rectangle extraction: $e'); + return null; + } + } } diff --git a/lib/src/replay/mask/posthog_mask_widget.dart b/lib/src/replay/mask/posthog_mask_widget.dart new file mode 100644 index 0000000..25ec21c --- /dev/null +++ b/lib/src/replay/mask/posthog_mask_widget.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class PostHogMaskWidget extends StatefulWidget { + final Widget child; + + const PostHogMaskWidget({ + super.key, + required this.child, + }); + + @override + PostHogMaskWidgetState createState() => PostHogMaskWidgetState(); +} + +class PostHogMaskWidgetState extends State { + final GlobalKey _widgetKey = GlobalKey(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + key: _widgetKey, + child: widget.child, + ); + } +} diff --git a/lib/src/replay/screenshot/screenshot_capturer.dart b/lib/src/replay/screenshot/screenshot_capturer.dart index 3be4086..ec6bdd2 100644 --- a/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/lib/src/replay/screenshot/screenshot_capturer.dart @@ -98,6 +98,9 @@ class ScreenshotCapturer { final replayConfig = _config.sessionReplayConfig; + final postHogWidgetWrapperElements = + PostHogMaskController.instance.getPostHogWidgetWrapperElements(); + // call getCurrentScreenRects if really necessary List? elementsDataWidgets; if (replayConfig.maskAllTexts || replayConfig.maskAllImages) { @@ -179,6 +182,12 @@ class ScreenshotCapturer { picture.dispose(); } } else { + if (postHogWidgetWrapperElements != null && + postHogWidgetWrapperElements.isNotEmpty) { + _imageMaskPainter.drawMaskedImageWrapper( + canvas, postHogWidgetWrapperElements, pixelRatio); + } + final picture = recorder.endRecording(); final finalImage =