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..9fea5c0e --- /dev/null +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -0,0 +1,68 @@ +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: Center( + 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)); + }); + }); +}