Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add wrapper support PostHogMaskWidget for Flutter widgets #153

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
});

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