From 9a6d0e9ef77d6171155ba19c32f5d420727c98b2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 11 Sep 2026 12:27:46 +0200 Subject: [PATCH 1/5] feat(replay): add selective unmasking and protect sensitive inputs --- .../selective-unmask-sensitive-inputs.md | 5 + api/posthog_flutter.api.json | 65 +++ posthog_flutter/lib/posthog_flutter.dart | 1 + posthog_flutter/lib/src/posthog_config.dart | 11 +- .../replay/element_parsers/element_data.dart | 12 +- .../element_object_parser.dart | 58 ++- .../replay/mask/posthog_unmask_widget.dart | 29 ++ .../src/replay/mask/sensitive_text_input.dart | 37 ++ .../replay/mask/widget_elements_decipher.dart | 13 +- .../test/selective_masking_test.dart | 392 ++++++++++++++++++ .../test/web_canvas_mask_provider_test.dart | 56 +++ 11 files changed, 638 insertions(+), 41 deletions(-) create mode 100644 .changeset/selective-unmask-sensitive-inputs.md create mode 100644 posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart create mode 100644 posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart create mode 100644 posthog_flutter/test/selective_masking_test.dart diff --git a/.changeset/selective-unmask-sensitive-inputs.md b/.changeset/selective-unmask-sensitive-inputs.md new file mode 100644 index 00000000..de0dd116 --- /dev/null +++ b/.changeset/selective-unmask-sensitive-inputs.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Add `PostHogUnmaskWidget` to selectively reveal known-safe Flutter text and images while keeping global session replay masking enabled. Explicit masks and sensitive inputs take precedence regardless of nesting. Password, card number/security code, and one-time-code autofill hints, password keyboard types, and obscured fields now stay masked across Material, Cupertino, and direct `EditableText` inputs even when global text masking is disabled. Flutter web still requires canvas masking to be enabled; native platform views and captured native screens are unaffected. diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index eebaaec2..def5848e 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -5266,6 +5266,71 @@ ], "typeParameterNames": [] }, + { + "entryPoints": [ + "posthog_flutter.dart" + ], + "executableDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "new", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "key", + "relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart", + "typeName": "Key?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "child", + "relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart", + "typeName": "Widget" + } + ], + "relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart", + "returnTypeName": "PostHogUnmaskWidget", + "type": "constructor", + "typeParameterNames": [] + } + ], + "fieldDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "child", + "relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart", + "typeName": "Widget" + } + ], + "isDeprecated": false, + "isExperimental": false, + "isRequired": false, + "isSealed": false, + "name": "PostHogUnmaskWidget", + "relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart", + "superTypeNames": [ + "StatelessWidget", + "Widget", + "DiagnosticableTree", + "Object", + "Diagnosticable" + ], + "typeParameterNames": [] + }, { "entryPoints": [ "posthog_flutter.dart" diff --git a/posthog_flutter/lib/posthog_flutter.dart b/posthog_flutter/lib/posthog_flutter.dart index 2b5c439b..85a6a502 100644 --- a/posthog_flutter/lib/posthog_flutter.dart +++ b/posthog_flutter/lib/posthog_flutter.dart @@ -11,4 +11,5 @@ export 'src/posthog_event.dart'; export 'src/posthog_observer.dart'; export 'src/posthog_widget.dart'; export 'src/replay/mask/posthog_mask_widget.dart'; +export 'src/replay/mask/posthog_unmask_widget.dart'; export 'src/replay/mask/posthog_platform_view.dart'; diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index ca7ca9db..29753da7 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -682,7 +682,13 @@ class PostHogSessionReplayConfig { PostHogSessionReplayConfig(); /// Enable masking of all text and text input fields. - /// Default: true. + /// Default: true. Wrap known-safe Flutter content in `PostHogUnmaskWidget` + /// to reveal it without disabling masking globally. + /// + /// Sensitive Flutter inputs stay masked regardless of this flag or unmask + /// widgets: `obscureText`, `TextInputType.visiblePassword`, and autofill hints + /// for passwords, new passwords, credit card numbers/security codes, and + /// one-time codes. Explicit `PostHogMaskWidget` masks also always apply. /// /// With [captureNativeScreens] enabled, setting this false also unmasks text /// on captured native screens, including native input fields (passwords, @@ -690,7 +696,8 @@ class PostHogSessionReplayConfig { var maskAllTexts = true; /// Enable masking of all images. - /// Default: true. + /// Default: true. `PostHogUnmaskWidget` can reveal known-safe Flutter images; + /// explicit `PostHogMaskWidget` masks still take precedence. var maskAllImages = true; /// Deprecated setter that forwards assigned values to [throttleDelay]. diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_data.dart b/posthog_flutter/lib/src/replay/element_parsers/element_data.dart index 6a3ab182..27798985 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_data.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_data.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart'; +import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart'; class ElementData { Rect rect; @@ -7,6 +8,7 @@ class ElementData { List? children; Widget? widget; Matrix4? transform; + bool isSensitiveText; ElementData({ required this.rect, @@ -14,6 +16,7 @@ class ElementData { this.children, this.widget, this.transform, + this.isSensitiveText = false, }); void addChildren(ElementData elementData) { @@ -43,13 +46,10 @@ class ElementData { void _collectMaskWidgetElements( ElementData element, List elements) { - if (element.widget is PostHogMaskWidget) { + if (element.widget is PostHogMaskWidget || + element.isSensitiveText || + isSensitiveTextInput(element.widget)) { elements.add(element); - } else if (element.widget is TextField) { - final textField = element.widget as TextField; - if (textField.obscureText) { - elements.add(element); - } } final children = element.children; diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart index 25889ff7..6bf6bf98 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart @@ -3,16 +3,22 @@ import 'package:flutter/rendering.dart'; import 'package:posthog_flutter/posthog_flutter.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/element_parsers/render_editable_parser.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; +import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart'; class ElementObjectParser { final ElementParser _elementParser = ElementParser(); + final RenderEditableParser _renderEditableParser = RenderEditableParser(); ElementData? relateRenderObject( ElementData activeElementData, - Element element, - ) { - if (element.widget is PostHogMaskWidget) { + Element element, { + bool unmask = false, + bool sensitiveText = false, + }) { + if (element.widget is PostHogMaskWidget || + isSensitiveTextInput(element.widget)) { final elementData = _elementParser.relate(element); if (elementData != null) { @@ -21,40 +27,26 @@ class ElementObjectParser { } } - if (element.widget is Text) { - final config = Posthog().config?.sessionReplayConfig; - final maskAllTexts = config?.maskAllTexts ?? true; - - if (maskAllTexts) { - final elementData = _elementParser.relate(element); - - if (elementData != null) { - activeElementData.addChildren(elementData); - return elementData; - } + // Dense/scaled inputs can paint beyond their widget bounds. Preserve the + // RenderEditable mask as part of the sensitivity floor, even when unmasked. + if (sensitiveText && + element is RenderObjectElement && + element.renderObject is RenderEditable) { + final elementData = _renderEditableParser.relate(element); + if (elementData != null) { + elementData.isSensitiveText = true; + activeElementData.addChildren(elementData); + return elementData; } } - // Handle TextField and TextFormField masking - // Only mask at widget level for obscureText fields when maskAllTexts is false - // When maskAllTexts is true, RenderEditable detection will handle it with better bounds - if (element.widget is TextField || element.widget is TextFormField) { + if (unmask) return null; + + if (element.widget is Text) { final config = Posthog().config?.sessionReplayConfig; final maskAllTexts = config?.maskAllTexts ?? true; - var isObscured = false; - if (element.widget is TextField) { - isObscured = (element.widget as TextField).obscureText; - } - - // Note: TextFormField obscureText is handled differently in Flutter. - // TextFormField creates an internal TextField, but the obscureText property - // is not directly accessible on the TextFormField widget itself. - // For TextFormField, we rely on the maskAllTexts configuration. - // Otherwise, let RenderEditable handle it (it has better bounds via preferredLineHeight) - final shouldMask = !maskAllTexts && isObscured; - - if (shouldMask) { + if (maskAllTexts) { final elementData = _elementParser.relate(element); if (elementData != null) { @@ -64,6 +56,10 @@ class ElementObjectParser { } } + // Component elements can forward a descendant's render object before an + // intervening unmask widget has been visited. Match only its owning element. + if (element is! RenderObjectElement) return null; + if (element.renderObject is RenderImage) { final dataType = element.renderObject.runtimeType.toString(); diff --git a/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart new file mode 100644 index 00000000..046859f2 --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; + +/// Reveals a widget subtree in session replay despite global text/image masking. +/// +/// Keep `maskAllTexts` / `maskAllImages` enabled and reveal only known-safe UI: +/// +/// ```dart +/// PostHogUnmaskWidget(child: Text('Try again')) +/// ``` +/// +/// Only wrap content known to be safe. Explicit `PostHogMaskWidget` masks and +/// sensitive text inputs always take precedence, regardless of nesting order. +/// This does not erase masks from ancestors or overlapping widgets, reveal +/// native platform views, or change masking on captured native screens. +/// +/// On Flutter web, canvas masking must already be enabled through +/// `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`, or by +/// mounting a `PostHogMaskWidget`. This widget does not enable canvas recording +/// or masking itself. Keep it inside `PostHogWidget` on all platforms. +class PostHogUnmaskWidget extends StatelessWidget { + /// The known-safe widget subtree to reveal in session replay. + final Widget child; + + /// Creates an exception to global text/image masking around [child]. + const PostHogUnmaskWidget({super.key, required this.child}); + + @override + Widget build(BuildContext context) => child; +} diff --git a/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart b/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart new file mode 100644 index 00000000..f8ffbb1d --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart @@ -0,0 +1,37 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +const _sensitiveAutofillHints = { + AutofillHints.password, + AutofillHints.newPassword, + AutofillHints.creditCardNumber, + AutofillHints.creditCardSecurityCode, + AutofillHints.oneTimeCode, +}; + +bool isSensitiveTextInput(Widget? widget) { + final bool obscureText; + final TextInputType? keyboardType; + final Iterable? autofillHints; + if (widget is EditableText) { + obscureText = widget.obscureText; + keyboardType = widget.keyboardType; + autofillHints = widget.autofillHints; + } else if (widget is TextField) { + obscureText = widget.obscureText; + keyboardType = widget.keyboardType; + autofillHints = widget.autofillHints; + } else if (widget is CupertinoTextField) { + // Cupertino passes autofill hints through its AutofillClient, not through + // the nested EditableText's autofillHints. + obscureText = widget.obscureText; + keyboardType = widget.keyboardType; + autofillHints = widget.autofillHints; + } else { + return false; + } + + return obscureText || + keyboardType == TextInputType.visiblePassword || + (autofillHints?.any(_sensitiveAutofillHints.contains) ?? false); +} diff --git a/posthog_flutter/lib/src/replay/mask/widget_elements_decipher.dart b/posthog_flutter/lib/src/replay/mask/widget_elements_decipher.dart index 510aa037..e1122dc0 100644 --- a/posthog_flutter/lib/src/replay/mask/widget_elements_decipher.dart +++ b/posthog_flutter/lib/src/replay/mask/widget_elements_decipher.dart @@ -3,6 +3,8 @@ import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_data_factory.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_object_parser.dart'; import 'package:posthog_flutter/src/replay/element_parsers/root_element_provider.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_unmask_widget.dart'; +import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart'; class WidgetElementsDecipher { late ElementData _rootElementData; @@ -36,14 +38,21 @@ class WidgetElementsDecipher { return _rootElementData; } - void _parseAllElements(ElementData activeElementData, Element element) { + void _parseAllElements(ElementData activeElementData, Element element, + {bool unmask = false, bool sensitiveText = false}) { + final unmaskSubtree = unmask || element.widget is PostHogUnmaskWidget; + final sensitiveSubtree = + sensitiveText || isSensitiveTextInput(element.widget); ElementData? newElementData = _elementObjectParser.relateRenderObject( activeElementData, element, + unmask: unmaskSubtree, + sensitiveText: sensitiveSubtree, ); element.debugVisitOnstageChildren((childElement) { - _parseAllElements(newElementData ?? activeElementData, childElement); + _parseAllElements(newElementData ?? activeElementData, childElement, + unmask: unmaskSubtree, sensitiveText: sensitiveSubtree); }); } } diff --git a/posthog_flutter/test/selective_masking_test.dart b/posthog_flutter/test/selective_masking_test.dart new file mode 100644 index 00000000..ecc2ac23 --- /dev/null +++ b/posthog_flutter/test/selective_masking_test.dart @@ -0,0 +1,392 @@ +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; +import 'package:posthog_flutter/src/replay/mask/image_mask_painter.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +Future _setup( + {bool maskAllTexts = true, bool maskAllImages = true}) async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterPlatformFake(); + final config = PostHogConfig('test_project_token'); + config.sessionReplayConfig + ..maskAllTexts = maskAllTexts + ..maskAllImages = maskAllImages; + await Posthog().setup(config); + PostHogMaskController.instance.refreshParsers(config.sessionReplayConfig); +} + +Future _pump(WidgetTester tester, Widget child) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: RepaintBoundary( + key: PostHogMaskController.instance.containerKey, + child: SizedBox(width: 400, child: child), + ), + ), + )); +} + +List _masks() { + final config = Posthog().config!.sessionReplayConfig; + return PostHogMaskController.instance.getMaskElements( + includeAllWidgets: config.maskAllTexts || config.maskAllImages, + )!; +} + +Rect _rect(ElementData element) => MatrixUtils.transformRect( + element.transform ?? Matrix4.identity(), element.rect); + +bool _isMasked(Rect target) => _masks().any((e) => _rect(e).overlaps(target)); + +class _SafeLabel extends StatelessWidget { + const _SafeLabel(); + + @override + Widget build(BuildContext context) => const PostHogUnmaskWidget( + child: Text('safe label'), + ); +} + +void main() { + tearDown(() async { + PostHogMaskController.instance.refreshParsers(null); + await Posthog().close(); + }); + + for (final maskAllImages in [false, true]) { + for (final signal in [ + 'obscureText', + 'visiblePassword', + AutofillHints.password, + AutofillHints.newPassword, + AutofillHints.creditCardNumber, + AutofillHints.creditCardSecurityCode, + AutofillHints.oneTimeCode, + ]) { + for (final kind in ['Material', 'Form', 'Cupertino', 'Editable']) { + testWidgets( + '$kind $signal stays masked with texts=false, images=$maskAllImages', + (tester) async { + await _setup(maskAllTexts: false, maskAllImages: maskAllImages); + final controller = TextEditingController(text: '4111111111111111'); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + final obscure = signal == 'obscureText'; + final keyboardType = signal == 'visiblePassword' + ? TextInputType.visiblePassword + : TextInputType.text; + final hints = signal == 'obscureText' || signal == 'visiblePassword' + ? null + : [signal]; + final Widget field; + switch (kind) { + case 'Material': + field = TextField( + controller: controller, + obscureText: obscure, + keyboardType: keyboardType, + autofillHints: hints); + case 'Form': + field = TextFormField( + controller: controller, + obscureText: obscure, + keyboardType: keyboardType, + autofillHints: hints); + case 'Cupertino': + field = CupertinoTextField( + controller: controller, + obscureText: obscure, + keyboardType: keyboardType, + autofillHints: hints); + default: + field = EditableText( + controller: controller, + focusNode: focusNode, + style: const TextStyle(fontSize: 16), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + obscureText: obscure, + keyboardType: keyboardType, + autofillHints: hints); + } + await _pump(tester, field); + final editable = tester.renderObject( + find.byElementPredicate((element) => + element is RenderObjectElement && + element.renderObject is RenderEditable)); + final target = MatrixUtils.transformRect( + editable.getTransformTo(PostHogMaskController + .instance.containerKey.currentContext! + .findRenderObject()), + Rect.fromLTWH( + 0, 0, editable.size.width, editable.preferredLineHeight)); + expect( + _masks().any((e) => + _rect(e).contains(target.topLeft) && + _rect(e).right >= target.right && + _rect(e).bottom >= target.bottom), + isTrue); + }); + } + } + } + + testWidgets( + 'unmasks text, rich text, selectable text and ordinary inputs only in its subtree', + (tester) async { + await _setup(); + await _pump( + tester, + Column(children: [ + const Text('private sibling'), + PostHogUnmaskWidget( + child: Column(children: [ + const Text('safe text'), + RichText(text: const TextSpan(text: 'safe rich text')), + const SelectableText('safe selectable text'), + const TextField(), + ])), + ])); + expect(_isMasked(tester.getRect(find.text('private sibling'))), isTrue); + for (final text in [ + 'safe text', + 'safe rich text', + 'safe selectable text' + ]) { + expect( + _isMasked(tester.getRect(find.text(text, findRichText: true).first)), + isFalse, + reason: text); + } + expect(_isMasked(tester.getRect(find.byType(TextField))), isFalse); + }); + + testWidgets('a forwarding ancestor must not mask a nested unmask widget', + (tester) async { + await _setup(); + await _pump(tester, const _SafeLabel()); + expect(_masks(), isEmpty); + }); + + testWidgets('unmasking images does not unmask sibling images', + (tester) async { + await _setup(); + final ui.Image image = + (await tester.runAsync(() => createTestImage(width: 20, height: 20)))!; + addTearDown(image.dispose); + await _pump( + tester, + Row(children: [ + RawImage( + key: const Key('private image'), + image: image, + width: 20, + height: 20), + PostHogUnmaskWidget( + child: RawImage( + key: const Key('safe image'), + image: image, + width: 20, + height: 20)), + ])); + expect(_isMasked(tester.getRect(find.byKey(const Key('private image')))), + isTrue); + expect(_isMasked(tester.getRect(find.byKey(const Key('safe image')))), + isFalse); + }); + + for (final maskOutside in [false, true]) { + testWidgets('explicit mask wins with maskOutside=$maskOutside', + (tester) async { + await _setup(); + const text = Text('private'); + final child = maskOutside + ? const PostHogMaskWidget(child: PostHogUnmaskWidget(child: text)) + : const PostHogUnmaskWidget( + child: + PostHogMaskWidget(child: PostHogUnmaskWidget(child: text))); + await _pump(tester, child); + expect(_isMasked(tester.getRect(find.text('private'))), isTrue); + }); + } + + for (final maskAllTexts in [false, true]) { + testWidgets( + 'sensitive fields stay masked inside unmask with texts=$maskAllTexts', + (tester) async { + await _setup(maskAllTexts: maskAllTexts, maskAllImages: false); + await _pump( + tester, + PostHogUnmaskWidget( + child: Column(children: [ + const Text('safe'), + const TextField(obscureText: true), + const CupertinoTextField( + autofillHints: [AutofillHints.creditCardNumber]), + TextFormField(keyboardType: TextInputType.visiblePassword), + ]))); + expect(_isMasked(tester.getRect(find.text('safe'))), isFalse); + for (final element in find.byType(EditableText).evaluate()) { + expect( + _isMasked(tester.getRect(find.byWidget(element.widget))), isTrue); + } + }); + } + + testWidgets('updates masking when an unmask wrapper is added or removed', + (tester) async { + await _setup(); + for (final reveal in [false, true, false]) { + await _pump( + tester, + reveal + ? const PostHogUnmaskWidget(child: Text('label')) + : const Text('label')); + expect(_isMasked(tester.getRect(find.text('label'))), !reveal); + } + }); + + testWidgets('sensitive input masking covers scaled text in a dense field', + (tester) async { + await _setup(maskAllTexts: false, maskAllImages: false); + await _pump( + tester, + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(3)), + child: const PostHogUnmaskWidget( + child: SizedBox( + height: 30, + child: TextField( + style: TextStyle(fontSize: 16), + decoration: InputDecoration( + isDense: true, contentPadding: EdgeInsets.zero), + autofillHints: [AutofillHints.creditCardNumber], + ))), + )); + final editable = tester.renderObject( + find.byElementPredicate((element) => + element is RenderObjectElement && + element.renderObject is RenderEditable)); + final target = MatrixUtils.transformRect( + editable.getTransformTo(PostHogMaskController + .instance.containerKey.currentContext! + .findRenderObject()), + Rect.fromLTWH(0, 0, editable.size.width, editable.preferredLineHeight)); + expect( + _masks().any((e) => + _rect(e).top <= target.top && + _rect(e).left <= target.left && + _rect(e).right >= target.right && + _rect(e).bottom >= target.bottom), + isTrue); + }); + + testWidgets('checks every autofill hint and updates sensitivity on rebuild', + (tester) async { + await _setup(maskAllTexts: false, maskAllImages: false); + for (final hints in [ + [AutofillHints.username], + [AutofillHints.username, AutofillHints.oneTimeCode], + [AutofillHints.username], + ]) { + await _pump( + tester, PostHogUnmaskWidget(child: TextField(autofillHints: hints))); + expect(_masks().isNotEmpty, hints.contains(AutofillHints.oneTimeCode)); + } + }); + + for (final pixelRatio in [1.0, 2.0]) { + testWidgets( + 'captured pixels reveal only safe content at pixelRatio=$pixelRatio', + (tester) async { + await _setup(); + final controller = TextEditingController(text: '4111111111111111'); + addTearDown(controller.dispose); + await _pump( + tester, + PostHogUnmaskWidget( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Safe error message', + style: TextStyle( + color: Colors.red, backgroundColor: Colors.yellow)), + TextField( + controller: controller, + autofillHints: const [AutofillHints.creditCardNumber]), + const PostHogMaskWidget(child: Text('private label')), + ], + ))); + final boundary = PostHogMaskController + .instance.containerKey.currentContext! + .findRenderObject()! as RenderRepaintBoundary; + final safeRect = tester.getRect(find.text('Safe error message')); + final sensitiveRect = tester.getRect(find.byType(EditableText)); + final privateRect = tester.getRect(find.text('private label')); + final masks = _masks(); + await tester.runAsync(() async { + final source = await boundary.toImage(pixelRatio: pixelRatio); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder) + ..drawImage(source, Offset.zero, Paint()); + ImageMaskPainter().drawMaskedImage(canvas, masks, pixelRatio); + final picture = recorder.endRecording(); + final masked = await picture.toImage(source.width, source.height); + try { + final sourceBytes = + (await source.toByteData(format: ui.ImageByteFormat.rawRgba))! + .buffer + .asUint8List(); + final maskedBytes = + (await masked.toByteData(format: ui.ImageByteFormat.rawRgba))! + .buffer + .asUint8List(); + for (final rect in [safeRect, sensitiveRect, privateRect]) { + var changed = false; + for (var y = (rect.top * pixelRatio).ceil() + 1; + y < (rect.bottom * pixelRatio).floor() - 1; + y++) { + for (var x = (rect.left * pixelRatio).ceil() + 1; + x < (rect.right * pixelRatio).floor() - 1; + x++) { + final i = (y * source.width + x) * 4; + final actual = maskedBytes.sublist(i, i + 4); + final original = sourceBytes.sublist(i, i + 4); + if (rect == safeRect) { + expect(actual, original, reason: 'safe content at ($x, $y)'); + } else { + expect(actual, [0, 0, 0, 255], + reason: 'private content at ($x, $y)'); + changed |= + original[0] != 0 || original[1] != 0 || original[2] != 0; + } + } + } + if (rect != safeRect) expect(changed, isTrue); + } + } finally { + source.dispose(); + masked.dispose(); + picture.dispose(); + } + }); + }); + } + + testWidgets('ordinary inputs remain visible when global text masking is off', + (tester) async { + await _setup(maskAllTexts: false, maskAllImages: false); + await _pump( + tester, const TextField(autofillHints: [AutofillHints.username])); + expect(_masks(), isEmpty); + }); +} diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index fdd23113..12f4fe1b 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -767,6 +767,62 @@ void main() { } }); + testWidgets( + 'canvas regions honor unmasking without revealing sensitive inputs', + (tester) async { + await tester.pumpWidget(const PostHogWidget( + child: MaterialApp( + home: Scaffold( + body: Column(children: [ + Text('private sibling'), + PostHogUnmaskWidget( + child: Column(children: [ + Text('safe label'), + TextField(autofillHints: [AutofillHints.creditCardNumber]), + PostHogMaskWidget(child: Text('explicitly private')), + ])), + ]))), + )); + final flutterView = web.document.createElement('flutter-view'); + flutterView.setAttribute('style', 'position: fixed; left: 0; top: 0'); + final canvas = web.document.createElement('canvas'); + canvas.setAttribute('style', 'position: absolute; left: 0; top: 0'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + installPosthogStub(); + try { + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = + (regionsFn.callAsFunction(null, canvas) as JSArray) + .toDart + .map((region) => Rect.fromLTWH( + region.getProperty('x'.toJS).toDartDouble, + region.getProperty('y'.toJS).toDartDouble, + region.getProperty('width'.toJS).toDartDouble, + region.getProperty('height'.toJS).toDartDouble, + )) + .toList(); + expect( + regions.any((rect) => + rect.contains(tester.getCenter(find.text('safe label')))), + isFalse); + for (final finder in [ + find.text('private sibling'), + find.byType(EditableText), + find.text('explicitly private') + ]) { + expect(regions.any((rect) => rect.contains(tester.getCenter(finder))), + isTrue); + } + } finally { + flutterView.remove(); + } + }); + testWidgets('maps mask rects into canvas-relative coordinates', (tester) async { final config = PostHogConfig('phc_test') From 56fd14cba20ea185989e11d53b6bfd712b4a8ebe Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 11 Sep 2026 12:43:48 +0200 Subject: [PATCH 2/5] fix(replay): always mask credit card expiration fields --- .../selective-unmask-sensitive-inputs.md | 2 +- posthog_flutter/lib/src/posthog_config.dart | 5 +- .../src/replay/mask/sensitive_text_input.dart | 4 ++ .../test/selective_masking_test.dart | 52 +++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/.changeset/selective-unmask-sensitive-inputs.md b/.changeset/selective-unmask-sensitive-inputs.md index de0dd116..7dc256c8 100644 --- a/.changeset/selective-unmask-sensitive-inputs.md +++ b/.changeset/selective-unmask-sensitive-inputs.md @@ -2,4 +2,4 @@ "posthog_flutter": minor --- -Add `PostHogUnmaskWidget` to selectively reveal known-safe Flutter text and images while keeping global session replay masking enabled. Explicit masks and sensitive inputs take precedence regardless of nesting. Password, card number/security code, and one-time-code autofill hints, password keyboard types, and obscured fields now stay masked across Material, Cupertino, and direct `EditableText` inputs even when global text masking is disabled. Flutter web still requires canvas masking to be enabled; native platform views and captured native screens are unaffected. +Add `PostHogUnmaskWidget` to selectively reveal known-safe Flutter text and images while keeping global session replay masking enabled. Explicit masks and sensitive inputs take precedence regardless of nesting. Password, card number/security code/expiration date (including day/month/year), and one-time-code autofill hints, password keyboard types, and obscured fields now stay masked across Material, Cupertino, and direct `EditableText` inputs even when global text masking is disabled. Flutter web still requires canvas masking to be enabled; native platform views and captured native screens are unaffected. diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 29753da7..b75d93e4 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -687,8 +687,9 @@ class PostHogSessionReplayConfig { /// /// Sensitive Flutter inputs stay masked regardless of this flag or unmask /// widgets: `obscureText`, `TextInputType.visiblePassword`, and autofill hints - /// for passwords, new passwords, credit card numbers/security codes, and - /// one-time codes. Explicit `PostHogMaskWidget` masks also always apply. + /// for passwords, new passwords, credit card numbers/security codes, + /// expiration dates (including day/month/year), and one-time codes. + /// Explicit `PostHogMaskWidget` masks also always apply. /// /// With [captureNativeScreens] enabled, setting this false also unmasks text /// on captured native screens, including native input fields (passwords, diff --git a/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart b/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart index f8ffbb1d..9492ec0d 100644 --- a/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart +++ b/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart @@ -6,6 +6,10 @@ const _sensitiveAutofillHints = { AutofillHints.newPassword, AutofillHints.creditCardNumber, AutofillHints.creditCardSecurityCode, + AutofillHints.creditCardExpirationDate, + AutofillHints.creditCardExpirationDay, + AutofillHints.creditCardExpirationMonth, + AutofillHints.creditCardExpirationYear, AutofillHints.oneTimeCode, }; diff --git a/posthog_flutter/test/selective_masking_test.dart b/posthog_flutter/test/selective_masking_test.dart index ecc2ac23..7eea9986 100644 --- a/posthog_flutter/test/selective_masking_test.dart +++ b/posthog_flutter/test/selective_masking_test.dart @@ -69,6 +69,10 @@ void main() { AutofillHints.newPassword, AutofillHints.creditCardNumber, AutofillHints.creditCardSecurityCode, + AutofillHints.creditCardExpirationDate, + AutofillHints.creditCardExpirationDay, + AutofillHints.creditCardExpirationMonth, + AutofillHints.creditCardExpirationYear, AutofillHints.oneTimeCode, ]) { for (final kind in ['Material', 'Form', 'Cupertino', 'Editable']) { @@ -242,6 +246,54 @@ void main() { }); } + for (final hint in [ + AutofillHints.creditCardExpirationDate, + AutofillHints.creditCardExpirationDay, + AutofillHints.creditCardExpirationMonth, + AutofillHints.creditCardExpirationYear, + ]) { + for (final maskAllTexts in [false, true]) { + testWidgets('$hint stays masked inside unmask with texts=$maskAllTexts', + (tester) async { + await _setup(maskAllTexts: maskAllTexts, maskAllImages: false); + final controller = TextEditingController(text: '12/2030'); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + await _pump( + tester, + PostHogUnmaskWidget( + child: Column(children: [ + const Text('Expiration date'), + TextField(autofillHints: [hint]), + TextFormField(autofillHints: [hint]), + CupertinoTextField(autofillHints: [hint]), + EditableText( + controller: controller, + focusNode: focusNode, + style: const TextStyle(fontSize: 16), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + autofillHints: [hint], + ), + ]))); + expect( + _isMasked(tester.getRect(find.text('Expiration date'))), isFalse); + expect(find.byType(EditableText), findsNWidgets(4)); + for (final element in find.byType(EditableText).evaluate()) { + final target = tester.getRect(find.byWidget(element.widget)); + expect( + _masks().any((e) => + _rect(e).left <= target.left && + _rect(e).top <= target.top && + _rect(e).right >= target.right && + _rect(e).bottom >= target.bottom), + isTrue); + } + }); + } + } + testWidgets('updates masking when an unmask wrapper is added or removed', (tester) async { await _setup(); From 7ffb5d7e872111d069f995e7c176015d2d9355b1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 14 Sep 2026 16:56:29 +0200 Subject: [PATCH 3/5] fix(replay): enable web masking from unmask widgets --- .../selective-unmask-sensitive-inputs.md | 2 +- api/posthog_flutter.api.json | 2 +- posthog_flutter/lib/src/posthog_config.dart | 9 +- .../replay/element_parsers/element_data.dart | 5 +- .../element_object_parser.dart | 5 +- .../mask/canvas_mask_registration_web.dart | 4 +- .../src/replay/mask/posthog_mask_widget.dart | 5 +- .../replay/mask/posthog_unmask_widget.dart | 37 ++++- .../replay/web/web_canvas_mask_provider.dart | 14 +- posthog_flutter/test/element_data_test.dart | 7 +- .../test/element_object_parser_test.dart | 31 ++++ .../test/web_canvas_mask_provider_test.dart | 137 ++++++++++++++++++ 12 files changed, 231 insertions(+), 27 deletions(-) diff --git a/.changeset/selective-unmask-sensitive-inputs.md b/.changeset/selective-unmask-sensitive-inputs.md index 7dc256c8..f6d156c3 100644 --- a/.changeset/selective-unmask-sensitive-inputs.md +++ b/.changeset/selective-unmask-sensitive-inputs.md @@ -2,4 +2,4 @@ "posthog_flutter": minor --- -Add `PostHogUnmaskWidget` to selectively reveal known-safe Flutter text and images while keeping global session replay masking enabled. Explicit masks and sensitive inputs take precedence regardless of nesting. Password, card number/security code/expiration date (including day/month/year), and one-time-code autofill hints, password keyboard types, and obscured fields now stay masked across Material, Cupertino, and direct `EditableText` inputs even when global text masking is disabled. Flutter web still requires canvas masking to be enabled; native platform views and captured native screens are unaffected. +Add `PostHogUnmaskWidget` to selectively reveal known-safe Flutter text and images while keeping global session replay masking enabled. Explicit masks and sensitive inputs take precedence regardless of nesting. Password, card number/security code/expiration date (including day/month/year), and one-time-code autofill hints, password keyboard types, and obscured fields now stay masked across Material, Cupertino, and direct `EditableText` inputs even when global text masking is disabled. On Flutter web, mounting a mask or unmask widget enables canvas masking. To protect frames before the first mount, declare `canvasCapture.maskRegionsFn: () => null` in `posthog.init`'s `session_recording` configuration. Native platform views and captured native screens are unaffected. diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index def5848e..a09b6791 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -5323,7 +5323,7 @@ "name": "PostHogUnmaskWidget", "relativePath": "lib/src/replay/mask/posthog_unmask_widget.dart", "superTypeNames": [ - "StatelessWidget", + "StatefulWidget", "Widget", "DiagnosticableTree", "Object", diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index b75d93e4..92cae815 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -691,6 +691,12 @@ class PostHogSessionReplayConfig { /// expiration dates (including day/month/year), and one-time codes. /// Explicit `PostHogMaskWidget` masks also always apply. /// + /// Flutter web requires canvas masking to be enabled by mounting a + /// `PostHogMaskWidget` or `PostHogUnmaskWidget` inside `PostHogWidget`, or by + /// declaring `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`. + /// Declare it as `() => null` to skip frames before Flutter installs its mask + /// provider. Canvas recording must be enabled separately. + /// /// With [captureNativeScreens] enabled, setting this false also unmasks text /// on captured native screens, including native input fields (passwords, /// card numbers) you may not have built. @@ -698,7 +704,8 @@ class PostHogSessionReplayConfig { /// Enable masking of all images. /// Default: true. `PostHogUnmaskWidget` can reveal known-safe Flutter images; - /// explicit `PostHogMaskWidget` masks still take precedence. + /// explicit `PostHogMaskWidget` masks still take precedence. Flutter web + /// requires canvas masking to be enabled as described in [maskAllTexts]. var maskAllImages = true; /// Deprecated setter that forwards assigned values to [throttleDelay]. diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_data.dart b/posthog_flutter/lib/src/replay/element_parsers/element_data.dart index 27798985..bf00fd9b 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_data.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_data.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart'; -import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart'; class ElementData { Rect rect; @@ -46,9 +45,7 @@ class ElementData { void _collectMaskWidgetElements( ElementData element, List elements) { - if (element.widget is PostHogMaskWidget || - element.isSensitiveText || - isSensitiveTextInput(element.widget)) { + if (element.widget is PostHogMaskWidget || element.isSensitiveText) { elements.add(element); } diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart index 6bf6bf98..f4387d11 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart @@ -17,11 +17,12 @@ class ElementObjectParser { bool unmask = false, bool sensitiveText = false, }) { - if (element.widget is PostHogMaskWidget || - isSensitiveTextInput(element.widget)) { + final isSensitiveText = isSensitiveTextInput(element.widget); + if (element.widget is PostHogMaskWidget || isSensitiveText) { final elementData = _elementParser.relate(element); if (elementData != null) { + elementData.isSensitiveText = isSensitiveText; activeElementData.addChildren(elementData); return elementData; } diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart index a606d271..8937d6b0 100644 --- a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart @@ -5,7 +5,7 @@ import '../../util/logging.dart'; import '../web/web_canvas_mask_provider.dart'; import 'posthog_mask_controller.dart'; -/// A mounted `PostHogMaskWidget` is an explicit request for masking, so it +/// A mounted `PostHogMaskWidget` or `PostHogUnmaskWidget` requests masking, so it /// opts the app into canvas masking even when `posthog.init` never declared /// `maskRegionsFn`. /// @@ -18,7 +18,7 @@ void notifyMaskWidgetMounted(BuildContext context) { try { if (!_isInTrackedTree(context)) { printIfDebug( - 'PostHog: this PostHogMaskWidget is outside the PostHogWidget tree ' + 'PostHog: this mask/unmask widget is outside the PostHogWidget tree ' 'PostHog tracks, so masking could never cover it — it does not ' 'enable web canvas masking.', ); diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 6db8927e..e8f72431 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -9,7 +9,8 @@ import 'canvas_mask_registration_io.dart' /// screenshots, regardless of the global session replay masking settings. /// /// **Flutter web:** the canvas is masked by posthog-js rather than by this -/// plugin, so the first [PostHogMaskWidget] to mount turns canvas masking on — +/// plugin, so the first [PostHogMaskWidget] or `PostHogUnmaskWidget` to mount +/// turns canvas masking on — /// which restarts an in-flight recording once, because masking also excludes /// the Flutter semantics DOM tree via `blockSelector`, and posthog-js only /// reads that when recording starts. Canvas recording itself must be enabled @@ -21,7 +22,7 @@ import 'canvas_mask_registration_io.dart' /// canvasCapture: { maskRegionsFn: () => null } }` /// in your `posthog.init` call — until this plugin takes over, those frames are /// skipped instead of recorded. Your app must be wrapped in `PostHogWidget`, -/// and every [PostHogMaskWidget] must sit inside it — otherwise canvas frames +/// and every mask/unmask widget must sit inside it — otherwise canvas frames /// are skipped instead of recorded unmasked, until the mask widget is moved /// inside `PostHogWidget` or removed. iOS and Android need no setup either /// way. diff --git a/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart index 046859f2..cf18ab91 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart @@ -1,5 +1,8 @@ import 'package:flutter/widgets.dart'; +import 'canvas_mask_registration_io.dart' + if (dart.library.js_interop) 'canvas_mask_registration_web.dart'; + /// Reveals a widget subtree in session replay despite global text/image masking. /// /// Keep `maskAllTexts` / `maskAllImages` enabled and reveal only known-safe UI: @@ -13,11 +16,16 @@ import 'package:flutter/widgets.dart'; /// This does not erase masks from ancestors or overlapping widgets, reveal /// native platform views, or change masking on captured native screens. /// -/// On Flutter web, canvas masking must already be enabled through -/// `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`, or by -/// mounting a `PostHogMaskWidget`. This widget does not enable canvas recording -/// or masking itself. Keep it inside `PostHogWidget` on all platforms. -class PostHogUnmaskWidget extends StatelessWidget { +/// On Flutter web, mounting either this widget or `PostHogMaskWidget` enables +/// canvas masking and restarts an in-flight recording once to protect the +/// semantics DOM too. Canvas recording must be enabled separately. Frames +/// captured before the first mount are not protected by this opt-in. Declare +/// `session_recording: { canvasCapture: { maskRegionsFn: () => null } }` in +/// `posthog.init` to skip those frames until Flutter installs its mask provider. +/// Keep both kinds of wrapper inside `PostHogWidget` on all platforms. On web, +/// a mounted wrapper outside the tracked tree causes frames to be skipped once +/// masking is enabled, until it is moved inside or removed. +class PostHogUnmaskWidget extends StatefulWidget { /// The known-safe widget subtree to reveal in session replay. final Widget child; @@ -25,5 +33,22 @@ class PostHogUnmaskWidget extends StatelessWidget { const PostHogUnmaskWidget({super.key, required this.child}); @override - Widget build(BuildContext context) => child; + State createState() => _PostHogUnmaskWidgetState(); +} + +class _PostHogUnmaskWidgetState extends State { + @override + void initState() { + super.initState(); + notifyMaskWidgetMounted(context); + } + + @override + void dispose() { + notifyMaskWidgetUnmounted(context); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; } diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 508aa593..ea578bb5 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -50,8 +50,8 @@ enum _ApplyResult { /// An app opts in either by declaring `maskRegionsFn` in its /// `posthog.init` call — declaring it as `() => null` also covers the frames /// captured before this provider takes over — or by mounting a -/// `PostHogMaskWidget`, which registers the provider on first mount. Without -/// either, nothing is registered and recording is left exactly as posthog-js +/// `PostHogMaskWidget` or `PostHogUnmaskWidget`, which registers the provider on +/// first mount. Otherwise nothing is registered and recording is left as posthog-js /// configured it. /// /// Fails closed: a failed widget-tree walk returns null, which makes @@ -84,7 +84,7 @@ class WebCanvasMaskProvider { @visibleForTesting static web.Element? debugOwnViewHostOverride; - /// Opts the app into canvas masking because a `PostHogMaskWidget` mounted. + /// Opts into canvas masking when a mask or unmask widget mounts. /// /// Called from shared widget code through a conditional import, so it must /// stay safe to call any number of times; a mount that happens before @@ -288,7 +288,7 @@ class WebCanvasMaskProvider { _objectAssign(canvasCapture, existingCanvasCapture as JSObject); } // the app opts into canvas masking by declaring maskRegionsFn in - // posthog.init or by mounting a PostHogMaskWidget — registering regardless + // posthog.init or by mounting a mask/unmask widget — registering regardless // would restart an in-flight recording and drop the semantics tree for // apps that never asked if (!canvasCapture.has('maskRegionsFn') && !_maskWidgetMounted) { @@ -342,7 +342,7 @@ class WebCanvasMaskProvider { void _warnNotOptedIn(JSObject sessionRecording) { printIfDebug( 'PostHog: Flutter web canvas masking is off — mount a PostHogMaskWidget ' - 'or declare maskRegionsFn in posthog.init to enable it.', + 'or PostHogUnmaskWidget, or declare maskRegionsFn in posthog.init to enable it.', ); final replayConfig = _config.sessionReplayConfig; if (!replayConfig.maskAllTexts && !replayConfig.maskAllImages) { @@ -359,7 +359,7 @@ class WebCanvasMaskProvider { web.console.warn( 'PostHog: canvas session recording is enabled but masking is not, so ' 'text painted by Flutter is recorded unmasked. Mount a ' - 'PostHogMaskWidget to enable masking, or declare ' + 'PostHogMaskWidget or PostHogUnmaskWidget to enable masking, or declare ' 'maskRegionsFn in posthog.init (see the posthog_flutter ' 'CHANGELOG for the snippet).' .toJS, @@ -489,7 +489,7 @@ class WebCanvasMaskProvider { if (!_warnedMaskWidgetOutsideTree) { _warnedMaskWidgetOutsideTree = true; printIfDebug( - 'PostHog: a PostHogMaskWidget is mounted outside the PostHogWidget ' + 'PostHog: a mask/unmask widget is mounted outside the PostHogWidget ' 'tree, so masking could never cover it — canvas frames are skipped ' 'until it is moved inside PostHogWidget or removed.', ); diff --git a/posthog_flutter/test/element_data_test.dart b/posthog_flutter/test/element_data_test.dart index 8488494b..43c54182 100644 --- a/posthog_flutter/test/element_data_test.dart +++ b/posthog_flutter/test/element_data_test.dart @@ -4,12 +4,16 @@ import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; -ElementData _node(String type, {List? children, Widget? widget}) { +ElementData _node(String type, + {List? children, + Widget? widget, + bool isSensitiveText = false}) { return ElementData( rect: const Rect.fromLTWH(0, 0, 10, 10), type: type, children: children, widget: widget, + isSensitiveText: isSensitiveText, ); } @@ -84,6 +88,7 @@ void main() { children: [ _node('TextField', widget: const TextField(obscureText: true), + isSensitiveText: true, children: [ _node('Text', widget: const Text('visible')), ]), diff --git a/posthog_flutter/test/element_object_parser_test.dart b/posthog_flutter/test/element_object_parser_test.dart index 953ac229..0a9ff4b0 100644 --- a/posthog_flutter/test/element_object_parser_test.dart +++ b/posthog_flutter/test/element_object_parser_test.dart @@ -1,5 +1,6 @@ import 'dart:ui' as ui; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; @@ -69,6 +70,36 @@ void main() { expect(types(elements), contains('RawImage')); }); + testWidgets('records sensitivity on widget-level mask data', (tester) async { + await setupPosthog(maskAllTexts: false, maskAllImages: false); + await tester.pumpWidget(MaterialApp( + home: RepaintBoundary( + key: PostHogMaskController.instance.containerKey, + child: const Scaffold( + body: PostHogUnmaskWidget( + child: Column(children: [ + TextField(obscureText: true), + CupertinoTextField(autofillHints: [AutofillHints.oneTimeCode]), + PostHogMaskWidget(child: Text('explicit mask')), + ]))), + ), + )); + final elements = PostHogMaskController.instance + .getMaskElements(includeAllWidgets: false)!; + final inputs = elements.where((e) => + e.widget is TextField || + e.widget is CupertinoTextField || + e.widget is EditableText); + expect(inputs.map((e) => e.widget.runtimeType).toSet(), + containsAll([TextField, CupertinoTextField, EditableText])); + expect(inputs.every((e) => e.isSensitiveText), isTrue); + expect( + elements + .where((e) => e.widget is PostHogMaskWidget) + .every((e) => !e.isSensitiveText), + isTrue); + }); + testWidgets('maskAllTexts=true still masks Text', (tester) async { await setupPosthog(maskAllTexts: true, maskAllImages: true); await pumpTree(tester); diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 12f4fe1b..c6335022 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -126,6 +126,143 @@ void main() { expect(startRecordingCalls, 0); }); + testWidgets('an unmask widget mounted before register opts in', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + await tester + .pumpWidget(const PostHogUnmaskWidget(child: SizedBox.shrink())); + expect(setConfigCalls, 0); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('an unmask widget alone enables safe canvas regions', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(capturedConfig, isNull); + await tester.pumpWidget(const PostHogWidget( + child: MaterialApp( + home: Scaffold( + body: Column(children: [ + Text('private sibling'), + PostHogUnmaskWidget( + child: Column(children: [ + Text('safe label'), + TextField(obscureText: true), + TextField(autofillHints: [AutofillHints.creditCardNumber]), + ])), + ])), + ))); + final recording = capturedSessionRecording(); + expect(recording.getProperty('blockSelector'.toJS).dartify(), + 'flt-semantics-host'); + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + final flutterView = web.document.createElement('flutter-view'); + flutterView.setAttribute('style', 'position: fixed; left: 0; top: 0'); + final canvas = web.document.createElement('canvas'); + canvas.setAttribute('style', 'position: absolute; left: 0; top: 0'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = recording + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = + (regionsFn.callAsFunction(null, canvas) as JSArray) + .toDart + .map((region) => Rect.fromLTWH( + region.getProperty('x'.toJS).toDartDouble, + region.getProperty('y'.toJS).toDartDouble, + region.getProperty('width'.toJS).toDartDouble, + region.getProperty('height'.toJS).toDartDouble, + )) + .toList(); + expect( + regions.any( + (r) => r.contains(tester.getCenter(find.text('safe label')))), + isFalse); + expect( + regions.any((r) => + r.contains(tester.getCenter(find.text('private sibling')))), + isTrue); + for (final field in find.byType(EditableText).evaluate()) { + expect( + regions.any((r) => + r.contains(tester.getCenter(find.byWidget(field.widget)))), + isTrue); + } + } finally { + flutterView.remove(); + } + }); + + testWidgets('unmask and mask mounts share a single registration', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await tester + .pumpWidget(const PostHogUnmaskWidget(child: SizedBox.shrink())); + expect(setConfigCalls, 1); + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + await tester + .pumpWidget(const PostHogUnmaskWidget(child: SizedBox.shrink())); + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('an unmask widget outside the tracked tree does not opt in', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Column(children: [ + Expanded(child: PostHogWidget(child: Container())), + const PostHogUnmaskWidget(child: SizedBox.shrink()), + ]), + )); + expect(setConfigCalls, 0); + expect(stopRecordingCalls, 0); + expect(startRecordingCalls, 0); + }); + + testWidgets( + 'unmounting an unmask widget outside the tracked tree recovers frames', + (tester) async { + installPosthogStub(recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + Widget layout(bool outside) => Directionality( + textDirection: TextDirection.ltr, + child: Column(children: [ + Expanded(child: PostHogWidget(child: Container())), + if (outside) const PostHogUnmaskWidget(child: SizedBox.shrink()), + ]), + ); + await tester.pumpWidget(layout(true)); + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + expect(regionsFn.callAsFunction(null, canvas), isNull); + await tester.pumpWidget(layout(false)); + expect(regionsFn.callAsFunction(null, canvas), isNotNull); + } finally { + flutterView.remove(); + } + }); + testWidgets('opts in when a PostHogMaskWidget mounted before register()', (tester) async { installPosthogStub(declaresMaskProvider: false, recordingStarted: true); From 272f3cc478e3dc656d50d03e7e2ae15be5b3de9b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 14 Sep 2026 19:06:18 +0200 Subject: [PATCH 4/5] fix(replay): align unmask precedence and protect sensitive fields --- .../selective-unmask-sensitive-inputs.md | 2 +- posthog_flutter/lib/src/posthog_config.dart | 14 +- .../replay/element_parsers/element_data.dart | 24 ++- .../element_object_parser.dart | 13 +- .../unmask_element_parser.dart | 52 +++++ .../src/replay/mask/posthog_mask_widget.dart | 3 + .../replay/mask/posthog_unmask_widget.dart | 14 +- .../src/replay/mask/sensitive_text_input.dart | 66 +++++- .../lib/src/replay/mask/unmask_rects.dart | 71 +++++++ .../test/element_object_parser_test.dart | 13 +- .../test/selective_masking_test.dart | 197 ++++++++++++++++-- .../test/sensitive_text_input_test.dart | 121 +++++++++++ posthog_flutter/test/unmask_rects_test.dart | 77 +++++++ .../test/web_canvas_mask_provider_test.dart | 20 +- 14 files changed, 641 insertions(+), 46 deletions(-) create mode 100644 posthog_flutter/lib/src/replay/element_parsers/unmask_element_parser.dart create mode 100644 posthog_flutter/lib/src/replay/mask/unmask_rects.dart create mode 100644 posthog_flutter/test/sensitive_text_input_test.dart create mode 100644 posthog_flutter/test/unmask_rects_test.dart diff --git a/.changeset/selective-unmask-sensitive-inputs.md b/.changeset/selective-unmask-sensitive-inputs.md index f6d156c3..a104b9bf 100644 --- a/.changeset/selective-unmask-sensitive-inputs.md +++ b/.changeset/selective-unmask-sensitive-inputs.md @@ -2,4 +2,4 @@ "posthog_flutter": minor --- -Add `PostHogUnmaskWidget` to selectively reveal known-safe Flutter text and images while keeping global session replay masking enabled. Explicit masks and sensitive inputs take precedence regardless of nesting. Password, card number/security code/expiration date (including day/month/year), and one-time-code autofill hints, password keyboard types, and obscured fields now stay masked across Material, Cupertino, and direct `EditableText` inputs even when global text masking is disabled. On Flutter web, mounting a mask or unmask widget enables canvas masking. To protect frames before the first mount, declare `canvasCapture.maskRegionsFn: () => null` in `posthog.init`'s `session_recording` configuration. Native platform views and captured native screens are unaffected. +Add `PostHogUnmaskWidget` to reveal known-safe Flutter text and images while keeping global masking enabled. Unmasking overrides explicit masks in either nesting order, but sensitive inputs stay masked. Protection covers obscured fields, sensitive keyboard types, and standard autofill hints for personal, contact, address, authentication, and payment data across Material, Cupertino, and direct `EditableText` inputs. Nested unmask regions are subtracted from enclosing masks only when their transforms and clips allow a safe rectangular exclusion. On Flutter web, mounting either wrapper enables canvas masking; declare `canvasCapture.maskRegionsFn: () => null` in `posthog.init`'s `session_recording` configuration to protect frames before the first mount. Native platform views and captured native screens are unaffected. diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 820b0cce..69612801 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -694,11 +694,13 @@ class PostHogSessionReplayConfig { /// Default: true. Wrap known-safe Flutter content in `PostHogUnmaskWidget` /// to reveal it without disabling masking globally. /// - /// Sensitive Flutter inputs stay masked regardless of this flag or unmask - /// widgets: `obscureText`, `TextInputType.visiblePassword`, and autofill hints - /// for passwords, new passwords, credit card numbers/security codes, - /// expiration dates (including day/month/year), and one-time codes. - /// Explicit `PostHogMaskWidget` masks also always apply. + /// Sensitive Flutter inputs stay masked regardless of this flag, explicit + /// masks, or unmask widgets. This includes `obscureText`, password, email, + /// phone, name, address and URL keyboard types, and standard Flutter autofill + /// hints for identity, contact, address, authentication, and payment data. + /// Inputs without these signals are not automatically classified as + /// sensitive. Annotate them appropriately or keep global masking enabled. + /// `PostHogUnmaskWidget` overrides global and explicit masks for other content. /// /// Flutter web requires canvas masking to be enabled by mounting a /// `PostHogMaskWidget` or `PostHogUnmaskWidget` inside `PostHogWidget`, or by @@ -713,7 +715,7 @@ class PostHogSessionReplayConfig { /// Enable masking of all images. /// Default: true. `PostHogUnmaskWidget` can reveal known-safe Flutter images; - /// explicit `PostHogMaskWidget` masks still take precedence. Flutter web + /// it overrides explicit masks within the same subtree too. Flutter web /// requires canvas masking to be enabled as described in [maskAllTexts]. var maskAllImages = true; diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_data.dart b/posthog_flutter/lib/src/replay/element_parsers/element_data.dart index bf00fd9b..b92703ab 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_data.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_data.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_widget.dart'; +import 'package:posthog_flutter/src/replay/mask/unmask_rects.dart'; class ElementData { Rect rect; @@ -8,6 +9,7 @@ class ElementData { Widget? widget; Matrix4? transform; bool isSensitiveText; + bool isUnmask; ElementData({ required this.rect, @@ -16,6 +18,7 @@ class ElementData { this.widget, this.transform, this.isSensitiveText = false, + this.isUnmask = false, }); void addChildren(ElementData elementData) { @@ -29,24 +32,33 @@ class ElementData { return elements; } - /// Every node below the root is an element that already matched a masking - /// rule, so the whole subtree is collected — a match can sit at any depth - /// (a `ListTile` title nests `AnimatedDefaultTextStyle` → `DefaultTextStyle` - /// → `Text` → `RichText`). + /// Collect every matched mask at any depth, excluding visible descendant + /// unmask regions from non-sensitive masks. Unmask markers themselves are + /// never emitted as masks. List extractRects() { final rects = []; for (final child in children ?? const []) { - rects.add(child); + if (!child.isUnmask) { + rects.addAll(subtractUnmaskRects(child, child._unmaskedDescendants())); + } rects.addAll(child.extractRects()); } return rects; } + Iterable _unmaskedDescendants() sync* { + for (final child in children ?? const []) { + if (child.isUnmask) yield child; + yield* child._unmaskedDescendants(); + } + } + void _collectMaskWidgetElements( ElementData element, List elements) { if (element.widget is PostHogMaskWidget || element.isSensitiveText) { - elements.add(element); + elements + .addAll(subtractUnmaskRects(element, element._unmaskedDescendants())); } final children = element.children; diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart index f4387d11..db2ca32d 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart @@ -4,12 +4,14 @@ import 'package:posthog_flutter/posthog_flutter.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/element_parsers/render_editable_parser.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/unmask_element_parser.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart'; class ElementObjectParser { final ElementParser _elementParser = ElementParser(); final RenderEditableParser _renderEditableParser = RenderEditableParser(); + final UnmaskElementParser _unmaskParser = UnmaskElementParser(); ElementData? relateRenderObject( ElementData activeElementData, @@ -17,8 +19,17 @@ class ElementObjectParser { bool unmask = false, bool sensitiveText = false, }) { + if (element.widget is PostHogUnmaskWidget) { + final elementData = _unmaskParser.relate(element); + if (elementData != null) { + elementData.isUnmask = true; + activeElementData.addChildren(elementData); + return elementData; + } + } + final isSensitiveText = isSensitiveTextInput(element.widget); - if (element.widget is PostHogMaskWidget || isSensitiveText) { + if ((!unmask && element.widget is PostHogMaskWidget) || isSensitiveText) { final elementData = _elementParser.relate(element); if (elementData != null) { diff --git a/posthog_flutter/lib/src/replay/element_parsers/unmask_element_parser.dart b/posthog_flutter/lib/src/replay/element_parsers/unmask_element_parser.dart new file mode 100644 index 00000000..30ed7f45 --- /dev/null +++ b/posthog_flutter/lib/src/replay/element_parsers/unmask_element_parser.dart @@ -0,0 +1,52 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart'; +import 'package:posthog_flutter/src/replay/mask/unmask_rects.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +class UnmaskElementParser extends ElementParser { + @override + ElementGeometry? buildElementData(Element element) { + final geometry = super.buildElementData(element); + if (geometry == null) return null; + final renderObject = element.renderObject; + final container = PostHogMaskController.instance.containerKey.currentContext + ?.findRenderObject(); + var rect = geometry.rect; + RenderObject? child; + renderObject?.visitChildren((candidate) => child ??= candidate); + var node = renderObject; + while (node != null) { + if ((node is RenderOpacity && + Color.getAlphaFromOpacity(node.opacity) == 0) || + (node is RenderAnimatedOpacity && + Color.getAlphaFromOpacity(node.opacity.value) == 0) || + (node is RenderSliverOpacity && + Color.getAlphaFromOpacity(node.opacity) == 0) || + (node is RenderSliverAnimatedOpacity && + Color.getAlphaFromOpacity(node.opacity.value) == 0)) { + return (rect: Rect.zero, transform: geometry.transform); + } + final clip = + child == null ? null : node.describeApproximatePaintClip(child!); + if (clip != null) { + // A bounding box of a curved/custom clip can expose pixels that were + // never part of the visible unmask region. Only use rectangular clips. + if (node is! RenderClipRect && node is! RenderViewportBase) { + return (rect: Rect.zero, transform: geometry.transform); + } + final inverse = Matrix4.tryInvert(renderObject!.getTransformTo(node)); + final localClip = + inverse == null ? null : axisAlignedUnmaskRect(clip, inverse); + if (localClip == null) { + return (rect: Rect.zero, transform: geometry.transform); + } + rect = rect.intersect(localClip); + } + if (identical(node, container)) break; + child = node; + node = node.parent; + } + return (rect: rect, transform: geometry.transform); + } +} diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index e8f72431..cf57a541 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -7,6 +7,9 @@ import 'canvas_mask_registration_io.dart' /// /// Wrap sensitive UI with [PostHogMaskWidget] to hide that area in captured /// screenshots, regardless of the global session replay masking settings. +/// `PostHogUnmaskWidget` overrides this mask in either nesting order, except +/// for sensitive text inputs. See that widget's documentation for geometry +/// limits when revealing part of an enclosing mask. /// /// **Flutter web:** the canvas is masked by posthog-js rather than by this /// plugin, so the first [PostHogMaskWidget] or `PostHogUnmaskWidget` to mount diff --git a/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart index cf18ab91..0293e021 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_unmask_widget.dart @@ -11,10 +11,16 @@ import 'canvas_mask_registration_io.dart' /// PostHogUnmaskWidget(child: Text('Try again')) /// ``` /// -/// Only wrap content known to be safe. Explicit `PostHogMaskWidget` masks and -/// sensitive text inputs always take precedence, regardless of nesting order. -/// This does not erase masks from ancestors or overlapping widgets, reveal -/// native platform views, or change masking on captured native screens. +/// Only wrap content known to be safe. This overrides global masking and +/// `PostHogMaskWidget` in either nesting order, but sensitive text inputs always +/// remain masked. Masks on overlapping sibling widgets are not removed. +/// +/// For an enclosing mask, only the visible rectangular unmask region is +/// excluded. If its transform relative to that mask is not axis-aligned, or a +/// non-rectangular/custom clip prevents a safe exclusion, the enclosing mask is +/// retained. Web masks use conservative bounds and can cover the edges of an +/// unmasked region. Native platform views and captured native screens are +/// unaffected. /// /// On Flutter web, mounting either this widget or `PostHogMaskWidget` enables /// canvas masking and restarts an in-flight recording once to protect the diff --git a/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart b/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart index 9492ec0d..f0cdee2f 100644 --- a/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart +++ b/posthog_flutter/lib/src/replay/mask/sensitive_text_input.dart @@ -2,6 +2,63 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; const _sensitiveAutofillHints = { + AutofillHints.addressCity, + AutofillHints.addressCityAndState, + AutofillHints.addressState, + AutofillHints.birthday, + AutofillHints.birthdayDay, + AutofillHints.birthdayMonth, + AutofillHints.birthdayYear, + AutofillHints.countryCode, + AutofillHints.countryName, + AutofillHints.creditCardFamilyName, + AutofillHints.creditCardGivenName, + AutofillHints.creditCardMiddleName, + AutofillHints.creditCardName, + AutofillHints.creditCardType, + AutofillHints.email, + AutofillHints.familyName, + AutofillHints.fullStreetAddress, + AutofillHints.gender, + AutofillHints.givenName, + AutofillHints.impp, + AutofillHints.jobTitle, + AutofillHints.language, + AutofillHints.location, + AutofillHints.middleInitial, + AutofillHints.middleName, + AutofillHints.name, + AutofillHints.namePrefix, + AutofillHints.nameSuffix, + AutofillHints.newUsername, + AutofillHints.nickname, + AutofillHints.organizationName, + AutofillHints.photo, + AutofillHints.postalAddress, + AutofillHints.postalAddressExtended, + AutofillHints.postalAddressExtendedPostalCode, + AutofillHints.postalCode, + AutofillHints.streetAddressLevel1, + AutofillHints.streetAddressLevel2, + AutofillHints.streetAddressLevel3, + AutofillHints.streetAddressLevel4, + AutofillHints.streetAddressLine1, + AutofillHints.streetAddressLine2, + AutofillHints.streetAddressLine3, + AutofillHints.sublocality, + AutofillHints.telephoneNumber, + AutofillHints.telephoneNumberAreaCode, + AutofillHints.telephoneNumberCountryCode, + AutofillHints.telephoneNumberDevice, + AutofillHints.telephoneNumberExtension, + AutofillHints.telephoneNumberLocal, + AutofillHints.telephoneNumberLocalPrefix, + AutofillHints.telephoneNumberLocalSuffix, + AutofillHints.telephoneNumberNational, + AutofillHints.transactionAmount, + AutofillHints.transactionCurrency, + AutofillHints.url, + AutofillHints.username, AutofillHints.password, AutofillHints.newPassword, AutofillHints.creditCardNumber, @@ -36,6 +93,13 @@ bool isSensitiveTextInput(Widget? widget) { } return obscureText || - keyboardType == TextInputType.visiblePassword || + const [ + TextInputType.visiblePassword, + TextInputType.emailAddress, + TextInputType.phone, + TextInputType.name, + TextInputType.streetAddress, + TextInputType.url, + ].contains(keyboardType) || (autofillHints?.any(_sensitiveAutofillHints.contains) ?? false); } diff --git a/posthog_flutter/lib/src/replay/mask/unmask_rects.dart b/posthog_flutter/lib/src/replay/mask/unmask_rects.dart new file mode 100644 index 00000000..e5afe709 --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/unmask_rects.dart @@ -0,0 +1,71 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; + +Rect? axisAlignedUnmaskRect(Rect rect, Matrix4 transform) { + final m = transform.storage; + if (!rect.isFinite || + !m.every((value) => value.isFinite) || + m[2] != 0 || + m[3] != 0 || + m[6] != 0 || + m[7] != 0 || + m[8] != 0 || + m[9] != 0 || + m[10] != 1 || + m[11] != 0 || + m[14] != 0 || + m[15] != 1) { + return null; + } + const tolerance = 1e-10; + final diagonal = m[1].abs() <= tolerance && m[4].abs() <= tolerance; + final swapped = m[0].abs() <= tolerance && m[5].abs() <= tolerance; + if (!diagonal && !swapped) return null; + // Inset away the rounding error of a nominally axis-aligned transform. An + // exclusion must never grow beyond the actual unmasked quadrilateral. + final inset = diagonal + ? math.max(m[1].abs() * rect.width, m[4].abs() * rect.height) + : math.max(m[0].abs() * rect.width, m[5].abs() * rect.height); + final result = MatrixUtils.transformRect(transform, rect).deflate(inset); + return result.isFinite ? result : null; +} + +List subtractUnmaskRects( + ElementData mask, Iterable unmasked) { + if (mask.isSensitiveText) return [mask]; + final regions = unmasked.toList(); + if (regions.isEmpty) return [mask]; + final inverse = Matrix4.tryInvert(mask.transform ?? Matrix4.identity()); + if (inverse == null) return [mask]; + var parts = [mask.rect]; + for (final region in regions) { + if (region.rect.isEmpty) continue; + final relative = inverse.clone() + ..multiply(region.transform ?? Matrix4.identity()); + final hole = axisAlignedUnmaskRect(region.rect, relative); + if (hole == null || hole.isEmpty) continue; + parts = parts.expand((part) => _subtract(part, hole)).toList(); + } + if (parts.length == 1 && parts.single == mask.rect) return [mask]; + return parts + .map((rect) => ElementData( + rect: rect, + type: mask.type, + widget: mask.widget, + transform: mask.transform, + )) + .toList(); +} + +Iterable _subtract(Rect mask, Rect hole) { + final cut = mask.intersect(hole); + if (cut.isEmpty) return [mask]; + return [ + Rect.fromLTRB(mask.left, mask.top, mask.right, cut.top), + Rect.fromLTRB(mask.left, cut.bottom, mask.right, mask.bottom), + Rect.fromLTRB(mask.left, cut.top, cut.left, cut.bottom), + Rect.fromLTRB(cut.right, cut.top, mask.right, cut.bottom), + ].where((rect) => !rect.isEmpty); +} diff --git a/posthog_flutter/test/element_object_parser_test.dart b/posthog_flutter/test/element_object_parser_test.dart index 0a9ff4b0..8dbed670 100644 --- a/posthog_flutter/test/element_object_parser_test.dart +++ b/posthog_flutter/test/element_object_parser_test.dart @@ -76,12 +76,14 @@ void main() { home: RepaintBoundary( key: PostHogMaskController.instance.containerKey, child: const Scaffold( - body: PostHogUnmaskWidget( - child: Column(children: [ - TextField(obscureText: true), - CupertinoTextField(autofillHints: [AutofillHints.oneTimeCode]), + body: Column(children: [ + PostHogUnmaskWidget( + child: Column(children: [ + TextField(obscureText: true), + CupertinoTextField(autofillHints: [AutofillHints.oneTimeCode]), + ])), PostHogMaskWidget(child: Text('explicit mask')), - ]))), + ])), ), )); final elements = PostHogMaskController.instance @@ -93,6 +95,7 @@ void main() { expect(inputs.map((e) => e.widget.runtimeType).toSet(), containsAll([TextField, CupertinoTextField, EditableText])); expect(inputs.every((e) => e.isSensitiveText), isTrue); + expect(elements.where((e) => e.widget is PostHogMaskWidget), isNotEmpty); expect( elements .where((e) => e.widget is PostHogMaskWidget) diff --git a/posthog_flutter/test/selective_masking_test.dart b/posthog_flutter/test/selective_masking_test.dart index 7eea9986..ad196543 100644 --- a/posthog_flutter/test/selective_masking_test.dart +++ b/posthog_flutter/test/selective_masking_test.dart @@ -209,8 +209,7 @@ void main() { }); for (final maskOutside in [false, true]) { - testWidgets('explicit mask wins with maskOutside=$maskOutside', - (tester) async { + testWidgets('unmask wins with maskOutside=$maskOutside', (tester) async { await _setup(); const text = Text('private'); final child = maskOutside @@ -219,7 +218,7 @@ void main() { child: PostHogMaskWidget(child: PostHogUnmaskWidget(child: text))); await _pump(tester, child); - expect(_isMasked(tester.getRect(find.text('private'))), isTrue); + expect(_isMasked(tester.getRect(find.text('private'))), isFalse); }); } @@ -294,6 +293,171 @@ void main() { } } + testWidgets('a nested unmask reveals only its region of an explicit mask', + (tester) async { + await _setup(); + await _pump( + tester, + const PostHogMaskWidget( + child: Column(children: [ + Text('private sibling'), + PostHogUnmaskWidget(child: Text('public label')), + SizedBox(key: Key('private decoration'), width: 60, height: 20), + ]))); + expect(_isMasked(tester.getRect(find.text('public label'))), isFalse); + expect(_isMasked(tester.getRect(find.text('private sibling'))), isTrue); + expect( + _isMasked(tester.getRect(find.byKey(const Key('private decoration')))), + isTrue); + }); + + testWidgets('a clipped unmask does not reveal content outside its viewport', + (tester) async { + await _setup(); + await _pump( + tester, + const PostHogMaskWidget( + child: Column(children: [ + ClipRect( + child: SizedBox( + height: 40, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 100, + maxHeight: 100, + child: PostHogUnmaskWidget( + child: SizedBox(height: 100, width: 400)), + ))), + Text('private below viewport'), + ]))); + expect( + _masks().any((e) => _rect(e).contains(const Offset(10, 10))), isFalse); + expect( + _isMasked(tester.getRect(find.text('private below viewport'))), isTrue); + }); + + for (final opacity in [0.0, 0.001]) { + testWidgets( + 'an invisible unmask retains its ancestor mask at opacity=$opacity', + (tester) async { + await _setup(); + await _pump( + tester, + PostHogMaskWidget( + child: PostHogUnmaskWidget( + child: Opacity( + opacity: opacity, + child: const SizedBox(width: 100, height: 100)), + ))); + expect( + _masks().any((e) => _rect(e).contains(const Offset(20, 20))), isTrue); + }); + + testWidgets( + 'an invisible sliver retains its ancestor mask at opacity=$opacity', + (tester) async { + await _setup(); + await _pump( + tester, + PostHogMaskWidget( + child: SizedBox( + height: 100, + child: CustomScrollView(slivers: [ + SliverOpacity( + opacity: opacity, + sliver: const SliverToBoxAdapter( + child: PostHogUnmaskWidget( + child: SizedBox(width: 100, height: 100), + )), + ) + ]), + ))); + expect( + _masks().any((e) => _rect(e).contains(const Offset(20, 20))), isTrue); + }); + } + + testWidgets('non-rectangular clips retain the enclosing mask', + (tester) async { + await _setup(); + await _pump( + tester, + PostHogMaskWidget( + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: const PostHogUnmaskWidget( + child: SizedBox(width: 100, height: 100)), + ))); + expect( + _masks().any((e) => _rect(e).contains(const Offset(20, 20))), isTrue); + }); + + testWidgets('a rotated child cannot cut an oversized rectangular hole', + (tester) async { + await _setup(); + await _pump( + tester, + PostHogMaskWidget( + child: SizedBox( + width: 200, + height: 200, + child: Center( + child: Transform.rotate( + angle: 0.3, + child: const PostHogUnmaskWidget( + child: SizedBox(width: 100, height: 100)), + )), + ))); + expect( + _masks().any((e) => _rect(e).contains(const Offset(100, 100))), isTrue); + }); + + testWidgets('unmasking does not remove an overlapping sibling mask', + (tester) async { + await _setup(); + await _pump( + tester, + const PostHogMaskWidget( + child: Stack(children: [ + PostHogUnmaskWidget(child: SizedBox(width: 100, height: 100)), + PostHogMaskWidget(child: SizedBox(width: 40, height: 40)), + ]))); + expect( + _masks().any((e) => _rect(e).contains(const Offset(20, 20))), isTrue); + expect( + _masks().any((e) => _rect(e).contains(const Offset(80, 80))), isFalse); + }); + + for (final hint in [ + AutofillHints.email, + AutofillHints.telephoneNumber, + AutofillHints.username, + AutofillHints.name, + AutofillHints.fullStreetAddress, + AutofillHints.birthday, + AutofillHints.gender, + AutofillHints.creditCardName + ]) { + testWidgets('personal hint $hint stays masked inside unmask', + (tester) async { + await _setup(maskAllTexts: false, maskAllImages: false); + await _pump( + tester, + PostHogUnmaskWidget( + child: Column(children: [ + const Text('public label'), + TextField(autofillHints: [hint]), + TextFormField(autofillHints: [hint]), + CupertinoTextField(autofillHints: [hint]), + ]))); + expect(_isMasked(tester.getRect(find.text('public label'))), isFalse); + for (final element in find.byType(EditableText).evaluate()) { + expect( + _isMasked(tester.getRect(find.byWidget(element.widget))), isTrue); + } + }); + } + testWidgets('updates masking when an unmask wrapper is added or removed', (tester) async { await _setup(); @@ -346,9 +510,9 @@ void main() { (tester) async { await _setup(maskAllTexts: false, maskAllImages: false); for (final hints in [ - [AutofillHints.username], - [AutofillHints.username, AutofillHints.oneTimeCode], - [AutofillHints.username], + ['custom-search'], + ['custom-search', AutofillHints.oneTimeCode], + ['custom-search'], ]) { await _pump( tester, PostHogUnmaskWidget(child: TextField(autofillHints: hints))); @@ -365,17 +529,19 @@ void main() { addTearDown(controller.dispose); await _pump( tester, - PostHogUnmaskWidget( + PostHogMaskWidget( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('Safe error message', - style: TextStyle( - color: Colors.red, backgroundColor: Colors.yellow)), - TextField( - controller: controller, - autofillHints: const [AutofillHints.creditCardNumber]), - const PostHogMaskWidget(child: Text('private label')), + const PostHogUnmaskWidget( + child: Text('Safe error message', + style: TextStyle( + color: Colors.red, backgroundColor: Colors.yellow))), + PostHogUnmaskWidget( + child: TextField( + controller: controller, + autofillHints: const [AutofillHints.creditCardNumber])), + const Text('private label'), ], ))); final boundary = PostHogMaskController @@ -437,8 +603,7 @@ void main() { testWidgets('ordinary inputs remain visible when global text masking is off', (tester) async { await _setup(maskAllTexts: false, maskAllImages: false); - await _pump( - tester, const TextField(autofillHints: [AutofillHints.username])); + await _pump(tester, const TextField()); expect(_masks(), isEmpty); }); } diff --git a/posthog_flutter/test/sensitive_text_input_test.dart b/posthog_flutter/test/sensitive_text_input_test.dart new file mode 100644 index 00000000..32de95ff --- /dev/null +++ b/posthog_flutter/test/sensitive_text_input_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/replay/mask/sensitive_text_input.dart'; + +void main() { + for (final hint in [ + AutofillHints.addressCity, + AutofillHints.addressCityAndState, + AutofillHints.addressState, + AutofillHints.birthday, + AutofillHints.birthdayDay, + AutofillHints.birthdayMonth, + AutofillHints.birthdayYear, + AutofillHints.countryCode, + AutofillHints.countryName, + AutofillHints.creditCardExpirationDate, + AutofillHints.creditCardExpirationDay, + AutofillHints.creditCardExpirationMonth, + AutofillHints.creditCardExpirationYear, + AutofillHints.creditCardFamilyName, + AutofillHints.creditCardGivenName, + AutofillHints.creditCardMiddleName, + AutofillHints.creditCardName, + AutofillHints.creditCardNumber, + AutofillHints.creditCardSecurityCode, + AutofillHints.creditCardType, + AutofillHints.email, + AutofillHints.familyName, + AutofillHints.fullStreetAddress, + AutofillHints.gender, + AutofillHints.givenName, + AutofillHints.impp, + AutofillHints.jobTitle, + AutofillHints.language, + AutofillHints.location, + AutofillHints.middleInitial, + AutofillHints.middleName, + AutofillHints.name, + AutofillHints.namePrefix, + AutofillHints.nameSuffix, + AutofillHints.newPassword, + AutofillHints.newUsername, + AutofillHints.nickname, + AutofillHints.oneTimeCode, + AutofillHints.organizationName, + AutofillHints.password, + AutofillHints.photo, + AutofillHints.postalAddress, + AutofillHints.postalAddressExtended, + AutofillHints.postalAddressExtendedPostalCode, + AutofillHints.postalCode, + AutofillHints.streetAddressLevel1, + AutofillHints.streetAddressLevel2, + AutofillHints.streetAddressLevel3, + AutofillHints.streetAddressLevel4, + AutofillHints.streetAddressLine1, + AutofillHints.streetAddressLine2, + AutofillHints.streetAddressLine3, + AutofillHints.sublocality, + AutofillHints.telephoneNumber, + AutofillHints.telephoneNumberAreaCode, + AutofillHints.telephoneNumberCountryCode, + AutofillHints.telephoneNumberDevice, + AutofillHints.telephoneNumberExtension, + AutofillHints.telephoneNumberLocal, + AutofillHints.telephoneNumberLocalPrefix, + AutofillHints.telephoneNumberLocalSuffix, + AutofillHints.telephoneNumberNational, + AutofillHints.transactionAmount, + AutofillHints.transactionCurrency, + AutofillHints.url, + AutofillHints.username, + ]) { + test('protects standard autofill hint $hint on every input surface', () { + final controller = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + for (final widget in [ + TextField(autofillHints: [hint]), + CupertinoTextField(autofillHints: [hint]), + EditableText( + controller: controller, + focusNode: focusNode, + style: const TextStyle(), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + autofillHints: [hint]), + ]) { + expect(isSensitiveTextInput(widget), isTrue); + } + }); + } + + for (final type in [ + TextInputType.visiblePassword, + TextInputType.emailAddress, + TextInputType.phone, + TextInputType.name, + TextInputType.streetAddress, + TextInputType.url + ]) { + test('protects sensitive keyboard type $type without autofill hints', () { + expect(isSensitiveTextInput(TextField(keyboardType: type)), isTrue); + expect( + isSensitiveTextInput(CupertinoTextField(keyboardType: type)), isTrue); + }); + } + + test('does not classify unannotated or unknown custom inputs as sensitive', + () { + for (final widget in [ + const TextField(), + const TextField(keyboardType: TextInputType.number), + const TextField(autofillHints: ['custom-search']) + ]) { + expect(isSensitiveTextInput(widget), isFalse); + } + }); +} diff --git a/posthog_flutter/test/unmask_rects_test.dart b/posthog_flutter/test/unmask_rects_test.dart new file mode 100644 index 00000000..2e80d0f2 --- /dev/null +++ b/posthog_flutter/test/unmask_rects_test.dart @@ -0,0 +1,77 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/mask/unmask_rects.dart'; + +ElementData region(Rect rect, {Matrix4? transform, bool sensitive = false}) => + ElementData( + rect: rect, + type: 'test', + transform: transform, + isSensitiveText: sensitive); + +void main() { + const outer = Rect.fromLTWH(0, 0, 100, 100); + const inner = Rect.fromLTWH(20, 20, 40, 40); + + test('subtracts only the hole and retains the rest of the mask', () { + final parts = subtractUnmaskRects(region(outer), [region(inner)]); + expect(parts, hasLength(4)); + expect(parts.any((part) => part.rect.overlaps(inner)), isFalse); + expect( + parts.fold( + 0, (area, part) => area + part.rect.width * part.rect.height), + 8400); + }); + + test('handles overlapping holes and a hole crossing a mask edge', () { + final holes = [inner, const Rect.fromLTWH(40, 40, 80, 80)]; + final parts = subtractUnmaskRects(region(outer), holes.map(region)); + for (var y = 0.5; y < 100; y++) { + for (var x = 0.5; x < 100; x++) { + final point = Offset(x, y); + expect(parts.any((part) => part.rect.contains(point)), + !holes.any((hole) => hole.contains(point))); + } + } + }); + + test('uses the mask coordinate space under shared rotation and scaling', () { + final transform = Matrix4.identity() + ..rotateZ(math.pi / 4) + ..multiply(Matrix4.diagonal3Values(2, 2, 1)); + final parts = subtractUnmaskRects(region(outer, transform: transform), + [region(inner, transform: transform)]); + expect(parts.any((part) => part.rect.contains(inner.center)), isFalse); + expect(parts.every((part) => identical(part.transform, transform)), isTrue); + }); + + test('resolves translated and mirrored holes', () { + final transform = Matrix4.identity() + ..setTranslationRaw(60, 20, 0) + ..multiply(Matrix4.diagonal3Values(-1, 1, 1)); + final parts = subtractUnmaskRects(region(outer), + [region(const Rect.fromLTWH(0, 0, 40, 40), transform: transform)]); + expect(parts.any((part) => part.rect.contains(inner.center)), isFalse); + }); + + test('never subtracts from a sensitive input mask', () { + final mask = region(outer, sensitive: true); + expect(subtractUnmaskRects(mask, [region(outer)]), [mask]); + }); + + test('retains masks for singular, rotated, and perspective exclusions', () { + for (final transform in [ + Matrix4.identity()..rotateZ(math.pi / 4), + Matrix4.identity()..setEntry(3, 0, 0.1), + ]) { + final mask = region(outer); + expect(subtractUnmaskRects(mask, [region(inner, transform: transform)]), + [mask]); + } + final singular = region(outer, transform: Matrix4.zero()); + expect(subtractUnmaskRects(singular, [region(inner)]), [singular]); + }); +} diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index c6335022..7b66bec2 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -905,20 +905,22 @@ void main() { }); testWidgets( - 'canvas regions honor unmasking without revealing sensitive inputs', + 'canvas unmask regions override explicit masks but keep sensitive inputs', (tester) async { await tester.pumpWidget(const PostHogWidget( child: MaterialApp( home: Scaffold( - body: Column(children: [ + body: PostHogMaskWidget( + child: Column(children: [ Text('private sibling'), PostHogUnmaskWidget( child: Column(children: [ Text('safe label'), TextField(autofillHints: [AutofillHints.creditCardNumber]), - PostHogMaskWidget(child: Text('explicitly private')), + TextField(autofillHints: [AutofillHints.email]), + PostHogMaskWidget(child: Text('nested explicit mask')), ])), - ]))), + ])))), )); final flutterView = web.document.createElement('flutter-view'); flutterView.setAttribute('style', 'position: fixed; left: 0; top: 0'); @@ -947,10 +949,16 @@ void main() { regions.any((rect) => rect.contains(tester.getCenter(find.text('safe label')))), isFalse); + expect( + regions.any((rect) => rect + .contains(tester.getCenter(find.text('nested explicit mask')))), + isFalse); for (final finder in [ find.text('private sibling'), - find.byType(EditableText), - find.text('explicitly private') + ...find + .byType(EditableText) + .evaluate() + .map((e) => find.byWidget(e.widget)), ]) { expect(regions.any((rect) => rect.contains(tester.getCenter(finder))), isTrue); From ec9af701c77201192111885efacae1429c657c87 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 14 Sep 2026 19:19:40 +0200 Subject: [PATCH 5/5] test(replay): cover unmask interaction with custom painters --- .../test/custom_paint_masking_test.dart | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/posthog_flutter/test/custom_paint_masking_test.dart b/posthog_flutter/test/custom_paint_masking_test.dart index 39901430..7e5202e9 100644 --- a/posthog_flutter/test/custom_paint_masking_test.dart +++ b/posthog_flutter/test/custom_paint_masking_test.dart @@ -124,6 +124,34 @@ void main() { ); } + testWidgets('unmask overrides opt-in custom-paint masking', (tester) async { + await setup(texts: false, images: false, customPaint: true); + await pumpTree(tester, PostHogUnmaskWidget(child: painted())); + final bounds = boundsOf(tester, find.byKey(paintKey)); + expect(maskRects().any((rect) => rect.overlaps(bounds)), isFalse); + }); + + testWidgets( + 'unmask excludes only its region from an enclosing custom painter', + (tester) async { + await setup(texts: false, images: false, customPaint: true); + await pumpTree( + tester, + const CustomPaint( + painter: _ValuePainter(), + child: Column(children: [ + Text('private painted child'), + PostHogUnmaskWidget(child: Text('safe painted child')), + ]), + )); + final privateBounds = boundsOf(tester, find.text('private painted child')); + final safeBounds = boundsOf(tester, find.text('safe painted child')); + expect( + maskRects().any((rect) => rect.contains(privateBounds.center)), isTrue); + expect( + maskRects().any((rect) => rect.contains(safeBounds.center)), isFalse); + }); + Future captureFrame(WidgetTester tester) async { const channel = MethodChannel('posthog_flutter'); final messenger =