From edc29f2291d8826b40245daf93a090a0245bed3a Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:16:06 +0000 Subject: [PATCH 1/2] fix(replay): correct platform view mask geometry and capture-failure fallback Three defects in the platform view path of screenshot_capturer.dart: - A revealed view (maskAllPlatformViews = false) turned black whenever the Android native capture returned null, so the disabled mask still hid it. Keep the Flutter pixels and log the failure instead. - The mask rect used unclipped paintBounds, so it spilled past the view onto the widgets below. Intersect it with the ancestor clip chain. - The platform view rects were collected after toImage() and the raw-RGBA await, so a mask could land a frame late. Collect them beside the widget mask walk, before any await. Generated-By: PostHog Desktop Task-Id: b9d8412a-9522-4446-a859-62f7f9fa0118 --- .changeset/platform-view-mask-geometry.md | 5 ++ .../screenshot/screenshot_capturer.dart | 59 +++++++++++++---- .../test/platform_view_clip_test.dart | 64 +++++++++++++++++++ 3 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 .changeset/platform-view-mask-geometry.md create mode 100644 posthog_flutter/test/platform_view_clip_test.dart diff --git a/.changeset/platform-view-mask-geometry.md b/.changeset/platform-view-mask-geometry.md new file mode 100644 index 00000000..8988bf7e --- /dev/null +++ b/.changeset/platform-view-mask-geometry.md @@ -0,0 +1,5 @@ +--- +'posthog_flutter': patch +--- + +Fix session replay masking on screens with a platform view (map, WebView, camera preview). A revealed view (`maskAllPlatformViews = false`) no longer turns black when the native capture fails — the SDK keeps the Flutter pixels and logs the failure instead. The mask rect is now clipped to the ancestor clip chain, so it no longer spills past the view onto the widgets below. The platform view rects are also collected in the same frame as the widget mask rects, so a mask can no longer land a frame late over moved pixels. diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 239e0fa0..3776d416 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -300,9 +300,12 @@ class ScreenshotCapturer { return; } try { + final rect = clippedPaintBounds(ro, ancestor); + // A view clipped away by an ancestor covers nothing, so it gets no rect. + if (rect.isEmpty) return; final transform = ro.getTransformTo(ancestor); final data = ElementData( - rect: ro.paintBounds, + rect: rect, type: 'platformView', transform: transform, ); @@ -338,16 +341,20 @@ class ScreenshotCapturer { ) async { final transform = viewRect.transform; if (transform == null) return; - final transformedRect = MatrixUtils.transformRect(transform, viewRect.rect); + // The user chose to reveal this view. A failed native capture must leave + // the Flutter pixels in place, never paint the mask they turned off. if (bytes == null) { - _imageMaskPainter.drawMaskedImage(canvas, [viewRect], pixelRatio); + printIfDebug( + 'Native capture returned no bytes for a revealed platform view; keeping the Flutter pixels.'); return; } final nativeImage = await _decodeRawPixels(bytes, nativeW, nativeH); if (nativeImage == null) { - _imageMaskPainter.drawMaskedImage(canvas, [viewRect], pixelRatio); + printIfDebug( + 'Failed to decode the native capture for a revealed platform view; keeping the Flutter pixels.'); return; } + final transformedRect = MatrixUtils.transformRect(transform, viewRect.rect); canvas.drawImageRect( nativeImage, Rect.fromLTWH( @@ -595,6 +602,16 @@ class ScreenshotCapturer { return; } + // Collect the platform view rects in the same frame as the widget mask + // rects, before any await. Collecting them after toImage() lets the UI + // move first, so a mask lands a frame late over the wrong pixels. + final defaultPolicy = replayConfig.maskAllPlatformViews + ? PostHogPlatformViewPrivacy.mask + : PostHogPlatformViewPrivacy.capture; + final pvRects = _collectPlatformViewRects(defaultPolicy); + final hasCapturedViews = pvRects.captured.isNotEmpty; + hasCapturedPlatformViews = hasCapturedViews; + image = await renderObject.toImage(pixelRatio: pixelRatio); final currentImage = image; @@ -651,13 +668,6 @@ class ScreenshotCapturer { final preMaskHash = _computeImageHash(imageBytes); imageBytes = null; - final defaultPolicy = replayConfig.maskAllPlatformViews - ? PostHogPlatformViewPrivacy.mask - : PostHogPlatformViewPrivacy.capture; - final pvRects = _collectPlatformViewRects(defaultPolicy); - final hasCapturedViews = pvRects.captured.isNotEmpty; - hasCapturedPlatformViews = hasCapturedViews; - if (!hasCapturedViews && preMaskHash == statusView.imageBytesHash) { printIfDebug( 'Snapshot is the same as the last one, nothing changed, do nothing.', @@ -820,6 +830,33 @@ class ScreenshotCapturer { } } +/// Intersects [ro]'s paint bounds with every clip its ancestors apply, up to +/// but not including [ancestor], and returns the visible rect in [ro]'s local +/// coordinates. A platform view reports its full, unclipped paint bounds, so a +/// map inside a scroll view or a `ClipRect` would otherwise place a mask past +/// the visible edge and over the widgets below. Returns [Rect.zero] when the +/// view is fully clipped away. +@visibleForTesting +Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { + var clipped = ro.paintBounds; + RenderObject child = ro; + RenderObject? node = ro.parent; + while (node != null && !identical(node, ancestor)) { + final clip = node.describeApproximatePaintClip(child); + if (clip != null) { + // The clip is in node's coordinates; map it into ro's frame. + final toRo = Matrix4.tryInvert(ro.getTransformTo(node)); + if (toRo != null) { + clipped = clipped.intersect(MatrixUtils.transformRect(toRo, clip)); + if (clipped.isEmpty) return Rect.zero; + } + } + child = node; + node = node.parent; + } + return clipped; +} + @visibleForTesting PostHogPlatformViewPrivacy resolvePrivacyPolicyForElement( Element element, diff --git a/posthog_flutter/test/platform_view_clip_test.dart b/posthog_flutter/test/platform_view_clip_test.dart new file mode 100644 index 00000000..9c5cb42e --- /dev/null +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/replay/screenshot/screenshot_capturer.dart'; + +void main() { + group('clippedPaintBounds — ancestor clip intersection', () { + testWidgets('an unclipped view keeps its full paint bounds', + (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + key: Key('ancestor'), + width: 300, + height: 300, + child: SizedBox(key: Key('view'), width: 200, height: 200), + ), + ), + ), + ); + + final view = tester.renderObject(find.byKey(const Key('view'))); + final ancestor = + tester.renderObject(find.byKey(const Key('ancestor'))); + + expect(clippedPaintBounds(view, ancestor), + const Rect.fromLTWH(0, 0, 200, 200)); + }); + + testWidgets('a ClipRect ancestor trims the view to the visible region', + (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + key: Key('ancestor'), + width: 100, + height: 100, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + maxWidth: 300, + maxHeight: 300, + child: SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ), + ); + + final view = tester.renderObject(find.byKey(const Key('view'))); + final ancestor = + tester.renderObject(find.byKey(const Key('ancestor'))); + + // The view paints 300x300 but the ClipRect only shows the top-left 100x100. + expect(clippedPaintBounds(view, ancestor), + const Rect.fromLTWH(0, 0, 100, 100)); + }); + }); +} From 7706ebef6430aeb390d74536e69dcf851f55065d Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:33:33 +0000 Subject: [PATCH 2/2] test(replay): fix clippedPaintBounds test scaffold and formatting The unclipped-view case forced the view to the ancestor's tight size, so it measured 300x300 instead of 200x200. Loosen it with a Center. Also wrap the render-object lookups to satisfy dart format. Generated-By: PostHog Desktop Task-Id: b9d8412a-9522-4446-a859-62f7f9fa0118 --- posthog_flutter/test/platform_view_clip_test.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/posthog_flutter/test/platform_view_clip_test.dart b/posthog_flutter/test/platform_view_clip_test.dart index 9c5cb42e..9fea5c0e 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -15,13 +15,16 @@ void main() { key: Key('ancestor'), width: 300, height: 300, - child: SizedBox(key: Key('view'), width: 200, height: 200), + child: Center( + child: SizedBox(key: Key('view'), width: 200, height: 200), + ), ), ), ), ); - final view = tester.renderObject(find.byKey(const Key('view'))); + final view = + tester.renderObject(find.byKey(const Key('view'))); final ancestor = tester.renderObject(find.byKey(const Key('ancestor'))); @@ -52,7 +55,8 @@ void main() { ), ); - final view = tester.renderObject(find.byKey(const Key('view'))); + final view = + tester.renderObject(find.byKey(const Key('view'))); final ancestor = tester.renderObject(find.byKey(const Key('ancestor')));