Skip to content

Commit

Permalink
feat: add wrapper support PostHogMaskWidget for Flutter widgets (#153)
Browse files Browse the repository at this point in the history
  • Loading branch information
thisames authored Jan 16, 2025
1 parent cd8522d commit a4f25ce
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 24 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
41 changes: 25 additions & 16 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -291,15 +298,15 @@ 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(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
child: const Text('Open route'),
child: const PostHogMaskWidget(child: Text('Open route')),
onPressed: () {
Navigator.push(
context,
Expand All @@ -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),
],
),
Expand Down
1 change: 1 addition & 0 deletions lib/posthog_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
28 changes: 26 additions & 2 deletions lib/src/replay/element_parsers/element_data.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import 'package:flutter/material.dart';
import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart';

class ElementData {
List<ElementData>? children;
Rect rect;
String type;
List<ElementData>? children;
Widget? widget;

ElementData({
this.children,
required this.rect,
required this.type,
this.children,
this.widget,
});

void addChildren(ElementData elementData) {
children ??= [];
children?.add(elementData);
}

List<Rect> extractMaskWidgetRects() {
final rects = <Rect>[];
_collectMaskWidgetRects(this, rects);
return rects;
}

List<ElementData> extractRects({bool isRoot = true}) {
List<ElementData> rects = [];

Expand All @@ -35,4 +44,19 @@ class ElementData {
}
return rects;
}

void _collectMaskWidgetRects(ElementData element, List<Rect> 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);
}
}
}
}
28 changes: 26 additions & 2 deletions lib/src/replay/element_parsers/element_object_parser.dart
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -20,6 +43,7 @@ class ElementObjectParser {
}
}
}

return null;
}
}
6 changes: 3 additions & 3 deletions lib/src/replay/element_parsers/element_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
18 changes: 18 additions & 0 deletions lib/src/replay/mask/image_mask_painter.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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(
Canvas canvas, List<ElementData> items, double pixelRatio) {
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,
Expand All @@ -15,4 +19,18 @@ class ImageMaskPainter {
canvas.drawRect(scaled, paint);
}
}

void drawMaskedImageWrapper(
Canvas canvas, List<Rect> 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);
}
}
}
26 changes: 25 additions & 1 deletion lib/src/replay/mask/posthog_mask_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class PostHogMaskController {
/// renderable elements.
///
List<ElementData>? getCurrentWidgetsElements() {
final BuildContext? context = containerKey.currentContext;
final context = containerKey.currentContext;

if (context == null) {
printIfDebug('Error: containerKey.currentContext is null.');
Expand All @@ -67,4 +67,28 @@ class PostHogMaskController {
return null;
}
}

List<Rect>? 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;
}
}
}
35 changes: 35 additions & 0 deletions lib/src/replay/mask/posthog_mask_widget.dart
Original file line number Diff line number Diff line change
@@ -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<PostHogMaskWidget> {
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,
);
}
}
9 changes: 9 additions & 0 deletions lib/src/replay/screenshot/screenshot_capturer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ class ScreenshotCapturer {

final replayConfig = _config.sessionReplayConfig;

final postHogWidgetWrapperElements =
PostHogMaskController.instance.getPostHogWidgetWrapperElements();

// call getCurrentScreenRects if really necessary
List<ElementData>? elementsDataWidgets;
if (replayConfig.maskAllTexts || replayConfig.maskAllImages) {
Expand Down Expand Up @@ -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 =
Expand Down

0 comments on commit a4f25ce

Please sign in to comment.