Skip to content

Commit a4f25ce

Browse files
authored
feat: add wrapper support PostHogMaskWidget for Flutter widgets (#153)
1 parent cd8522d commit a4f25ce

File tree

10 files changed

+170
-24
lines changed

10 files changed

+170
-24
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## Next
22

3+
- chore: add support for session replay manual masking with the PostHogMaskWidget widget ([#153](https://github.com/PostHog/posthog-flutter/pull/153))
4+
35
## 4.9.4
46

57
- fix: solve masks out of sync when moving too fast ([#147](https://github.com/PostHog/posthog-flutter/pull/147))

example/lib/main.dart

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ class _InitialScreenState extends State<_InitialScreen> {
8282
builder: (context) => const _FirstRoute()),
8383
);
8484
},
85-
child: const Text('Go to Second Route'),
85+
child: const PostHogMaskWidget(
86+
child: Text(
87+
'Go to Second Route',
88+
),
89+
),
8690
),
8791
const Padding(
8892
padding: EdgeInsets.all(8.0),
@@ -204,14 +208,16 @@ class _InitialScreenState extends State<_InitialScreen> {
204208
child: const Text("Flush"),
205209
),
206210
ElevatedButton(
207-
onPressed: () async {
208-
final result = await _posthogFlutterPlugin.getDistinctId();
209-
setState(() {
210-
_result = result;
211-
});
212-
},
213-
child: const Text("distinctId"),
214-
),
211+
onPressed: () async {
212+
final result =
213+
await _posthogFlutterPlugin.getDistinctId();
214+
setState(() {
215+
_result = result;
216+
});
217+
},
218+
child: const PostHogMaskWidget(
219+
child: Text("distinctId"),
220+
)),
215221
const Divider(),
216222
const Padding(
217223
padding: EdgeInsets.all(8.0),
@@ -254,7 +260,8 @@ class _InitialScreenState extends State<_InitialScreen> {
254260
onPressed: () async {
255261
await _posthogFlutterPlugin.reloadFeatureFlags();
256262
},
257-
child: const Text("reloadFeatureFlags"),
263+
child: const PostHogMaskWidget(
264+
child: Text("reloadFeatureFlags")),
258265
),
259266
const Divider(),
260267
const Padding(
@@ -291,15 +298,15 @@ class _FirstRouteState extends State<_FirstRoute> with WidgetsBindingObserver {
291298
Widget build(BuildContext context) {
292299
return Scaffold(
293300
appBar: AppBar(
294-
title: const Text('First Route'),
301+
title: const PostHogMaskWidget(child: Text('First Route')),
295302
),
296303
body: Center(
297304
child: RepaintBoundary(
298305
child: Column(
299306
mainAxisAlignment: MainAxisAlignment.center,
300307
children: [
301308
ElevatedButton(
302-
child: const Text('Open route'),
309+
child: const PostHogMaskWidget(child: Text('Open route')),
303310
onPressed: () {
304311
Navigator.push(
305312
context,
@@ -310,18 +317,20 @@ class _FirstRouteState extends State<_FirstRoute> with WidgetsBindingObserver {
310317
},
311318
),
312319
const SizedBox(height: 20),
313-
const TextField(
320+
const PostHogMaskWidget(
321+
child: TextField(
314322
decoration: InputDecoration(
315323
labelText: 'Sensitive Text Input',
316324
hintText: 'Enter sensitive data',
317325
border: OutlineInputBorder(),
318326
),
319-
),
327+
)),
320328
const SizedBox(height: 20),
321-
Image.asset(
329+
PostHogMaskWidget(
330+
child: Image.asset(
322331
'assets/training_posthog.png',
323332
height: 200,
324-
),
333+
)),
325334
const SizedBox(height: 20),
326335
],
327336
),

lib/posthog_flutter.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export 'src/posthog.dart';
44
export 'src/posthog_config.dart';
55
export 'src/posthog_observer.dart';
66
export 'src/posthog_widget_widget.dart';
7+
export 'src/replay/mask/posthog_mask_widget.dart';

lib/src/replay/element_parsers/element_data.dart

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
import 'package:flutter/material.dart';
2+
import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart';
23

34
class ElementData {
4-
List<ElementData>? children;
55
Rect rect;
66
String type;
7+
List<ElementData>? children;
8+
Widget? widget;
79

810
ElementData({
9-
this.children,
1011
required this.rect,
1112
required this.type,
13+
this.children,
14+
this.widget,
1215
});
1316

1417
void addChildren(ElementData elementData) {
1518
children ??= [];
1619
children?.add(elementData);
1720
}
1821

22+
List<Rect> extractMaskWidgetRects() {
23+
final rects = <Rect>[];
24+
_collectMaskWidgetRects(this, rects);
25+
return rects;
26+
}
27+
1928
List<ElementData> extractRects({bool isRoot = true}) {
2029
List<ElementData> rects = [];
2130

@@ -35,4 +44,19 @@ class ElementData {
3544
}
3645
return rects;
3746
}
47+
48+
void _collectMaskWidgetRects(ElementData element, List<Rect> rectList) {
49+
if (!rectList.contains(element.rect)) {
50+
if (element.widget is PostHogMaskWidget) {
51+
rectList.add(element.rect);
52+
}
53+
}
54+
55+
final children = element.children;
56+
if (children != null && children.isNotEmpty) {
57+
for (var child in children) {
58+
_collectMaskWidgetRects(child, rectList);
59+
}
60+
}
61+
}
3862
}

lib/src/replay/element_parsers/element_object_parser.dart

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
11
import 'package:flutter/cupertino.dart';
2+
import 'package:flutter/rendering.dart';
23
import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart';
4+
import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart';
35
import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart';
6+
import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart';
47

58
class ElementObjectParser {
9+
final ElementParser _elementParser = ElementParser();
10+
611
ElementData? relateRenderObject(
712
ElementData activeElementData,
813
Element element,
914
) {
10-
if (element.renderObject is RenderBox) {
11-
final String dataType = element.renderObject.runtimeType.toString();
15+
if (element.widget is PostHogMaskWidget) {
16+
final elementData = _elementParser.relate(element, activeElementData);
17+
18+
if (elementData != null) {
19+
activeElementData.addChildren(elementData);
20+
return elementData;
21+
}
22+
}
23+
24+
if (element.widget is Text) {
25+
final elementData = _elementParser.relate(element, activeElementData);
26+
27+
if (elementData != null) {
28+
activeElementData.addChildren(elementData);
29+
return elementData;
30+
}
31+
}
32+
33+
if (element.renderObject is RenderImage) {
34+
final dataType = element.renderObject.runtimeType.toString();
1235

1336
final parser = PostHogMaskController.instance.parsers[dataType];
1437
if (parser != null) {
@@ -20,6 +43,7 @@ class ElementObjectParser {
2043
}
2144
}
2245
}
46+
2347
return null;
2448
}
2549
}

lib/src/replay/element_parsers/element_parser.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ class ElementParser {
1515
}
1616

1717
final thisElementData = ElementData(
18-
type: element.widget.runtimeType.toString(),
19-
rect: elementRect,
20-
);
18+
type: element.widget.runtimeType.toString(),
19+
rect: elementRect,
20+
widget: element.widget);
2121

2222
return thisElementData;
2323
}

lib/src/replay/mask/image_mask_painter.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import 'package:flutter/material.dart';
22
import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart';
3+
import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart';
34

45
class ImageMaskPainter {
56
void drawMaskedImage(
67
Canvas canvas, List<ElementData> items, double pixelRatio) {
78
final paint = Paint()..style = PaintingStyle.fill;
89
for (var elementData in items) {
910
paint.color = Colors.black;
11+
if (elementData.widget is PostHogMaskWidget) {
12+
paint.color = Colors.black;
13+
}
1014
final scaled = Rect.fromLTRB(
1115
elementData.rect.left * pixelRatio,
1216
elementData.rect.top * pixelRatio,
@@ -15,4 +19,18 @@ class ImageMaskPainter {
1519
canvas.drawRect(scaled, paint);
1620
}
1721
}
22+
23+
void drawMaskedImageWrapper(
24+
Canvas canvas, List<Rect> items, double pixelRatio) {
25+
final paint = Paint()..style = PaintingStyle.fill;
26+
for (var rect in items) {
27+
paint.color = Colors.black;
28+
final scaled = Rect.fromLTRB(
29+
rect.left * pixelRatio,
30+
rect.top * pixelRatio,
31+
rect.right * pixelRatio,
32+
rect.bottom * pixelRatio);
33+
canvas.drawRect(scaled, paint);
34+
}
35+
}
1836
}

lib/src/replay/mask/posthog_mask_controller.dart

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class PostHogMaskController {
4545
/// renderable elements.
4646
///
4747
List<ElementData>? getCurrentWidgetsElements() {
48-
final BuildContext? context = containerKey.currentContext;
48+
final context = containerKey.currentContext;
4949

5050
if (context == null) {
5151
printIfDebug('Error: containerKey.currentContext is null.');
@@ -67,4 +67,28 @@ class PostHogMaskController {
6767
return null;
6868
}
6969
}
70+
71+
List<Rect>? getPostHogWidgetWrapperElements() {
72+
final context = containerKey.currentContext;
73+
74+
if (context == null) {
75+
printIfDebug('Error: containerKey.currentContext is null.');
76+
return null;
77+
}
78+
79+
try {
80+
final widgetElementsTree = _widgetScraper.parseRenderTree(context);
81+
82+
if (widgetElementsTree == null) {
83+
printIfDebug('Error: widgetElementsTree is null after parsing.');
84+
return null;
85+
}
86+
87+
return widgetElementsTree.extractMaskWidgetRects();
88+
} catch (e) {
89+
printIfDebug(
90+
'Error during render tree parsing or rectangle extraction: $e');
91+
return null;
92+
}
93+
}
7094
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'package:flutter/material.dart';
2+
3+
class PostHogMaskWidget extends StatefulWidget {
4+
final Widget child;
5+
6+
const PostHogMaskWidget({
7+
super.key,
8+
required this.child,
9+
});
10+
11+
@override
12+
PostHogMaskWidgetState createState() => PostHogMaskWidgetState();
13+
}
14+
15+
class PostHogMaskWidgetState extends State<PostHogMaskWidget> {
16+
final GlobalKey _widgetKey = GlobalKey();
17+
18+
@override
19+
void initState() {
20+
super.initState();
21+
}
22+
23+
@override
24+
void dispose() {
25+
super.dispose();
26+
}
27+
28+
@override
29+
Widget build(BuildContext context) {
30+
return Container(
31+
key: _widgetKey,
32+
child: widget.child,
33+
);
34+
}
35+
}

lib/src/replay/screenshot/screenshot_capturer.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ class ScreenshotCapturer {
9898

9999
final replayConfig = _config.sessionReplayConfig;
100100

101+
final postHogWidgetWrapperElements =
102+
PostHogMaskController.instance.getPostHogWidgetWrapperElements();
103+
101104
// call getCurrentScreenRects if really necessary
102105
List<ElementData>? elementsDataWidgets;
103106
if (replayConfig.maskAllTexts || replayConfig.maskAllImages) {
@@ -179,6 +182,12 @@ class ScreenshotCapturer {
179182
picture.dispose();
180183
}
181184
} else {
185+
if (postHogWidgetWrapperElements != null &&
186+
postHogWidgetWrapperElements.isNotEmpty) {
187+
_imageMaskPainter.drawMaskedImageWrapper(
188+
canvas, postHogWidgetWrapperElements, pixelRatio);
189+
}
190+
182191
final picture = recorder.endRecording();
183192

184193
final finalImage =

0 commit comments

Comments
 (0)