From f07112fd70e59cebefffb31b7343466499e81246 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 11 Sep 2026 12:23:01 +0200 Subject: [PATCH 1/2] fix(replay): mask custom-painted content --- .changeset/mask-custom-painted-content.md | 5 + posthog_flutter/lib/src/posthog_config.dart | 10 + .../element_object_parser.dart | 15 + .../element_parsers_const.dart | 3 + .../test/custom_paint_masking_test.dart | 260 ++++++++++++++++++ posthog_flutter/test/element_data_test.dart | 2 + 6 files changed, 295 insertions(+) create mode 100644 .changeset/mask-custom-painted-content.md create mode 100644 posthog_flutter/test/custom_paint_masking_test.dart diff --git a/.changeset/mask-custom-painted-content.md b/.changeset/mask-custom-painted-content.md new file mode 100644 index 00000000..2a558b30 --- /dev/null +++ b/.changeset/mask-custom-painted-content.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Mask the full bounds of `CustomPaint` widgets with a painter or foreground painter when either `maskAllTexts` or `maskAllImages` is enabled, preventing custom-painted text and images from appearing unmasked in session replay. This also masks children and custom-painted Flutter decorations within those bounds. diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index ca7ca9db..1da0daeb 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -684,6 +684,13 @@ class PostHogSessionReplayConfig { /// Enable masking of all text and text input fields. /// Default: true. /// + /// When this or [maskAllImages] is enabled, CustomPaint widgets with a + /// painter or foregroundPainter are masked over their entire bounds, + /// including their children, because canvas contents cannot be inspected. + /// This also masks custom-painted decorations in Flutter widgets. Disable + /// MaterialApp's debugShowCheckedModeBanner when testing replay: its + /// full-window CustomPaint otherwise masks the entire screen. + /// /// 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. @@ -691,6 +698,9 @@ class PostHogSessionReplayConfig { /// Enable masking of all images. /// Default: true. + /// + /// Custom-painted content is masked when this or [maskAllTexts] is enabled; + /// see [maskAllTexts] for details. var maskAllImages = true; /// Deprecated setter that forwards assigned values to [throttleDelay]. 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..58584845 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,6 +3,7 @@ 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/element_parsers_const.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; class ElementObjectParser { @@ -64,6 +65,20 @@ class ElementObjectParser { } } + final renderObject = element.renderObject; + if (renderObject is RenderCustomPaint && + (renderObject.painter != null || + renderObject.foregroundPainter != null)) { + // Canvas commands cannot be inspected for sensitive text or images. + final parser = PostHogMaskController.instance + .parsers[ElementParsersConst.getRuntimeType()]; + final elementData = parser?.relate(element); + if (elementData != null) { + activeElementData.addChildren(elementData); + return elementData; + } + } + if (element.renderObject is RenderImage) { final dataType = element.renderObject.runtimeType.toString(); diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_parsers_const.dart b/posthog_flutter/lib/src/replay/element_parsers/element_parsers_const.dart index bb65712d..0d2c21ce 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_parsers_const.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_parsers_const.dart @@ -8,6 +8,9 @@ class ElementParsersConst { final Map parsersMap = {}; ElementParsersConst(this._factory, PostHogSessionReplayConfig? config) { + if ((config?.maskAllTexts ?? true) || (config?.maskAllImages ?? true)) { + registerElementParser(); + } if (config?.maskAllImages ?? true) { registerElementParser(); } diff --git a/posthog_flutter/test/custom_paint_masking_test.dart b/posthog_flutter/test/custom_paint_masking_test.dart new file mode 100644 index 00000000..ac5926e9 --- /dev/null +++ b/posthog_flutter/test/custom_paint_masking_test.dart @@ -0,0 +1,260 @@ +import 'dart:ui' as ui; + +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/mask/image_mask_painter.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +class _ValuePainter extends CustomPainter { + const _ValuePainter(); + + @override + void paint(Canvas canvas, Size size) { + final text = TextPainter( + text: const TextSpan( + text: '₦2,450,000.00', + style: TextStyle(fontSize: 12, color: Colors.black), + ), + textDirection: TextDirection.ltr, + )..layout(); + text.paint(canvas, Offset.zero); + text.dispose(); + } + + @override + bool shouldRepaint(covariant _ValuePainter oldDelegate) => false; +} + +void main() { + const paintKey = ValueKey('sensitive-paint'); + final controller = PostHogMaskController.instance; + + Future setup({bool texts = true, bool images = true}) async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterPlatformFake(); + final config = PostHogConfig('test_project_token'); + config.sessionReplayConfig + ..maskAllTexts = texts + ..maskAllImages = images; + await Posthog().setup(config); + controller.refreshParsers(config.sessionReplayConfig); + } + + tearDown(() async { + controller.refreshParsers(null); + await Posthog().close(); + }); + + Widget painted({bool foreground = false}) => CustomPaint( + key: paintKey, + size: const Size(200, 40), + painter: foreground ? null : const _ValuePainter(), + foregroundPainter: foreground ? const _ValuePainter() : null, + ); + + Future pumpTree(WidgetTester tester, Widget child) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RepaintBoundary( + key: controller.containerKey, + child: ColoredBox( + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [const Text('₦2,450,000.00'), child], + ), + ), + ), + ), + ); + } + + List maskRects({bool includeAllWidgets = true}) { + final elements = controller.getMaskElements( + includeAllWidgets: includeAllWidgets, + ); + expect(elements, isNotNull); + return elements! + .map((element) => element.transform == null + ? element.rect + : MatrixUtils.transformRect(element.transform!, element.rect)) + .toList(); + } + + Rect boundsOf(WidgetTester tester, Finder finder) { + final renderObject = tester.renderObject(finder); + return MatrixUtils.transformRect( + renderObject.getTransformTo( + controller.containerKey.currentContext!.findRenderObject(), + ), + renderObject.paintBounds, + ); + } + + for (final foreground in [false, true]) { + for (final texts in [false, true]) { + for (final images in [false, true]) { + testWidgets( + 'masks CustomPaint foreground=$foreground texts=$texts images=$images', + (tester) async { + await setup(texts: texts, images: images); + await pumpTree(tester, painted(foreground: foreground)); + + final rects = maskRects(includeAllWidgets: texts || images); + final paintRect = boundsOf(tester, find.byKey(paintKey)); + expect(rects.contains(paintRect), texts || images); + expect( + rects.contains(boundsOf(tester, find.byType(Text))), + texts, + ); + }); + } + } + } + + testWidgets('explicit mask still covers CustomPaint with both flags off', + (tester) async { + await setup(texts: false, images: false); + await pumpTree(tester, PostHogMaskWidget(child: painted())); + + expect( + maskRects(includeAllWidgets: false), + contains(boundsOf(tester, find.byKey(paintKey))), + ); + }); + + testWidgets('does not mask an empty CustomPaint but still walks its child', + (tester) async { + await setup(); + await pumpTree( + tester, + const CustomPaint( + key: paintKey, + child: SizedBox( + width: 200, + height: 100, + child: Align(child: Text('child text')), + ), + ), + ); + + final rects = maskRects(); + expect(rects, isNot(contains(boundsOf(tester, find.byKey(paintKey))))); + expect(rects, contains(boundsOf(tester, find.text('child text')))); + }); + + testWidgets('masks children underneath a foreground painter', (tester) async { + await setup(); + await pumpTree( + tester, + const CustomPaint( + key: paintKey, + foregroundPainter: _ValuePainter(), + child: SizedBox(width: 200, height: 100), + ), + ); + + expect(maskRects(), contains(boundsOf(tester, find.byKey(paintKey)))); + }); + + testWidgets('conservatively masks a full-window debug banner', + (tester) async { + await setup(); + await tester.pumpWidget( + RepaintBoundary( + key: controller.containerKey, + child: const MaterialApp(home: Scaffold(body: Text('secret'))), + ), + ); + await tester.pumpAndSettle(); + + expect( + maskRects(), + contains(boundsOf(tester, find.byKey(controller.containerKey))), + ); + }); + + testWidgets('uses painted bounds and transform for CustomPaint masks', + (tester) async { + await setup(); + await pumpTree( + tester, + Transform.translate( + offset: const Offset(30, 20), + child: Transform.scale( + scale: 1.5, + alignment: Alignment.topLeft, + child: painted(), + ), + ), + ); + + final paintRect = boundsOf(tester, find.byKey(paintKey)); + expect(paintRect.size, const Size(300, 60)); + expect(maskRects(), contains(paintRect)); + }); + + testWidgets('masking replaces custom-painted screenshot pixels with black', + (tester) async { + await setup(); + await pumpTree(tester, painted()); + + final boundary = controller.containerKey.currentContext!.findRenderObject() + as RenderRepaintBoundary; + final elements = controller.getMaskElements(includeAllWidgets: true)!; + final paintRect = boundsOf(tester, find.byKey(paintKey)); + + await tester.runAsync(() async { + final screenshot = await boundary.toImage(pixelRatio: 2); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder) + ..drawImage(screenshot, Offset.zero, Paint()); + ImageMaskPainter().drawMaskedImage(canvas, elements, 2); + final picture = recorder.endRecording(); + final masked = await picture.toImage(screenshot.width, screenshot.height); + try { + final before = (await screenshot.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final after = (await masked.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + var originalBlackPixels = 0; + var originalWhitePixels = 0; + var maskedBlackPixels = 0; + var totalPixels = 0; + for (var y = (paintRect.top * 2).ceil(); + y < (paintRect.bottom * 2).floor(); + y++) { + for (var x = (paintRect.left * 2).ceil(); + x < (paintRect.right * 2).floor(); + x++) { + final offset = (y * screenshot.width + x) * 4; + if (before.getUint32(offset) == 0x000000ff) originalBlackPixels++; + if (before.getUint32(offset) == 0xffffffff) originalWhitePixels++; + if (after.getUint32(offset) == 0x000000ff) maskedBlackPixels++; + totalPixels++; + } + } + expect(originalBlackPixels, greaterThan(0)); + expect(originalWhitePixels, greaterThan(0)); + expect(maskedBlackPixels, totalPixels); + final outside = ((screenshot.height - 10) * screenshot.width + + screenshot.width - + 10) * + 4; + expect(before.getUint32(outside), 0xffffffff); + expect(after.getUint32(outside), 0xffffffff); + } finally { + screenshot.dispose(); + masked.dispose(); + picture.dispose(); + } + }); + }); +} diff --git a/posthog_flutter/test/element_data_test.dart b/posthog_flutter/test/element_data_test.dart index 8488494b..8fca75eb 100644 --- a/posthog_flutter/test/element_data_test.dart +++ b/posthog_flutter/test/element_data_test.dart @@ -35,6 +35,8 @@ Future _pumpMaskedApp(WidgetTester tester, Widget child) async { await tester.pumpWidget( PostHogWidget( child: MaterialApp( + // The debug banner is a full-window CustomPaint and is masked as such. + debugShowCheckedModeBanner: false, home: Scaffold(body: Center(child: child)), ), ), From a440e0af45e1b07aed9b549f6996386636811f28 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 11 Sep 2026 12:34:28 +0200 Subject: [PATCH 2/2] feat(replay): make custom-paint masking opt-in --- .changeset/mask-custom-painted-content.md | 4 +- api/posthog_flutter.api.json | 11 ++ posthog_flutter/lib/src/posthog_config.dart | 23 ++-- .../element_parsers_const.dart | 2 +- .../screenshot/screenshot_capturer.dart | 5 +- .../replay/web/web_canvas_mask_provider.dart | 9 +- .../test/custom_paint_masking_test.dart | 129 ++++++++++++++---- posthog_flutter/test/element_data_test.dart | 2 - .../test/web_canvas_mask_provider_test.dart | 72 ++++++++++ 9 files changed, 216 insertions(+), 41 deletions(-) diff --git a/.changeset/mask-custom-painted-content.md b/.changeset/mask-custom-painted-content.md index 2a558b30..10303ae8 100644 --- a/.changeset/mask-custom-painted-content.md +++ b/.changeset/mask-custom-painted-content.md @@ -1,5 +1,5 @@ --- -"posthog_flutter": patch +"posthog_flutter": minor --- -Mask the full bounds of `CustomPaint` widgets with a painter or foreground painter when either `maskAllTexts` or `maskAllImages` is enabled, preventing custom-painted text and images from appearing unmasked in session replay. This also masks children and custom-painted Flutter decorations within those bounds. +Add `sessionReplayConfig.maskCustomPaint`, defaulting to `false`, to opt into masking the full bounds of custom-painted widgets independently of text and image masking. diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index eebaaec2..e3b348a9 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -2663,6 +2663,17 @@ "relativePath": "lib/src/posthog_config.dart", "typeName": "bool" }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "maskCustomPaint", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "bool" + }, { "entryPoints": [], "isDeprecated": false, diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 1da0daeb..5d7a6c9f 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -684,12 +684,8 @@ class PostHogSessionReplayConfig { /// Enable masking of all text and text input fields. /// Default: true. /// - /// When this or [maskAllImages] is enabled, CustomPaint widgets with a - /// painter or foregroundPainter are masked over their entire bounds, - /// including their children, because canvas contents cannot be inspected. - /// This also masks custom-painted decorations in Flutter widgets. Disable - /// MaterialApp's debugShowCheckedModeBanner when testing replay: its - /// full-window CustomPaint otherwise masks the entire screen. + /// Does not mask text drawn by CustomPainter. Enable [maskCustomPaint] or + /// wrap sensitive custom-painted widgets in PostHogMaskWidget to mask them. /// /// With [captureNativeScreens] enabled, setting this false also unmasks text /// on captured native screens, including native input fields (passwords, @@ -699,10 +695,21 @@ class PostHogSessionReplayConfig { /// Enable masking of all images. /// Default: true. /// - /// Custom-painted content is masked when this or [maskAllTexts] is enabled; - /// see [maskAllTexts] for details. + /// Does not mask images drawn by CustomPainter. Enable [maskCustomPaint] or + /// wrap sensitive custom-painted widgets in PostHogMaskWidget to mask them. var maskAllImages = true; + /// Mask the full bounds of CustomPaint widgets with a painter or + /// foregroundPainter, including their children. + /// Default: false. + /// + /// This is independent of [maskAllTexts] and [maskAllImages]. Canvas contents + /// cannot be inspected for individual text or images, so enabling this also + /// masks custom-painted decorations in Flutter widgets. Disable MaterialApp's + /// debugShowCheckedModeBanner when using this option: its full-window + /// CustomPaint otherwise masks the entire screen. + var maskCustomPaint = false; + /// Deprecated setter that forwards assigned values to [throttleDelay]. /// /// Debouncer delay used to reduce the number of snapshots captured and reduce diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_parsers_const.dart b/posthog_flutter/lib/src/replay/element_parsers/element_parsers_const.dart index 0d2c21ce..b46e3c71 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_parsers_const.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_parsers_const.dart @@ -8,7 +8,7 @@ class ElementParsersConst { final Map parsersMap = {}; ElementParsersConst(this._factory, PostHogSessionReplayConfig? config) { - if ((config?.maskAllTexts ?? true) || (config?.maskAllImages ?? true)) { + if (config?.maskCustomPaint ?? false) { registerElementParser(); } if (config?.maskAllImages ?? true) { diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index fe1cd913..b47dc265 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -642,8 +642,9 @@ class ScreenshotCapturer { ); final replayConfig = effectiveConfig.sessionReplayConfig; - final maskAllContent = - replayConfig.maskAllTexts || replayConfig.maskAllImages; + final maskAllContent = replayConfig.maskAllTexts || + replayConfig.maskAllImages || + replayConfig.maskCustomPaint; ui.Image? image; ui.PictureRecorder? recorder; 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..41846ab2 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 @@ -345,7 +345,9 @@ class WebCanvasMaskProvider { 'or declare maskRegionsFn in posthog.init to enable it.', ); final replayConfig = _config.sessionReplayConfig; - if (!replayConfig.maskAllTexts && !replayConfig.maskAllImages) { + if (!replayConfig.maskAllTexts && + !replayConfig.maskAllImages && + !replayConfig.maskCustomPaint) { return; } final captureCanvas = @@ -644,8 +646,9 @@ class WebCanvasMaskProvider { final replayConfig = _config.sessionReplayConfig; final elements = PostHogMaskController.instance.getMaskElements( - includeAllWidgets: - replayConfig.maskAllTexts || replayConfig.maskAllImages, + includeAllWidgets: replayConfig.maskAllTexts || + replayConfig.maskAllImages || + replayConfig.maskCustomPaint, ); if (elements == null) { _cachedContainerRects = null; diff --git a/posthog_flutter/test/custom_paint_masking_test.dart b/posthog_flutter/test/custom_paint_masking_test.dart index ac5926e9..59ef6f50 100644 --- a/posthog_flutter/test/custom_paint_masking_test.dart +++ b/posthog_flutter/test/custom_paint_masking_test.dart @@ -1,14 +1,19 @@ import 'dart:ui' as ui; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.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/mask/image_mask_painter.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; +import 'package:posthog_flutter/src/replay/screenshot/screenshot_capturer.dart' + as replay; import 'posthog_flutter_platform_interface_fake.dart'; +import 'replay_capture_settle.dart'; class _ValuePainter extends CustomPainter { const _ValuePainter(); @@ -34,12 +39,22 @@ void main() { const paintKey = ValueKey('sensitive-paint'); final controller = PostHogMaskController.instance; - Future setup({bool texts = true, bool images = true}) async { + setUp(() { PosthogFlutterPlatformInterface.instance = PosthogFlutterPlatformFake(); + }); + + Future setup({ + bool texts = true, + bool images = true, + bool? customPaint, + }) async { final config = PostHogConfig('test_project_token'); config.sessionReplayConfig ..maskAllTexts = texts ..maskAllImages = images; + if (customPaint != null) { + config.sessionReplayConfig.maskCustomPaint = customPaint; + } await Posthog().setup(config); controller.refreshParsers(config.sessionReplayConfig); } @@ -96,23 +111,42 @@ void main() { ); } + test('custom-paint masking is disabled by default', () { + expect(PostHogSessionReplayConfig().maskCustomPaint, isFalse); + controller.refreshParsers(null); + expect(controller.parsers, isNot(contains('RenderCustomPaint'))); + }); + + testWidgets('leaves CustomPaint visible by default', (tester) async { + await setup(); + await pumpTree(tester, painted()); + + expect( + maskRects(), isNot(contains(boundsOf(tester, find.byKey(paintKey))))); + expect(maskRects(), contains(boundsOf(tester, find.byType(Text)))); + }); + for (final foreground in [false, true]) { - for (final texts in [false, true]) { - for (final images in [false, true]) { - testWidgets( - 'masks CustomPaint foreground=$foreground texts=$texts images=$images', - (tester) async { - await setup(texts: texts, images: images); - await pumpTree(tester, painted(foreground: foreground)); - - final rects = maskRects(includeAllWidgets: texts || images); - final paintRect = boundsOf(tester, find.byKey(paintKey)); - expect(rects.contains(paintRect), texts || images); - expect( - rects.contains(boundsOf(tester, find.byType(Text))), - texts, - ); - }); + for (final customPaint in [false, true]) { + for (final texts in [false, true]) { + for (final images in [false, true]) { + testWidgets( + 'CustomPaint foreground=$foreground customPaint=$customPaint ' + 'texts=$texts images=$images', (tester) async { + await setup(texts: texts, images: images, customPaint: customPaint); + await pumpTree(tester, painted(foreground: foreground)); + + final rects = maskRects( + includeAllWidgets: texts || images || customPaint, + ); + final paintRect = boundsOf(tester, find.byKey(paintKey)); + expect(rects.contains(paintRect), customPaint); + expect( + rects.contains(boundsOf(tester, find.byType(Text))), + texts, + ); + }); + } } } } @@ -130,7 +164,7 @@ void main() { testWidgets('does not mask an empty CustomPaint but still walks its child', (tester) async { - await setup(); + await setup(customPaint: true); await pumpTree( tester, const CustomPaint( @@ -149,7 +183,7 @@ void main() { }); testWidgets('masks children underneath a foreground painter', (tester) async { - await setup(); + await setup(customPaint: true); await pumpTree( tester, const CustomPaint( @@ -162,9 +196,9 @@ void main() { expect(maskRects(), contains(boundsOf(tester, find.byKey(paintKey)))); }); - testWidgets('conservatively masks a full-window debug banner', + testWidgets('conservatively masks a full-window debug banner when opted in', (tester) async { - await setup(); + await setup(customPaint: true); await tester.pumpWidget( RepaintBoundary( key: controller.containerKey, @@ -181,7 +215,7 @@ void main() { testWidgets('uses painted bounds and transform for CustomPaint masks', (tester) async { - await setup(); + await setup(customPaint: true); await pumpTree( tester, Transform.translate( @@ -199,9 +233,58 @@ void main() { expect(maskRects(), contains(paintRect)); }); + testWidgets('native captureScreenshot honors custom-paint masking on its own', + (tester) async { + await setup(texts: false, images: false, customPaint: true); + await pumpTree(tester, painted()); + + const channel = MethodChannel('posthog_flutter'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getSessionReplayState') { + return {'isActive': true, 'sessionId': 'custom-paint-session'}; + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final capturer = replay.ScreenshotCapturer(Posthog().config!); + addTearDown(capturer.cancel); + var completed = false; + final capture = capturer.captureScreenshot().whenComplete(() { + completed = true; + }); + await settleUntil(tester, () => completed); + expect(completed, isTrue); + final captured = await capture; + expect(captured, isNotNull); + + final paintRect = boundsOf(tester, find.byKey(paintKey)); + final boundary = tester.renderObject( + find.byKey(controller.containerKey), + ); + await tester.runAsync(() async { + final codec = await ui.instantiateImageCodec(captured!.imageBytes); + final image = (await codec.getNextFrame()).image; + try { + final data = + (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!; + final ratio = image.width / boundary.size.width; + final x = (5 * ratio).floor(); + final insideY = ((paintRect.bottom - 5) * ratio).floor(); + final outsideY = ((paintRect.bottom + 10) * ratio).floor(); + expect(data.getUint32((insideY * image.width + x) * 4), 0x000000ff); + expect(data.getUint32((outsideY * image.width + x) * 4), 0xffffffff); + } finally { + image.dispose(); + codec.dispose(); + } + }); + }, skip: kIsWeb); + testWidgets('masking replaces custom-painted screenshot pixels with black', (tester) async { - await setup(); + await setup(texts: false, images: false, customPaint: true); await pumpTree(tester, painted()); final boundary = controller.containerKey.currentContext!.findRenderObject() diff --git a/posthog_flutter/test/element_data_test.dart b/posthog_flutter/test/element_data_test.dart index 8fca75eb..8488494b 100644 --- a/posthog_flutter/test/element_data_test.dart +++ b/posthog_flutter/test/element_data_test.dart @@ -35,8 +35,6 @@ Future _pumpMaskedApp(WidgetTester tester, Widget child) async { await tester.pumpWidget( PostHogWidget( child: MaterialApp( - // The debug banner is a full-window CustomPaint and is masked as such. - debugShowCheckedModeBanner: false, home: Scaffold(body: Center(child: child)), ), ), diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index fdd23113..57e984bc 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -114,6 +114,78 @@ void main() { return sessionRecording as JSObject; } + testWidgets('emits custom-paint regions when other masking flags are off', + (tester) async { + installPosthogStub(); + final config = PostHogConfig('phc_test'); + config.sessionReplayConfig + ..maskAllTexts = false + ..maskAllImages = false + ..maskCustomPaint = true; + WebCanvasMaskProvider(config).register(); + + await tester.pumpWidget( + PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: CustomPaint( + size: const Size(100, 40), + painter: BannerPainter( + message: 'sensitive', + layoutDirection: TextDirection.ltr, + textDirection: TextDirection.ltr, + location: BannerLocation.topStart, + ), + ), + ), + ), + ); + + 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); + final regions = + regionsFn.callAsFunction(null, canvas) as JSArray; + expect(regions.toDart, isNotEmpty); + expect( + regions.toDart.any((region) => + region.getProperty('width'.toJS).toDartDouble >= 100 && + region.getProperty('height'.toJS).toDartDouble >= 40), + isTrue, + ); + } finally { + flutterView.remove(); + } + }); + + test('warns when only custom-paint masking is requested without a provider', + () { + final captureCanvas = JSObject() + ..setProperty('recordCanvas'.toJS, true.toJS); + final sessionRecording = JSObject() + ..setProperty('captureCanvas'.toJS, captureCanvas); + installPosthogStub( + declaresMaskProvider: false, + sessionRecording: sessionRecording, + ); + final warns = interceptWarns(); + final config = PostHogConfig('phc_test'); + config.sessionReplayConfig + ..maskAllTexts = false + ..maskAllImages = false + ..maskCustomPaint = true; + + WebCanvasMaskProvider(config).register(); + + expect(warns(), 1); + }); + test('leaves posthog-js untouched when the app declares no mask provider', () { installPosthogStub(declaresMaskProvider: true, recordingStarted: true);