From 5a4eb1eb4873cf744df64126d8b3d81a383c0ea7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 27 Aug 2026 14:50:22 -0400 Subject: [PATCH 01/17] fix(replay): stop a platform view mask spilling past its visible bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A map, WebView, or camera preview reports its full, unclipped paint bounds, so a view trimmed by a `ClipRect` or a scroll viewport was masked over its whole size — the black box overran the view and covered the Flutter widgets below it, which then vanished from replay. Revealed views composited past the clip the same way on iOS. The mask rect is now intersected with every clip its ancestors apply, and a revealed view is clipped to that same region when it is composited rather than by shrinking the native request, which the platform matches the view by. Trimming the mask to the visible region exposed an older problem it had been hiding: the rect is measured a moment before the screenshot is rasterised, so a tree that moves in between leaves the mask slightly off, and the oversized rect used to absorb that slop. Measured on an Android emulator under continuous scroll, masked content became legible in 27.8% of frames with the trim alone, against 16.7% before it. The mask is therefore re-measured immediately before it is painted and widened to cover both positions, which returns the leak to baseline (18.6% vs 19.3%, p=0.90) without reintroducing the spill. Verified on an Android emulator with `example/lib/platform_view_spill_screen.dart`, which puts a sentinel banner directly below a clipped platform view in seven configurations: 3 of 7 sentinels were fully covered before the fix and 0 of 7 after, with the unclipped control byte-identical across both runs. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/platform-view-mask-spill.md | 7 + example/lib/platform_view_spill_screen.dart | 255 ++++++++++++++++++ .../screenshot/screenshot_capturer.dart | 161 +++++++++-- .../test/platform_view_clip_test.dart | 180 +++++++++++++ 4 files changed, 583 insertions(+), 20 deletions(-) create mode 100644 .changeset/platform-view-mask-spill.md create mode 100644 example/lib/platform_view_spill_screen.dart create mode 100644 posthog_flutter/test/platform_view_clip_test.dart diff --git a/.changeset/platform-view-mask-spill.md b/.changeset/platform-view-mask-spill.md new file mode 100644 index 00000000..e33d3faf --- /dev/null +++ b/.changeset/platform-view-mask-spill.md @@ -0,0 +1,7 @@ +--- +'posthog_flutter': patch +--- + +Fix session replay masking a platform view past its visible bounds. A map, WebView, or camera preview inside a `ClipRect` or a scroll view reports its full, unclipped size, so the mask was drawn over the whole view and covered the widgets sitting below it. The mask is now intersected with the ancestor clip chain, and revealed views are clipped to the same region when they are composited. + +The mask is also re-measured immediately before it is painted and widened to cover both positions if the tree moved mid-capture, so trimming it to the visible region cannot expose masked content during a scroll. diff --git a/example/lib/platform_view_spill_screen.dart b/example/lib/platform_view_spill_screen.dart new file mode 100644 index 00000000..dc998621 --- /dev/null +++ b/example/lib/platform_view_spill_screen.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Repro cases for the platform-view mask/reveal spill. +/// +/// Every case puts a sentinel banner directly below a clipped platform view. +/// The banner is plain Flutter, so replay must always show it — if a case's +/// banner is missing from a replay frame, the platform view's rect overran its +/// visible region and painted over it. +const spillSentinels = { + 'clipped_masked': 'SPILLONE', + 'scrolled_masked': 'SPILLTWO', + 'nested_clip_masked': 'SPILLTHREE', + 'clipped_revealed': 'SPILLFOUR', + 'scrolled_revealed': 'SPILLFIVE', + 'masked_over_revealed': 'SPILLSIX', + 'unclipped_masked_control': 'SPILLSEVEN', +}; + +WebViewController _wv() => + WebViewController()..loadRequest(Uri.parse('https://www.wikipedia.org')); + +class _Sentinel extends StatelessWidget { + final String token; + const _Sentinel(this.token); + + @override + Widget build(BuildContext context) => Container( + height: 90, + width: double.infinity, + color: const Color(0xFFFFE24A), + alignment: Alignment.center, + child: Text( + token, + style: const TextStyle( + color: Colors.black, + fontSize: 34, + fontWeight: FontWeight.bold, + ), + ), + ); +} + +/// A tall platform view trimmed by a `ClipRect`, sentinel directly below. +class ClippedMasked extends StatefulWidget { + final PostHogPlatformViewPrivacy privacy; + final String token; + const ClippedMasked({super.key, required this.privacy, required this.token}); + @override + State createState() => _ClippedMaskedState(); +} + +class _ClippedMaskedState extends State { + late final WebViewController _c = _wv(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text("spill repro")), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 160, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, + child: SizedBox( + height: 520, + child: PostHogPlatformView( + privacy: widget.privacy, + child: WebViewWidget(controller: _c), + ), + ), + ), + ), + ), + _Sentinel(widget.token), + ], + ), + ); +} + +/// Nested clips: the innermost one wins and the mask must respect it. +class NestedClipMasked extends StatefulWidget { + final String token; + const NestedClipMasked({super.key, required this.token}); + @override + State createState() => _NestedClipMaskedState(); +} + +class _NestedClipMaskedState extends State { + late final WebViewController _c = _wv(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text("spill repro")), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 300, + child: ClipRect( + child: SizedBox( + height: 140, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, + child: SizedBox( + height: 520, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _c), + ), + ), + ), + ), + ), + ), + ), + _Sentinel(widget.token), + ], + ), + ); +} + +/// A platform view scrolled half out of a viewport, sentinel pinned below it. +class ScrolledPartlyOut extends StatefulWidget { + final PostHogPlatformViewPrivacy privacy; + final String token; + const ScrolledPartlyOut({ + super.key, + required this.privacy, + required this.token, + }); + @override + State createState() => _ScrolledPartlyOutState(); +} + +class _ScrolledPartlyOutState extends State { + late final WebViewController _c = _wv(); + final _controller = ScrollController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_controller.hasClients) _controller.jumpTo(260); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text("spill repro")), + body: ListView( + controller: _controller, + children: [ + const SizedBox(height: 40), + SizedBox( + height: 460, + child: PostHogPlatformView( + privacy: widget.privacy, + child: WebViewWidget(controller: _c), + ), + ), + _Sentinel(widget.token), + const SizedBox(height: 700), + ], + ), + ); +} + +/// A masked view directly above a revealed one: the mask must not overrun and +/// black out the view the developer asked to reveal. +class MaskedOverRevealed extends StatefulWidget { + final String token; + const MaskedOverRevealed({super.key, required this.token}); + @override + State createState() => _MaskedOverRevealedState(); +} + +class _MaskedOverRevealedState extends State { + late final WebViewController _top = _wv(); + late final WebViewController _bottom = _wv(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text("spill repro")), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 150, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, + child: SizedBox( + height: 520, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _top), + ), + ), + ), + ), + ), + _Sentinel(widget.token), + Expanded( + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.capture, + child: WebViewWidget(controller: _bottom), + ), + ), + ], + ), + ); +} + +/// Control: no clip anywhere. The mask must be unchanged by the fix. +class UnclippedMaskedControl extends StatefulWidget { + final String token; + const UnclippedMaskedControl({super.key, required this.token}); + @override + State createState() => _UnclippedMaskedControlState(); +} + +class _UnclippedMaskedControlState extends State { + late final WebViewController _c = _wv(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text("spill repro")), + body: Column( + children: [ + SizedBox( + height: 200, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _c), + ), + ), + _Sentinel(widget.token), + ], + ), + ); +} diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 239e0fa0..543c8afe 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -60,10 +60,35 @@ class ViewTreeSnapshotStatus { ViewTreeSnapshotStatus(this.sentMetaEvent); } +/// A masked platform view, kept with its [RenderBox] so its mask can be +/// re-measured immediately before it is painted. +class _MaskedView { + final RenderBox ro; + final ElementData data; + const _MaskedView({required this.ro, required this.data}); +} + +/// A revealed platform view. [data] carries the view's own bounds, which the +/// native side uses to find and crop it; [visibleRect] is the part an ancestor +/// clip leaves on screen, which is all we are allowed to paint. +class _CapturedView { + final ElementData data; + final Rect visibleRect; + const _CapturedView({required this.data, required this.visibleRect}); +} + class _PlatformViewRects { - final List masked; - final List captured; - const _PlatformViewRects({required this.masked, required this.captured}); + final List<_MaskedView> masked; + final List<_CapturedView> captured; + + /// The screenshot container the rects are relative to, kept for re-measuring. + final RenderObject? ancestor; + + const _PlatformViewRects({ + required this.masked, + required this.captured, + required this.ancestor, + }); } class ScreenshotCapturer { @@ -243,8 +268,8 @@ class ScreenshotCapturer { _PlatformViewRects _collectPlatformViewRects( PostHogPlatformViewPrivacy defaultPolicy) { - final masked = []; - final captured = []; + final masked = <_MaskedView>[]; + final captured = <_CapturedView>[]; final ancestor = PostHogMaskController.instance.containerKey.currentContext ?.findRenderObject(); final seen = {}; @@ -259,14 +284,15 @@ class ScreenshotCapturer { printIfDebug( 'Found ${masked.length} masked and ${captured.length} captured platform view rect(s)'); } - return _PlatformViewRects(masked: masked, captured: captured); + return _PlatformViewRects( + masked: masked, captured: captured, ancestor: ancestor); } void _visitElementForPlatformViews( Element element, RenderObject? ancestor, - List masked, - List captured, + List<_MaskedView> masked, + List<_CapturedView> captured, Set seen, PostHogPlatformViewPrivacy inheritedPolicy, ) { @@ -288,8 +314,8 @@ class ScreenshotCapturer { void _addIfNew( RenderBox ro, RenderObject? ancestor, - List masked, - List captured, + List<_MaskedView> masked, + List<_CapturedView> captured, Set seen, PostHogPlatformViewPrivacy policy, ) { @@ -301,15 +327,27 @@ class ScreenshotCapturer { } try { final transform = ro.getTransformTo(ancestor); - final data = ElementData( - rect: ro.paintBounds, - type: 'platformView', - transform: transform, - ); + final visible = clippedPaintBounds(ro, ancestor); + // A view an ancestor clips away entirely covers nothing on screen. + if (visible.isEmpty) return; if (policy == PostHogPlatformViewPrivacy.capture) { - captured.add(data); + captured.add(_CapturedView( + data: ElementData( + rect: ro.paintBounds, + type: 'platformView', + transform: transform, + ), + visibleRect: visible, + )); } else { - masked.add(data); + masked.add(_MaskedView( + ro: ro, + data: ElementData( + rect: visible, + type: 'platformView', + transform: transform, + ), + )); } } catch (e) { printIfDebug('Error collecting platform view rect: $e'); @@ -328,14 +366,42 @@ class ScreenshotCapturer { }; } + /// Re-measures [view] and widens its mask to cover both where the view was + /// when the rect was collected and where it is now. + /// + /// Clipping the mask to the visible region makes it tight enough to expose + /// content whenever the tree moves between collection and this paint — the + /// oversized rect used to hide that slop. Covering both positions fails + /// closed; when nothing moved the two rects are equal and this is a no-op. + ElementData _maskCoveringMotion(_MaskedView view, RenderObject? ancestor) { + final collected = view.data; + final collectedTransform = collected.transform; + if (collectedTransform == null) return collected; + try { + if (!view.ro.attached || !view.ro.hasSize) return collected; + final fresh = clippedPaintBounds(view.ro, ancestor); + if (fresh.isEmpty) return collected; + return ElementData( + rect: maskRectCoveringMotion(collected.rect, collectedTransform, fresh, + view.ro.getTransformTo(ancestor)), + type: collected.type, + transform: collectedTransform, + ); + } catch (e) { + printIfDebug('Error re-measuring a masked platform view: $e'); + return collected; + } + } + Future _compositeRevealedView( Canvas canvas, - ElementData viewRect, + _CapturedView view, Uint8List? bytes, int nativeW, int nativeH, double pixelRatio, ) async { + final viewRect = view.data; final transform = viewRect.transform; if (transform == null) return; final transformedRect = MatrixUtils.transformRect(transform, viewRect.rect); @@ -348,6 +414,12 @@ class ScreenshotCapturer { _imageMaskPainter.drawMaskedImage(canvas, [viewRect], pixelRatio); return; } + // The native request covers the view's own frame, because that is what the + // platform matches it by. Clipping here, rather than shrinking the request, + // keeps the revealed pixels inside the ancestor clip without changing what + // the native side is asked for. + canvas.save(); + canvas.clipRect(MatrixUtils.transformRect(transform, view.visibleRect)); canvas.drawImageRect( nativeImage, Rect.fromLTWH( @@ -355,6 +427,7 @@ class ScreenshotCapturer { transformedRect, Paint()..blendMode = ui.BlendMode.srcOver, ); + canvas.restore(); nativeImage.dispose(); } @@ -695,13 +768,15 @@ class ScreenshotCapturer { if (pvRects.masked.isNotEmpty) { _imageMaskPainter.drawMaskedImage( canvas, - pvRects.masked, + pvRects.masked + .map((m) => _maskCoveringMotion(m, pvRects.ancestor)) + .toList(), pixelRatio, ); } if (pvRects.captured.isNotEmpty) { final specs = pvRects.captured - .map((r) => _viewSpec(r, globalPosition)) + .map((v) => _viewSpec(v.data, globalPosition)) .toList(); final bytesList = await _nativeCommunicator.captureNativeScreenshots(specs); @@ -820,6 +895,52 @@ 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 be masked past its visible edge +/// and over the widgets below it. 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; +} + +/// The rect to mask, widened to cover a view at both the position it was +/// collected at and the position it now occupies. See [_maskCoveringMotion]. +@visibleForTesting +Rect maskRectCoveringMotion( + Rect collectedRect, + Matrix4 collectedTransform, + Rect freshRect, + Matrix4 freshTransform, +) { + final inverse = Matrix4.tryInvert(collectedTransform); + if (inverse == null) return collectedRect; + return collectedRect.expandToInclude(MatrixUtils.transformRect( + inverse.multiplied(freshTransform), + freshRect, + )); +} + @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..7fac0650 --- /dev/null +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -0,0 +1,180 @@ +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', () { + testWidgets('an unclipped view keeps its full paint bounds', + (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: Key('ancestor'), + width: 300, + height: 300, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(key: Key('view'), width: 200, height: 200), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 200, 200), + ); + }); + + testWidgets('a ClipRect ancestor trims the view to its visible region', + (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: Key('ancestor'), + width: 100, + height: 100, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: 0, + minHeight: 0, + maxWidth: 300, + maxHeight: 300, + child: SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 100, 100), + ); + }); + + testWidgets('a farther ancestor clip is applied, not just the nearest', + (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: Key('ancestor'), + width: 300, + height: 300, + // The outer clip (60) is tighter than the nearest one (80), so a + // result of 60 proves the walk kept going past the first clip. + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300, + height: 60, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 300, + child: SizedBox( + height: 80, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 300, + child: SizedBox( + key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ).height, + 60, + ); + }); + + testWidgets('a null ancestor walks to the root without throwing', + (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox(key: Key('view'), width: 50, height: 50), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + null, + ), + const Rect.fromLTWH(0, 0, 50, 50), + ); + }); + }); + + group('maskRectCoveringMotion', () { + final identity = Matrix4.identity(); + + test('a still view keeps exactly its collected rect', () { + const collected = Rect.fromLTWH(0, 0, 200, 100); + expect(maskRectCoveringMotion(collected, identity, collected, identity), + collected); + }); + + test('a view that scrolled is covered at both positions', () { + const collected = Rect.fromLTWH(0, 0, 200, 100); + expect( + maskRectCoveringMotion(collected, identity, collected, + Matrix4.translationValues(0, -40, 0)), + const Rect.fromLTWH(0, -40, 200, 140), + ); + }); + + test('a shrinking clip still covers the larger collected rect', () { + const collected = Rect.fromLTWH(0, 0, 200, 100); + expect( + maskRectCoveringMotion( + collected, identity, const Rect.fromLTWH(0, 0, 200, 40), identity), + collected, + ); + }); + + test('a non-invertible transform falls back to the collected rect', () { + const collected = Rect.fromLTWH(0, 0, 200, 100); + expect( + maskRectCoveringMotion(collected, Matrix4.zero(), collected, identity), + collected, + ); + }); + }); +} From fad6b4051786c02ed30028afc19213ba917ecca5 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 27 Aug 2026 15:34:27 -0400 Subject: [PATCH 02/17] fix(replay): harden the platform view mask walk after review Three follow-ups from the pre-PR review, all on paths this change introduced or edits. The capture-failure fallback in _compositeRevealedView painted its mask from the view's full frame, which the revealed path deliberately keeps because the native side matches the view by it. That is the same oversized mask this change exists to remove, still reachable whenever a native capture returns nothing: iOS has no capture path for a non-WKWebView, and Android's software-canvas fallback cannot read a SurfaceView. It now masks the visible region. describeApproximatePaintClip is documented as an approximation for the semantics phase and carries no guarantee of being a superset of the real paint clip; RenderViewportBase subtracts a sliver overlap correction that makes it smaller, so the walk uses the viewport's own bounds there. The walk also calls application code through CustomClipper.getApproximateClipRect, and a throw was propagating out to _addIfNew, which logged and added no rect at all, turning masking off for that view. A throwing clip is now skipped, keeping the wider bounds. Also drops the mask re-measure added earlier in this branch. Rect collection and the mask paint are separated by no await, so it re-read an identical render tree and could never observe motion, while the changeset claimed a protection it did not provide. The frame-late leak it was meant to cover is pre-existing and tracked separately. Verified on an Android emulator with forced capture failure and a throwing clipper: the sentinel below a revealed view went from fully covered to visible, and a masked view under a throwing clipper went from 123,600 exposed pixels to zero. The seven original spill cases re-ran byte-identical on Android and iOS. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/platform-view-mask-spill.md | 4 +- example/lib/platform_view_spill_screen.dart | 255 ------------------ .../screenshot/screenshot_capturer.dart | 119 +++----- .../test/platform_view_clip_test.dart | 84 +++--- 4 files changed, 89 insertions(+), 373 deletions(-) delete mode 100644 example/lib/platform_view_spill_screen.dart diff --git a/.changeset/platform-view-mask-spill.md b/.changeset/platform-view-mask-spill.md index e33d3faf..0b815012 100644 --- a/.changeset/platform-view-mask-spill.md +++ b/.changeset/platform-view-mask-spill.md @@ -2,6 +2,6 @@ 'posthog_flutter': patch --- -Fix session replay masking a platform view past its visible bounds. A map, WebView, or camera preview inside a `ClipRect` or a scroll view reports its full, unclipped size, so the mask was drawn over the whole view and covered the widgets sitting below it. The mask is now intersected with the ancestor clip chain, and revealed views are clipped to the same region when they are composited. +Fix session replay masking a platform view past its visible bounds. A map, WebView, or camera preview inside a `ClipRect` or a scroll view reports its full, unclipped size, so the mask was drawn over the whole view and covered the widgets sitting below it. The mask is now intersected with the ancestor clip chain, and a revealed view is clipped to the same region when it is composited. -The mask is also re-measured immediately before it is painted and widened to cover both positions if the tree moved mid-capture, so trimming it to the visible region cannot expose masked content during a scroll. +Masked regions are correspondingly smaller than before: content that a clipped platform view never actually showed on screen is no longer covered in replay. diff --git a/example/lib/platform_view_spill_screen.dart b/example/lib/platform_view_spill_screen.dart deleted file mode 100644 index dc998621..00000000 --- a/example/lib/platform_view_spill_screen.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:posthog_flutter/posthog_flutter.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -/// Repro cases for the platform-view mask/reveal spill. -/// -/// Every case puts a sentinel banner directly below a clipped platform view. -/// The banner is plain Flutter, so replay must always show it — if a case's -/// banner is missing from a replay frame, the platform view's rect overran its -/// visible region and painted over it. -const spillSentinels = { - 'clipped_masked': 'SPILLONE', - 'scrolled_masked': 'SPILLTWO', - 'nested_clip_masked': 'SPILLTHREE', - 'clipped_revealed': 'SPILLFOUR', - 'scrolled_revealed': 'SPILLFIVE', - 'masked_over_revealed': 'SPILLSIX', - 'unclipped_masked_control': 'SPILLSEVEN', -}; - -WebViewController _wv() => - WebViewController()..loadRequest(Uri.parse('https://www.wikipedia.org')); - -class _Sentinel extends StatelessWidget { - final String token; - const _Sentinel(this.token); - - @override - Widget build(BuildContext context) => Container( - height: 90, - width: double.infinity, - color: const Color(0xFFFFE24A), - alignment: Alignment.center, - child: Text( - token, - style: const TextStyle( - color: Colors.black, - fontSize: 34, - fontWeight: FontWeight.bold, - ), - ), - ); -} - -/// A tall platform view trimmed by a `ClipRect`, sentinel directly below. -class ClippedMasked extends StatefulWidget { - final PostHogPlatformViewPrivacy privacy; - final String token; - const ClippedMasked({super.key, required this.privacy, required this.token}); - @override - State createState() => _ClippedMaskedState(); -} - -class _ClippedMaskedState extends State { - late final WebViewController _c = _wv(); - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text("spill repro")), - body: Column( - children: [ - ClipRect( - child: SizedBox( - height: 160, - child: OverflowBox( - alignment: Alignment.topLeft, - minHeight: 0, - maxHeight: 520, - child: SizedBox( - height: 520, - child: PostHogPlatformView( - privacy: widget.privacy, - child: WebViewWidget(controller: _c), - ), - ), - ), - ), - ), - _Sentinel(widget.token), - ], - ), - ); -} - -/// Nested clips: the innermost one wins and the mask must respect it. -class NestedClipMasked extends StatefulWidget { - final String token; - const NestedClipMasked({super.key, required this.token}); - @override - State createState() => _NestedClipMaskedState(); -} - -class _NestedClipMaskedState extends State { - late final WebViewController _c = _wv(); - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text("spill repro")), - body: Column( - children: [ - ClipRect( - child: SizedBox( - height: 300, - child: ClipRect( - child: SizedBox( - height: 140, - child: OverflowBox( - alignment: Alignment.topLeft, - minHeight: 0, - maxHeight: 520, - child: SizedBox( - height: 520, - child: PostHogPlatformView( - privacy: PostHogPlatformViewPrivacy.mask, - child: WebViewWidget(controller: _c), - ), - ), - ), - ), - ), - ), - ), - _Sentinel(widget.token), - ], - ), - ); -} - -/// A platform view scrolled half out of a viewport, sentinel pinned below it. -class ScrolledPartlyOut extends StatefulWidget { - final PostHogPlatformViewPrivacy privacy; - final String token; - const ScrolledPartlyOut({ - super.key, - required this.privacy, - required this.token, - }); - @override - State createState() => _ScrolledPartlyOutState(); -} - -class _ScrolledPartlyOutState extends State { - late final WebViewController _c = _wv(); - final _controller = ScrollController(); - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_controller.hasClients) _controller.jumpTo(260); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text("spill repro")), - body: ListView( - controller: _controller, - children: [ - const SizedBox(height: 40), - SizedBox( - height: 460, - child: PostHogPlatformView( - privacy: widget.privacy, - child: WebViewWidget(controller: _c), - ), - ), - _Sentinel(widget.token), - const SizedBox(height: 700), - ], - ), - ); -} - -/// A masked view directly above a revealed one: the mask must not overrun and -/// black out the view the developer asked to reveal. -class MaskedOverRevealed extends StatefulWidget { - final String token; - const MaskedOverRevealed({super.key, required this.token}); - @override - State createState() => _MaskedOverRevealedState(); -} - -class _MaskedOverRevealedState extends State { - late final WebViewController _top = _wv(); - late final WebViewController _bottom = _wv(); - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text("spill repro")), - body: Column( - children: [ - ClipRect( - child: SizedBox( - height: 150, - child: OverflowBox( - alignment: Alignment.topLeft, - minHeight: 0, - maxHeight: 520, - child: SizedBox( - height: 520, - child: PostHogPlatformView( - privacy: PostHogPlatformViewPrivacy.mask, - child: WebViewWidget(controller: _top), - ), - ), - ), - ), - ), - _Sentinel(widget.token), - Expanded( - child: PostHogPlatformView( - privacy: PostHogPlatformViewPrivacy.capture, - child: WebViewWidget(controller: _bottom), - ), - ), - ], - ), - ); -} - -/// Control: no clip anywhere. The mask must be unchanged by the fix. -class UnclippedMaskedControl extends StatefulWidget { - final String token; - const UnclippedMaskedControl({super.key, required this.token}); - @override - State createState() => _UnclippedMaskedControlState(); -} - -class _UnclippedMaskedControlState extends State { - late final WebViewController _c = _wv(); - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text("spill repro")), - body: Column( - children: [ - SizedBox( - height: 200, - child: PostHogPlatformView( - privacy: PostHogPlatformViewPrivacy.mask, - child: WebViewWidget(controller: _c), - ), - ), - _Sentinel(widget.token), - ], - ), - ); -} diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 543c8afe..685b0c55 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -60,14 +60,6 @@ class ViewTreeSnapshotStatus { ViewTreeSnapshotStatus(this.sentMetaEvent); } -/// A masked platform view, kept with its [RenderBox] so its mask can be -/// re-measured immediately before it is painted. -class _MaskedView { - final RenderBox ro; - final ElementData data; - const _MaskedView({required this.ro, required this.data}); -} - /// A revealed platform view. [data] carries the view's own bounds, which the /// native side uses to find and crop it; [visibleRect] is the part an ancestor /// clip leaves on screen, which is all we are allowed to paint. @@ -78,17 +70,9 @@ class _CapturedView { } class _PlatformViewRects { - final List<_MaskedView> masked; + final List masked; final List<_CapturedView> captured; - - /// The screenshot container the rects are relative to, kept for re-measuring. - final RenderObject? ancestor; - - const _PlatformViewRects({ - required this.masked, - required this.captured, - required this.ancestor, - }); + const _PlatformViewRects({required this.masked, required this.captured}); } class ScreenshotCapturer { @@ -268,7 +252,7 @@ class ScreenshotCapturer { _PlatformViewRects _collectPlatformViewRects( PostHogPlatformViewPrivacy defaultPolicy) { - final masked = <_MaskedView>[]; + final masked = []; final captured = <_CapturedView>[]; final ancestor = PostHogMaskController.instance.containerKey.currentContext ?.findRenderObject(); @@ -284,14 +268,13 @@ class ScreenshotCapturer { printIfDebug( 'Found ${masked.length} masked and ${captured.length} captured platform view rect(s)'); } - return _PlatformViewRects( - masked: masked, captured: captured, ancestor: ancestor); + return _PlatformViewRects(masked: masked, captured: captured); } void _visitElementForPlatformViews( Element element, RenderObject? ancestor, - List<_MaskedView> masked, + List masked, List<_CapturedView> captured, Set seen, PostHogPlatformViewPrivacy inheritedPolicy, @@ -314,7 +297,7 @@ class ScreenshotCapturer { void _addIfNew( RenderBox ro, RenderObject? ancestor, - List<_MaskedView> masked, + List masked, List<_CapturedView> captured, Set seen, PostHogPlatformViewPrivacy policy, @@ -340,13 +323,10 @@ class ScreenshotCapturer { visibleRect: visible, )); } else { - masked.add(_MaskedView( - ro: ro, - data: ElementData( - rect: visible, - type: 'platformView', - transform: transform, - ), + masked.add(ElementData( + rect: visible, + type: 'platformView', + transform: transform, )); } } catch (e) { @@ -366,33 +346,6 @@ class ScreenshotCapturer { }; } - /// Re-measures [view] and widens its mask to cover both where the view was - /// when the rect was collected and where it is now. - /// - /// Clipping the mask to the visible region makes it tight enough to expose - /// content whenever the tree moves between collection and this paint — the - /// oversized rect used to hide that slop. Covering both positions fails - /// closed; when nothing moved the two rects are equal and this is a no-op. - ElementData _maskCoveringMotion(_MaskedView view, RenderObject? ancestor) { - final collected = view.data; - final collectedTransform = collected.transform; - if (collectedTransform == null) return collected; - try { - if (!view.ro.attached || !view.ro.hasSize) return collected; - final fresh = clippedPaintBounds(view.ro, ancestor); - if (fresh.isEmpty) return collected; - return ElementData( - rect: maskRectCoveringMotion(collected.rect, collectedTransform, fresh, - view.ro.getTransformTo(ancestor)), - type: collected.type, - transform: collectedTransform, - ); - } catch (e) { - printIfDebug('Error re-measuring a masked platform view: $e'); - return collected; - } - } - Future _compositeRevealedView( Canvas canvas, _CapturedView view, @@ -405,13 +358,21 @@ class ScreenshotCapturer { final transform = viewRect.transform; if (transform == null) return; final transformedRect = MatrixUtils.transformRect(transform, viewRect.rect); + // [viewRect] carries the view's full frame because that is what the native + // side matches it by, so the fallback mask has to use the visible region + // instead or it covers the widgets below the ancestor clip. + final fallbackMask = ElementData( + rect: view.visibleRect, + type: viewRect.type, + transform: transform, + ); if (bytes == null) { - _imageMaskPainter.drawMaskedImage(canvas, [viewRect], pixelRatio); + _imageMaskPainter.drawMaskedImage(canvas, [fallbackMask], pixelRatio); return; } final nativeImage = await _decodeRawPixels(bytes, nativeW, nativeH); if (nativeImage == null) { - _imageMaskPainter.drawMaskedImage(canvas, [viewRect], pixelRatio); + _imageMaskPainter.drawMaskedImage(canvas, [fallbackMask], pixelRatio); return; } // The native request covers the view's own frame, because that is what the @@ -419,7 +380,8 @@ class ScreenshotCapturer { // keeps the revealed pixels inside the ancestor clip without changing what // the native side is asked for. canvas.save(); - canvas.clipRect(MatrixUtils.transformRect(transform, view.visibleRect)); + canvas.clipRect(MatrixUtils.transformRect(transform, view.visibleRect), + doAntiAlias: false); canvas.drawImageRect( nativeImage, Rect.fromLTWH( @@ -768,9 +730,7 @@ class ScreenshotCapturer { if (pvRects.masked.isNotEmpty) { _imageMaskPainter.drawMaskedImage( canvas, - pvRects.masked - .map((m) => _maskCoveringMotion(m, pvRects.ancestor)) - .toList(), + pvRects.masked, pixelRatio, ); } @@ -909,7 +869,21 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { RenderObject child = ro; RenderObject? node = ro.parent; while (node != null && !identical(node, ancestor)) { - final clip = node.describeApproximatePaintClip(child); + // describeApproximatePaintClip is a semantics helper with no guarantee of + // being a superset of the real paint clip, and RenderViewportBase subtracts + // a sliver overlap correction that makes it smaller. Use the viewport's own + // bounds there so an overlapping header cannot shrink a mask. + Rect? clip; + try { + clip = node is RenderViewportBase && node.hasSize + ? Offset.zero & node.size + // A CustomClipper runs app code here; widening beyond the real clip + // only over-masks, while letting it throw would drop the mask. + : node.describeApproximatePaintClip(child); + } catch (e) { + printIfDebug('Skipping an ancestor clip that threw: $e'); + clip = null; + } if (clip != null) { // The clip is in node's coordinates; map it into ro's frame. final toRo = Matrix4.tryInvert(ro.getTransformTo(node)); @@ -924,23 +898,6 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { return clipped; } -/// The rect to mask, widened to cover a view at both the position it was -/// collected at and the position it now occupies. See [_maskCoveringMotion]. -@visibleForTesting -Rect maskRectCoveringMotion( - Rect collectedRect, - Matrix4 collectedTransform, - Rect freshRect, - Matrix4 freshTransform, -) { - final inverse = Matrix4.tryInvert(collectedTransform); - if (inverse == null) return collectedRect; - return collectedRect.expandToInclude(MatrixUtils.transformRect( - inverse.multiplied(freshTransform), - freshRect, - )); -} - @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 index 7fac0650..ed5c844f 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -121,59 +121,73 @@ void main() { ); }); - testWidgets('a null ancestor walks to the root without throwing', + testWidgets('a clip is mapped through a non-zero ancestor offset', (tester) async { await tester.pumpWidget( const Directionality( textDirection: TextDirection.ltr, - child: Center( - child: SizedBox(key: Key('view'), width: 50, height: 50), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: Key('ancestor'), + width: 600, + height: 600, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300, + height: 300, + child: ClipRect( + child: Padding( + padding: EdgeInsets.only(left: 50, top: 100), + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: 0, + minHeight: 0, + maxWidth: 400, + maxHeight: 400, + child: + SizedBox(key: Key('view'), width: 400, height: 400), + ), + ), + ), + ), + ), + ), ), ), ); + // The clip is 300x300 in the ClipRect's space but the view sits at + // (50, 100) inside it, so in the view's own space only 250x200 survives. + // Using the forward transform instead of its inverse would give + // (50, 100, 350, 400) here. expect( clippedPaintBounds( tester.renderObject(find.byKey(const Key('view'))), - null, + tester.renderObject(find.byKey(const Key('ancestor'))), ), - const Rect.fromLTWH(0, 0, 50, 50), + const Rect.fromLTWH(0, 0, 250, 200), ); }); - }); - - group('maskRectCoveringMotion', () { - final identity = Matrix4.identity(); - test('a still view keeps exactly its collected rect', () { - const collected = Rect.fromLTWH(0, 0, 200, 100); - expect(maskRectCoveringMotion(collected, identity, collected, identity), - collected); - }); - - test('a view that scrolled is covered at both positions', () { - const collected = Rect.fromLTWH(0, 0, 200, 100); - expect( - maskRectCoveringMotion(collected, identity, collected, - Matrix4.translationValues(0, -40, 0)), - const Rect.fromLTWH(0, -40, 200, 140), - ); - }); - - test('a shrinking clip still covers the larger collected rect', () { - const collected = Rect.fromLTWH(0, 0, 200, 100); - expect( - maskRectCoveringMotion( - collected, identity, const Rect.fromLTWH(0, 0, 200, 40), identity), - collected, + testWidgets('a null ancestor walks to the root without throwing', + (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox(key: Key('view'), width: 50, height: 50), + ), + ), ); - }); - test('a non-invertible transform falls back to the collected rect', () { - const collected = Rect.fromLTWH(0, 0, 200, 100); expect( - maskRectCoveringMotion(collected, Matrix4.zero(), collected, identity), - collected, + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + null, + ), + const Rect.fromLTWH(0, 0, 50, 50), ); }); }); From c3cc86c7648ab1af328d0087e71d83f771e5608b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 27 Aug 2026 15:50:31 -0400 Subject: [PATCH 03/17] fix(replay): drop the unproven sliver overlap branch from the clip walk The walk was substituting a viewport's own bounds for describeApproximatePaintClip, on the grounds that RenderViewportBase subtracts a sliver overlap correction and so can report a clip smaller than the one it actually paints. That reasoning holds against Flutter's source, but a repro built on a pinned translucent SliverAppBar produced frames identical to the unpatched build, so nothing here was ever observed failing. Shipping a privacy change that cannot be demonstrated is worse than leaving the case open. The guard around the same call stays. That one is reproducible: a CustomClipper throwing from application code was letting the exception escape to _addIfNew, which added no rect at all and turned masking off for the view. On an Android emulator that leaked 123,600 pixels of otherwise-masked content, and none with the guard. Re-verified after the removal: 7/7 spill cases still pass on an Android emulator and an iOS simulator with pixel counts unchanged, and the capture failure and throwing clipper cases still pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/replay/screenshot/screenshot_capturer.dart | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 685b0c55..4ea42c3e 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -869,17 +869,12 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { RenderObject child = ro; RenderObject? node = ro.parent; while (node != null && !identical(node, ancestor)) { - // describeApproximatePaintClip is a semantics helper with no guarantee of - // being a superset of the real paint clip, and RenderViewportBase subtracts - // a sliver overlap correction that makes it smaller. Use the viewport's own - // bounds there so an overlapping header cannot shrink a mask. + // This can run application code through a CustomClipper. Letting it throw + // would drop the mask for this view entirely, so a failing clip is skipped + // and the wider, unclipped bounds survive. Rect? clip; try { - clip = node is RenderViewportBase && node.hasSize - ? Offset.zero & node.size - // A CustomClipper runs app code here; widening beyond the real clip - // only over-masks, while letting it throw would drop the mask. - : node.describeApproximatePaintClip(child); + clip = node.describeApproximatePaintClip(child); } catch (e) { printIfDebug('Skipping an ancestor clip that threw: $e'); clip = null; From b9df3e8cea4d305f6ea9f7a545facf54adfec3c9 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 27 Aug 2026 16:17:07 -0400 Subject: [PATCH 04/17] test(replay): cover the scroll viewport and failing clip paths Every committed expectation for clippedPaintBounds had origin (0, 0), so an implementation that dropped the visible rect's origin would have passed the whole suite while masking the wrong band of any scrolled platform view. None of them used a scroll view either, even though a viewport reaches describeApproximatePaintClip through a different framework implementation than a ClipRect does. Two cases now cover a view straddling each viewport edge, the second expecting a non-zero origin. The guard for a clipper that throws was verified on a device but pinned by nothing. RenderCustomClip asks getApproximateClipRect rather than getClip, so the obvious form of that test never reaches the walk and passes vacuously; the same is true of a control clipper that leaves getApproximateClipRect at its default, which returns the full box rather than the clip. Both are written so the throwing case and its control disagree. Also records why the composite clip disables antialiasing, that visibleRect is in the view's own coordinates, and that the walk depends on describeApproximatePaintClip over-approximating the real clip. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/platform-view-mask-spill.md | 2 +- .../screenshot/screenshot_capturer.dart | 17 +- .../test/platform_view_clip_test.dart | 170 ++++++++++++++++++ 3 files changed, 182 insertions(+), 7 deletions(-) diff --git a/.changeset/platform-view-mask-spill.md b/.changeset/platform-view-mask-spill.md index 0b815012..191b2b97 100644 --- a/.changeset/platform-view-mask-spill.md +++ b/.changeset/platform-view-mask-spill.md @@ -2,6 +2,6 @@ 'posthog_flutter': patch --- -Fix session replay masking a platform view past its visible bounds. A map, WebView, or camera preview inside a `ClipRect` or a scroll view reports its full, unclipped size, so the mask was drawn over the whole view and covered the widgets sitting below it. The mask is now intersected with the ancestor clip chain, and a revealed view is clipped to the same region when it is composited. +Fix session replay masking a platform view past its visible bounds. A map, WebView, or camera preview inside a `ClipRect` or a scroll view reports its full, unclipped size, so the mask was drawn over the whole view and covered unrelated widgets outside that clip. The mask is now intersected with the ancestor clip chain, a revealed view is clipped to the same region when it is composited, and a revealed view whose native capture fails falls back to masking only that region too. Masked regions are correspondingly smaller than before: content that a clipped platform view never actually showed on screen is no longer covered in replay. diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 4ea42c3e..b2cf78a3 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -62,7 +62,8 @@ class ViewTreeSnapshotStatus { /// A revealed platform view. [data] carries the view's own bounds, which the /// native side uses to find and crop it; [visibleRect] is the part an ancestor -/// clip leaves on screen, which is all we are allowed to paint. +/// clip leaves visible, which is all we are allowed to paint. Both are in the +/// view's own coordinates and share [data]'s transform. class _CapturedView { final ElementData data; final Rect visibleRect; @@ -358,9 +359,7 @@ class ScreenshotCapturer { final transform = viewRect.transform; if (transform == null) return; final transformedRect = MatrixUtils.transformRect(transform, viewRect.rect); - // [viewRect] carries the view's full frame because that is what the native - // side matches it by, so the fallback mask has to use the visible region - // instead or it covers the widgets below the ancestor clip. + // Masking [viewRect] here would cover everything outside the ancestor clip. final fallbackMask = ElementData( rect: view.visibleRect, type: viewRect.type, @@ -380,6 +379,8 @@ class ScreenshotCapturer { // keeps the revealed pixels inside the ancestor clip without changing what // the native side is asked for. canvas.save(); + // An antialiased clip edge blends the native pixels roughly a pixel past + // the ancestor clip, which is a hairline of the leak this clip prevents. canvas.clipRect(MatrixUtils.transformRect(transform, view.visibleRect), doAntiAlias: false); canvas.drawImageRect( @@ -861,8 +862,12 @@ class ScreenshotCapturer { /// /// A platform view reports its full, unclipped paint bounds, so a map inside a /// scroll view or a `ClipRect` would otherwise be masked past its visible edge -/// and over the widgets below it. Returns [Rect.zero] when the view is fully -/// clipped away. +/// and over the widgets outside that clip. Returns [Rect.zero] when the view is +/// fully clipped away. +/// +/// Relies on `describeApproximatePaintClip` over-approximating the real paint +/// clip, which every framework implementation does. A `CustomClipper` whose +/// `getApproximateClipRect` reports less than its own `getClip` will under-mask. @visibleForTesting Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { var clipped = ro.paintBounds; diff --git a/posthog_flutter/test/platform_view_clip_test.dart b/posthog_flutter/test/platform_view_clip_test.dart index ed5c844f..cec1624d 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -191,4 +191,174 @@ void main() { ); }); }); + + group('clippedPaintBounds — scroll viewport', () { + // RenderViewportBase.describeApproximatePaintClip is a different framework + // implementation from RenderCustomClip, and a scrolled view is the only + // case that produces a non-zero origin. + Widget list(ScrollController controller) => Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ListView( + controller: controller, + children: const [ + SizedBox(height: 250), + SizedBox(key: Key('view'), width: 300, height: 200), + SizedBox(height: 400), + ], + ), + ), + ), + ); + + Rect boundsOf(WidgetTester tester) => clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ); + + testWidgets('a view straddling the bottom edge keeps only the visible band', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(list(controller)); + + expect(boundsOf(tester), const Rect.fromLTRB(0, 0, 300, 50)); + }); + + testWidgets('a view straddling the top edge is trimmed from its origin', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(list(controller)); + + controller.jumpTo(400); + await tester.pump(); + + expect(boundsOf(tester), const Rect.fromLTRB(0, 150, 300, 200)); + }); + }); + + group('clippedPaintBounds — failing and degenerate clips', () { + testWidgets('a clipper that throws leaves the full paint bounds', + (tester) async { + // RenderCustomClip asks getApproximateClipRect, not getClip, so a clipper + // that throws from getClip never reaches the walk and would prove nothing. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ClipRect( + clipper: _ThrowingApproxClipper(), + child: + const SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 300, 300), + ); + }); + + testWidgets('the same clipper not throwing reports its narrow clip', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ClipRect( + clipper: _NarrowClipper(), + child: + const SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 100, 100), + ); + }); + + testWidgets('a view clipped entirely away reports an empty rect', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ClipRect( + child: Transform.translate( + offset: const Offset(1000, 1000), + child: + const SizedBox(key: Key('view'), width: 100, height: 100), + ), + ), + ), + ), + ), + ); + + final bounds = clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ); + expect(bounds.isEmpty, isTrue); + expect(bounds, Rect.zero); + }); + }); +} + +class _ThrowingApproxClipper extends CustomClipper { + @override + Rect getClip(Size size) => const Rect.fromLTWH(0, 0, 100, 100); + + @override + Rect getApproximateClipRect(Size size) => throw StateError('from app code'); + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} + +/// The non-throwing control for [_ThrowingApproxClipper]: identical shape, so +/// the narrow rect here proves the throwing case really did fall back. +class _NarrowClipper extends CustomClipper { + @override + Rect getClip(Size size) => const Rect.fromLTWH(0, 0, 100, 100); + + @override + Rect getApproximateClipRect(Size size) => getClip(size); + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; } From 5a25d6515083974c9a203c9fa104a18c48560a26 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 27 Aug 2026 18:58:03 -0400 Subject: [PATCH 05/17] fix(replay): keep the full bounds when an ancestor transform is projective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mapping an ancestor's clip back into the view's coordinates goes through MatrixUtils.transformRect, which maps the four corners and takes their bounding box. That over-approximates for an affine matrix, which is what makes it safe for a mask, but it does not hold once the matrix carries perspective: a corner crossing the w = 0 plane makes the hull arbitrarily smaller than the real region, so the mask shrinks, and where the result misses the paint bounds entirely the view ends up with no mask at all. A ListWheelScrollView reaches this in ordinary use — it gives each child a perspective transform and reports a clip — so a masked platform view inside a CupertinoPicker lost its mask completely. Before this change the masked rect was the full paint bounds and the painter applied the same matrix as the content, so coverage was exact; the clip walk is what introduced the gap. An ancestor transform with a non-zero perspective row now keeps the unclipped bounds, matching how the walk already treats a clip it cannot map. The mask can still be wider than necessary there, never narrower. Also corrects a doc comment that claimed every framework implementation over-approximates the paint clip. RenderViewportBase does not: it subtracts a sliver overlap correction and reports what is semantically visible. Verified on an iPhone 14 Pro Max, iOS 26.6.1: the seven spill cases go from 3 passing on main to 7 with this change, and the unclipped control is identical across both runs. Co-Authored-By: Claude Opus 5 (1M context) --- .../screenshot/screenshot_capturer.dart | 32 +++++--- .../test/platform_view_clip_test.dart | 77 +++++++++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index b2cf78a3..415bad5b 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -865,9 +865,11 @@ class ScreenshotCapturer { /// and over the widgets outside that clip. Returns [Rect.zero] when the view is /// fully clipped away. /// -/// Relies on `describeApproximatePaintClip` over-approximating the real paint -/// clip, which every framework implementation does. A `CustomClipper` whose -/// `getApproximateClipRect` reports less than its own `getClip` will under-mask. +/// `describeApproximatePaintClip` answers "what can a viewer see", which is what +/// masking wants, but it is not a paint-clip guarantee: `RenderViewportBase` +/// subtracts a sliver overlap correction, and a `CustomClipper` may report less +/// than its own `getClip`. Either under-masks a view drawn under a translucent +/// overlapping header. @visibleForTesting Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { var clipped = ro.paintBounds; @@ -878,19 +880,27 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { // would drop the mask for this view entirely, so a failing clip is skipped // and the wider, unclipped bounds survive. Rect? clip; + Matrix4? toRo; try { clip = node.describeApproximatePaintClip(child); + if (clip != null) { + final toNode = ro.getTransformTo(node); + // transformRect maps the four corners and takes their bounding box, + // which only over-approximates for an affine matrix. Under perspective + // the mapped hull can be far smaller than the real one, which would + // shrink the mask or drop it — so those keep the unclipped bounds. + final m = toNode.storage; + final projective = m[3] != 0 || m[7] != 0 || m[11] != 0; + toRo = projective ? null : Matrix4.tryInvert(toNode); + } } catch (e) { - printIfDebug('Skipping an ancestor clip that threw: $e'); + printIfDebug('Skipping an ancestor clip that could not be mapped: $e'); clip = null; + toRo = null; } - 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; - } + if (clip != null && toRo != null) { + clipped = clipped.intersect(MatrixUtils.transformRect(toRo, clip)); + if (clipped.isEmpty) return Rect.zero; } child = node; node = node.parent; diff --git a/posthog_flutter/test/platform_view_clip_test.dart b/posthog_flutter/test/platform_view_clip_test.dart index cec1624d..f938d14f 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -243,6 +243,83 @@ void main() { }); }); + group('clippedPaintBounds — projective ancestors', () { + testWidgets( + 'a perspective ancestor keeps the full bounds, not a shrunk one', + (tester) async { + // Mapping a rect through the inverse of a projective matrix by its four + // corners can produce a far smaller rect than the real one, which would + // silently shrink or drop the mask. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 400, + height: 400, + child: ClipRect( + child: Transform( + transform: Matrix4.identity() + ..setEntry(3, 2, 0.004) + ..rotateX(1.4), + child: + const SizedBox(key: Key('view'), width: 200, height: 200), + ), + ), + ), + ), + ), + ); + + final view = + tester.renderObject(find.byKey(const Key('view'))); + expect( + clippedPaintBounds( + view, + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + view.paintBounds, + ); + }); + + testWidgets('a ListWheelScrollView item keeps a non-empty mask rect', + (tester) async { + // CupertinoPicker's viewport applies a per-child perspective transform + // and reports a clip, so its items hit the projective path in ordinary use. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 400, + height: 400, + child: ListWheelScrollView( + itemExtent: 120, + children: List.generate( + 7, + (i) => SizedBox(key: Key('i$i'), width: 300, height: 120), + ), + ), + ), + ), + ), + ); + + final ancestor = + tester.renderObject(find.byKey(const Key('ancestor'))); + // Only the items the wheel actually builds are on screen. + for (final key in ['i1', 'i2']) { + final item = tester.renderObject(find.byKey(Key(key))); + expect(clippedPaintBounds(item, ancestor).isEmpty, isFalse, + reason: '$key is on screen and must keep a mask'); + } + }); + }); + group('clippedPaintBounds — failing and degenerate clips', () { testWidgets('a clipper that throws leaves the full paint bounds', (tester) async { From caf06301b9d9856415ce376074f651b4350dc5e9 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 27 Aug 2026 22:42:06 -0400 Subject: [PATCH 06/17] fix(replay): mask the band a pinned sliver header overlaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A viewport answers describeApproximatePaintClip with what a viewer can see, which subtracts the overlap a pinned or floating sliver header sits over. It paints with its own bounds, so that band is still on screen whenever the header is translucent, and deriving the mask from the reported clip left it uncovered. Measured on a 300x300 viewport with an 80px pinned header, a platform view scrolled to 100 masked from y=100 instead of y=20 — exactly the header band missing, on content that was masked before this branch. The walk now uses the viewport's own bounds. Where a sliver reports no overlap the two are the same rect, so an ordinary ListView or SingleChildScrollView is unaffected: the existing scroll tests assert the same values, and the seven device cases return byte-identical pixel counts on an Android emulator. Also pins the third term of the projective guard. Rotating outside a perspective transform leaves the first two terms zero, and without storage[11] the mask for that subtree would shrink from 400px to 31px tall. Co-Authored-By: Claude Opus 5 (1M context) --- .../screenshot/screenshot_capturer.dart | 17 ++- .../test/platform_view_clip_test.dart | 104 ++++++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 415bad5b..26b7b5dd 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -359,7 +359,7 @@ class ScreenshotCapturer { final transform = viewRect.transform; if (transform == null) return; final transformedRect = MatrixUtils.transformRect(transform, viewRect.rect); - // Masking [viewRect] here would cover everything outside the ancestor clip. + // The native crop covers the whole view; only the clipped part may be painted. final fallbackMask = ElementData( rect: view.visibleRect, type: viewRect.type, @@ -865,11 +865,9 @@ class ScreenshotCapturer { /// and over the widgets outside that clip. Returns [Rect.zero] when the view is /// fully clipped away. /// -/// `describeApproximatePaintClip` answers "what can a viewer see", which is what -/// masking wants, but it is not a paint-clip guarantee: `RenderViewportBase` -/// subtracts a sliver overlap correction, and a `CustomClipper` may report less -/// than its own `getClip`. Either under-masks a view drawn under a translucent -/// overlapping header. +/// `describeApproximatePaintClip` is a semantics helper, not a paint-clip +/// guarantee: a `CustomClipper` may report less than its own `getClip`, which +/// would under-mask. @visibleForTesting Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { var clipped = ro.paintBounds; @@ -883,6 +881,13 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { Matrix4? toRo; try { clip = node.describeApproximatePaintClip(child); + // A viewport reports what a viewer can see, which subtracts the band an + // overlapping sliver header covers. The header may be translucent, so the + // view is still on screen there; the viewport's own bounds are the clip + // it actually paints with. + if (clip != null && node is RenderViewportBase && node.hasSize) { + clip = Offset.zero & node.size; + } if (clip != null) { final toNode = ro.getTransformTo(node); // transformRect maps the four corners and takes their bounding box, diff --git a/posthog_flutter/test/platform_view_clip_test.dart b/posthog_flutter/test/platform_view_clip_test.dart index f938d14f..b6a67d30 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -243,6 +243,53 @@ void main() { }); }); + group('clippedPaintBounds — overlapping sliver header', () { + testWidgets('a pinned header does not trim the band it overlaps', + (tester) async { + // A viewport reports what a viewer sees, minus the overlap a pinned + // header covers. That header can be translucent, so the band is still on + // screen and must stay masked. + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: CustomScrollView( + controller: controller, + slivers: [ + const SliverPersistentHeader( + pinned: true, delegate: _PinnedHeader()), + SliverToBoxAdapter( + child: Container( + key: const Key('view'), height: 200, color: null), + ), + const SliverToBoxAdapter(child: SizedBox(height: 800)), + ], + ), + ), + ), + ), + ); + + controller.jumpTo(100); + await tester.pump(); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTRB(0, 20, 300, 200), + ); + }); + }); + group('clippedPaintBounds — projective ancestors', () { testWidgets( 'a perspective ancestor keeps the full bounds, not a shrunk one', @@ -284,6 +331,45 @@ void main() { ); }); + testWidgets('perspective reached only through the third term still guards', + (tester) async { + // Rotating outside a perspective transform leaves storage[3] and [7] zero + // and only storage[11] set, so dropping that term would shrink the mask. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 400, + height: 400, + child: ClipRect( + child: Transform( + transform: Matrix4.rotationX(1.2), + child: Transform( + transform: Matrix4.identity()..setEntry(3, 2, 0.01), + child: const SizedBox( + key: Key('view'), width: 200, height: 200), + ), + ), + ), + ), + ), + ), + ); + + final view = + tester.renderObject(find.byKey(const Key('view'))); + expect( + clippedPaintBounds( + view, + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + view.paintBounds, + ); + }); + testWidgets('a ListWheelScrollView item keeps a non-empty mask rect', (tester) async { // CupertinoPicker's viewport applies a per-child perspective transform @@ -439,3 +525,21 @@ class _NarrowClipper extends CustomClipper { @override bool shouldReclip(covariant CustomClipper oldClipper) => false; } + +class _PinnedHeader extends SliverPersistentHeaderDelegate { + const _PinnedHeader(); + + @override + double get minExtent => 80; + + @override + double get maxExtent => 80; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlaps) => + const SizedBox.expand(); + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => + false; +} From b88e50150a81eb071f0f5848557bf58508d2b040 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 27 Aug 2026 23:01:32 -0400 Subject: [PATCH 07/17] chore(replay): trim the comments and shorten the changeset entry Drops the comments that restated the line under them or repeated a neighbour, and shortens the rest. What is left states a constraint that is not recoverable from the code: which rect the native side matches on, why the composite clip cannot antialias, why a throwing clipper is skipped, why a viewport's reported clip is not the one it paints with, and why a projective transform cannot go through transformRect. The changeset carried the rationale as well as the change. Release notes are read by someone deciding whether to upgrade, so it is now one line describing what they will see; the rest lives in the pull request. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/platform-view-mask-spill.md | 4 +-- .../screenshot/screenshot_capturer.dart | 31 ++++++------------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/.changeset/platform-view-mask-spill.md b/.changeset/platform-view-mask-spill.md index 191b2b97..05caec23 100644 --- a/.changeset/platform-view-mask-spill.md +++ b/.changeset/platform-view-mask-spill.md @@ -2,6 +2,4 @@ 'posthog_flutter': patch --- -Fix session replay masking a platform view past its visible bounds. A map, WebView, or camera preview inside a `ClipRect` or a scroll view reports its full, unclipped size, so the mask was drawn over the whole view and covered unrelated widgets outside that clip. The mask is now intersected with the ancestor clip chain, a revealed view is clipped to the same region when it is composited, and a revealed view whose native capture fails falls back to masking only that region too. - -Masked regions are correspondingly smaller than before: content that a clipped platform view never actually showed on screen is no longer covered in replay. +Fix session replay masking past a clipped map, WebView, or camera preview and hiding the widgets around it diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 26b7b5dd..aa9f3e3f 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -312,7 +312,6 @@ class ScreenshotCapturer { try { final transform = ro.getTransformTo(ancestor); final visible = clippedPaintBounds(ro, ancestor); - // A view an ancestor clips away entirely covers nothing on screen. if (visible.isEmpty) return; if (policy == PostHogPlatformViewPrivacy.capture) { captured.add(_CapturedView( @@ -374,13 +373,8 @@ class ScreenshotCapturer { _imageMaskPainter.drawMaskedImage(canvas, [fallbackMask], pixelRatio); return; } - // The native request covers the view's own frame, because that is what the - // platform matches it by. Clipping here, rather than shrinking the request, - // keeps the revealed pixels inside the ancestor clip without changing what - // the native side is asked for. canvas.save(); - // An antialiased clip edge blends the native pixels roughly a pixel past - // the ancestor clip, which is a hairline of the leak this clip prevents. + // An antialiased edge would blend native pixels a hairline past the clip. canvas.clipRect(MatrixUtils.transformRect(transform, view.visibleRect), doAntiAlias: false); canvas.drawImageRect( @@ -864,36 +858,29 @@ class ScreenshotCapturer { /// scroll view or a `ClipRect` would otherwise be masked past its visible edge /// and over the widgets outside that clip. Returns [Rect.zero] when the view is /// fully clipped away. -/// -/// `describeApproximatePaintClip` is a semantics helper, not a paint-clip -/// guarantee: a `CustomClipper` may report less than its own `getClip`, which -/// would under-mask. + @visibleForTesting Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { var clipped = ro.paintBounds; RenderObject child = ro; RenderObject? node = ro.parent; while (node != null && !identical(node, ancestor)) { - // This can run application code through a CustomClipper. Letting it throw - // would drop the mask for this view entirely, so a failing clip is skipped - // and the wider, unclipped bounds survive. + // A CustomClipper runs application code here; a throw would drop the mask + // entirely, so a failing clip is skipped and the wider bounds survive. Rect? clip; Matrix4? toRo; try { clip = node.describeApproximatePaintClip(child); - // A viewport reports what a viewer can see, which subtracts the band an - // overlapping sliver header covers. The header may be translucent, so the - // view is still on screen there; the viewport's own bounds are the clip - // it actually paints with. + // A viewport subtracts the band an overlapping sliver header covers, but + // that header may be translucent and it paints with its own bounds. if (clip != null && node is RenderViewportBase && node.hasSize) { clip = Offset.zero & node.size; } if (clip != null) { final toNode = ro.getTransformTo(node); - // transformRect maps the four corners and takes their bounding box, - // which only over-approximates for an affine matrix. Under perspective - // the mapped hull can be far smaller than the real one, which would - // shrink the mask or drop it — so those keep the unclipped bounds. + // transformRect's four-corner hull only over-approximates for an + // affine matrix; under perspective it can be far smaller than the real + // region, which would shrink the mask or drop it. final m = toNode.storage; final projective = m[3] != 0 || m[7] != 0 || m[11] != 0; toRo = projective ? null : Matrix4.tryInvert(toNode); From 43af7eba6220a6a5cc1a0b1acc1ea21d2cd05966 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 27 Aug 2026 23:19:19 -0400 Subject: [PATCH 08/17] fix(replay): mask the clip a custom clipper paints, not the one it reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CustomClipper answers getApproximateClipRect for the semantics layer, and it is allowed to report less than the region getClip actually clips to. The mask walk read that approximation, so a clipper reporting a 10x10 rect over a 100x100 clip left the difference uncovered — the view was on screen there and nothing masked it. The walk now reads getClip for the clip render objects that carry an app clipper, and falls back to the reported rect for everything else. A revealed view was also composited under a clip built from the bounding box of the transformed visible rect. Under a rotation or a skew that box is larger than the region itself, so native pixels could land outside the clip and over unrelated widgets. The canvas now carries the view's transform and clips in the view's own coordinates, which is exact for any affine transform and mirrors how the mask painter already works. Verified on an Android emulator and an iOS simulator: the seven spill cases stay at 7/7 with pixel counts unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../screenshot/screenshot_capturer.dart | 26 +++++++--- .../test/platform_view_clip_test.dart | 49 +++++++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index aa9f3e3f..0328fccf 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -357,7 +357,6 @@ class ScreenshotCapturer { final viewRect = view.data; final transform = viewRect.transform; if (transform == null) return; - final transformedRect = MatrixUtils.transformRect(transform, viewRect.rect); // The native crop covers the whole view; only the clipped part may be painted. final fallbackMask = ElementData( rect: view.visibleRect, @@ -374,14 +373,16 @@ class ScreenshotCapturer { return; } canvas.save(); - // An antialiased edge would blend native pixels a hairline past the clip. - canvas.clipRect(MatrixUtils.transformRect(transform, view.visibleRect), - doAntiAlias: false); + // Clipping in the view's own space keeps a rotated or skewed clip exact; + // its bounding box would let native pixels past the real clip edge. An + // antialiased edge would blend them a hairline past it too. + canvas.transform(transform.storage); + canvas.clipRect(view.visibleRect, doAntiAlias: false); canvas.drawImageRect( nativeImage, Rect.fromLTWH( 0, 0, nativeImage.width.toDouble(), nativeImage.height.toDouble()), - transformedRect, + viewRect.rect, Paint()..blendMode = ui.BlendMode.srcOver, ); canvas.restore(); @@ -859,6 +860,16 @@ class ScreenshotCapturer { /// and over the widgets outside that clip. Returns [Rect.zero] when the view is /// fully clipped away. +Rect? _appClipperBounds(RenderObject node) { + if (node is! RenderBox || !node.hasSize) return null; + final size = node.size; + if (node is RenderClipRect) return node.clipper?.getClip(size); + if (node is RenderClipOval) return node.clipper?.getClip(size); + if (node is RenderClipRRect) return node.clipper?.getClip(size).outerRect; + if (node is RenderClipPath) return node.clipper?.getClip(size).getBounds(); + return null; +} + @visibleForTesting Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { var clipped = ro.paintBounds; @@ -870,7 +881,10 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { Rect? clip; Matrix4? toRo; try { - clip = node.describeApproximatePaintClip(child); + // An app-supplied clipper may report an approximation smaller than the + // region it actually clips to, which would mask less than the view shows. + clip = + _appClipperBounds(node) ?? node.describeApproximatePaintClip(child); // A viewport subtracts the band an overlapping sliver header covers, but // that header may be translucent and it paints with its own bounds. if (clip != null && node is RenderViewportBase && node.hasSize) { diff --git a/posthog_flutter/test/platform_view_clip_test.dart b/posthog_flutter/test/platform_view_clip_test.dart index b6a67d30..63af39f4 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -407,10 +407,40 @@ void main() { }); group('clippedPaintBounds — failing and degenerate clips', () { + testWidgets('an under-reporting clipper does not shrink the mask', + (tester) async { + // getApproximateClipRect may report less than the clipper actually clips + // to; masking the smaller rect would leave the difference uncovered. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ClipRect( + clipper: _UnderReportingClipper(), + child: + const SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 100, 100), + ); + }); + testWidgets('a clipper that throws leaves the full paint bounds', (tester) async { - // RenderCustomClip asks getApproximateClipRect, not getClip, so a clipper - // that throws from getClip never reaches the walk and would prove nothing. await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, @@ -430,6 +460,10 @@ void main() { ), ); + // The framework hits the same throw while painting the clip; that is the + // app's own bug, and it must not stop the mask from being computed. + expect(tester.takeException(), isStateError); + expect( clippedPaintBounds( tester.renderObject(find.byKey(const Key('view'))), @@ -503,11 +537,20 @@ void main() { } class _ThrowingApproxClipper extends CustomClipper { + @override + Rect getClip(Size size) => throw StateError('from app code'); + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} + +/// Reports far less than it clips to, which is legal and would under-mask. +class _UnderReportingClipper extends CustomClipper { @override Rect getClip(Size size) => const Rect.fromLTWH(0, 0, 100, 100); @override - Rect getApproximateClipRect(Size size) => throw StateError('from app code'); + Rect getApproximateClipRect(Size size) => const Rect.fromLTWH(0, 0, 10, 10); @override bool shouldReclip(covariant CustomClipper oldClipper) => false; From 034f096000ab76e13f1620b3506bd1d9b8fc97b9 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 08:50:01 -0400 Subject: [PATCH 09/17] fix(replay): ignore a clipper attached with Clip.none, and let a viewport report its own clip A clip render object with Clip.none paints its child in full, and describes no clip. Reading the clipper directly skipped that check, so a narrow clipper attached with Clip.none shrank the mask over content Flutter draws unclipped. The clipper is now only consulted when the node actually clips. Reverts extending a viewport's clip to its full bounds. The band an overlapping sliver header covers is excluded from what the viewport reports, and masking it paints black over the header itself, which is the common case for a pinned header and a visible regression in the replay. The narrower report is right whenever that header is opaque; a translucent one leaves the band visible and unmasked, which is recorded on clippedPaintBounds as a known limitation. Verified on an Android emulator and an iOS simulator: seven spill cases at 7/7 with pixel counts unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../screenshot/screenshot_capturer.dart | 35 +++++++++++----- .../test/platform_view_clip_test.dart | 40 +++++++++++++++++-- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 0328fccf..22cc42f2 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -859,14 +859,36 @@ class ScreenshotCapturer { /// scroll view or a `ClipRect` would otherwise be masked past its visible edge /// and over the widgets outside that clip. Returns [Rect.zero] when the view is /// fully clipped away. +/// +/// A viewport reports the region a viewer can see, which excludes the band an +/// overlapping sliver header covers. That is what masking wants for the usual +/// opaque header — masking the band would black the header out of the replay — +/// but a translucent header leaves that band visible and unmasked. +/// The region [node] actually clips to, when it is a clip render object whose +/// app-supplied clipper may report a smaller approximation than it clips with. +/// +/// Returns null when the node clips nothing, so a clipper attached with +/// [Clip.none] cannot shrink a mask over content Flutter paints in full. Rect? _appClipperBounds(RenderObject node) { if (node is! RenderBox || !node.hasSize) return null; final size = node.size; - if (node is RenderClipRect) return node.clipper?.getClip(size); - if (node is RenderClipOval) return node.clipper?.getClip(size); - if (node is RenderClipRRect) return node.clipper?.getClip(size).outerRect; - if (node is RenderClipPath) return node.clipper?.getClip(size).getBounds(); + if (node is RenderClipRect) { + return node.clipBehavior == Clip.none ? null : node.clipper?.getClip(size); + } + if (node is RenderClipOval) { + return node.clipBehavior == Clip.none ? null : node.clipper?.getClip(size); + } + if (node is RenderClipRRect) { + return node.clipBehavior == Clip.none + ? null + : node.clipper?.getClip(size).outerRect; + } + if (node is RenderClipPath) { + return node.clipBehavior == Clip.none + ? null + : node.clipper?.getClip(size).getBounds(); + } return null; } @@ -885,11 +907,6 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { // region it actually clips to, which would mask less than the view shows. clip = _appClipperBounds(node) ?? node.describeApproximatePaintClip(child); - // A viewport subtracts the band an overlapping sliver header covers, but - // that header may be translucent and it paints with its own bounds. - if (clip != null && node is RenderViewportBase && node.hasSize) { - clip = Offset.zero & node.size; - } if (clip != null) { final toNode = ro.getTransformTo(node); // transformRect's four-corner hull only over-approximates for an diff --git a/posthog_flutter/test/platform_view_clip_test.dart b/posthog_flutter/test/platform_view_clip_test.dart index 63af39f4..5bbc1365 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -244,8 +244,7 @@ void main() { }); group('clippedPaintBounds — overlapping sliver header', () { - testWidgets('a pinned header does not trim the band it overlaps', - (tester) async { + testWidgets('a pinned header trims the band it covers', (tester) async { // A viewport reports what a viewer sees, minus the overlap a pinned // header covers. That header can be translucent, so the band is still on // screen and must stay masked. @@ -280,12 +279,15 @@ void main() { controller.jumpTo(100); await tester.pump(); + // The viewport excludes the band the header covers. That is right for an + // opaque header, and masking it would black the header out of the replay; + // a translucent header leaves the band visible and unmasked. expect( clippedPaintBounds( tester.renderObject(find.byKey(const Key('view'))), tester.renderObject(find.byKey(const Key('ancestor'))), ), - const Rect.fromLTRB(0, 20, 300, 200), + const Rect.fromLTRB(0, 100, 300, 200), ); }); }); @@ -407,6 +409,38 @@ void main() { }); group('clippedPaintBounds — failing and degenerate clips', () { + testWidgets('a clipper attached with Clip.none does not shrink the mask', + (tester) async { + // Flutter paints the view in full, so the clipper describes nothing. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ClipRect( + clipper: _NarrowClipper(), + clipBehavior: Clip.none, + child: + const SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 300, 300), + ); + }); + testWidgets('an under-reporting clipper does not shrink the mask', (tester) async { // getApproximateClipRect may report less than the clipper actually clips From 7cfe8ec5ceaae2c02fb23706959606a4174bf959 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 09:20:01 -0400 Subject: [PATCH 10/17] fix(replay): keep a non-finite clip out of the mask geometry, and fix two misplaced comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app clipper can return a rect carrying NaN, and a NaN rect is not empty: it passes every emptiness check on the way to the mask, and the mask is then never drawn. In a debug build the draw asserts and the frame is dropped; in release the draw is skipped and the frame ships with the view unmasked. A clip that is not finite is now discarded, which keeps the wider bounds like the other guards. The comment block describing clippedPaintBounds sat above a private helper that was added between it and the function, so dartdoc attached the whole thing — including the pinned header limitation — to the helper, and the function it describes had no documentation at all. It also read as being about the helper to anyone scanning the file. The block now sits on the function, and says that under a translucent header a TextureBox contributes its own pixels rather than just leaving the band unmasked. A test comment still argued for the behaviour reverted in 034f096, directly contradicting the expectation thirty lines below it. Adds coverage for the ClipRRect, ClipPath and non-finite paths, none of which any test constructed, and offsets the clip inside the ancestor so a walk measuring the wrong node's transform no longer passes. Verified on an Android emulator and an iOS simulator: seven spill cases at 7/7 with pixel counts unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/platform-view-mask-spill.md | 2 +- .../screenshot/screenshot_capturer.dart | 32 ++-- .../test/platform_view_clip_test.dart | 145 ++++++++++++++++-- 3 files changed, 155 insertions(+), 24 deletions(-) diff --git a/.changeset/platform-view-mask-spill.md b/.changeset/platform-view-mask-spill.md index 05caec23..a88bb071 100644 --- a/.changeset/platform-view-mask-spill.md +++ b/.changeset/platform-view-mask-spill.md @@ -2,4 +2,4 @@ 'posthog_flutter': patch --- -Fix session replay masking past a clipped map, WebView, or camera preview and hiding the widgets around it +Fix session replay painting a clipped map, WebView, or camera preview past its visible edge and over the widgets around it diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 22cc42f2..00a0b8a1 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -851,20 +851,6 @@ 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 be masked past its visible edge -/// and over the widgets outside that clip. Returns [Rect.zero] when the view is -/// fully clipped away. -/// -/// A viewport reports the region a viewer can see, which excludes the band an -/// overlapping sliver header covers. That is what masking wants for the usual -/// opaque header — masking the band would black the header out of the replay — -/// but a translucent header leaves that band visible and unmasked. - /// The region [node] actually clips to, when it is a clip render object whose /// app-supplied clipper may report a smaller approximation than it clips with. /// @@ -892,6 +878,21 @@ Rect? _appClipperBounds(RenderObject node) { return null; } +/// 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 be masked past its visible edge +/// and over the widgets outside that clip. Returns [Rect.zero] when the view is +/// fully clipped away. +/// +/// A viewport reports the region a viewer can see, which excludes the band an +/// overlapping sliver header covers. That is what masking wants for the usual +/// opaque header, and masking the band would black the header out of the +/// replay. Under a translucent header the band stays visible, and for a +/// [TextureBox] — whose content is already in the Flutter image — that means +/// the view's own pixels reach the recording. @visibleForTesting Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { var clipped = ro.paintBounds; @@ -907,6 +908,9 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { // region it actually clips to, which would mask less than the view shows. clip = _appClipperBounds(node) ?? node.describeApproximatePaintClip(child); + // A NaN rect is not empty, so it would pass every check below and leave + // the mask undrawn; an app clipper's arithmetic can produce one. + if (clip != null && !clip.isFinite) clip = null; if (clip != null) { final toNode = ro.getTransformTo(node); // transformRect's four-corner hull only over-approximates for an diff --git a/posthog_flutter/test/platform_view_clip_test.dart b/posthog_flutter/test/platform_view_clip_test.dart index 5bbc1365..1fb76b64 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -245,9 +245,6 @@ void main() { group('clippedPaintBounds — overlapping sliver header', () { testWidgets('a pinned header trims the band it covers', (tester) async { - // A viewport reports what a viewer sees, minus the overlap a pinned - // header covers. That header can be translucent, so the band is still on - // screen and must stay masked. final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( @@ -409,6 +406,99 @@ void main() { }); group('clippedPaintBounds — failing and degenerate clips', () { + testWidgets('an under-reporting ClipRRect clipper does not shrink the mask', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ClipRRect( + clipper: _UnderReportingRRectClipper(), + child: + const SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 100, 100), + ); + }); + + testWidgets('an under-reporting ClipPath clipper does not shrink the mask', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ClipPath( + clipper: _UnderReportingPathClipper(), + child: + const SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 100, 100), + ); + }); + + testWidgets('a clipper returning a non-finite rect leaves the full bounds', + (tester) async { + // A NaN rect is not empty, so an unguarded walk would carry it into the + // mask geometry and the mask would never be drawn. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + key: const Key('ancestor'), + width: 300, + height: 300, + child: ClipRect( + clipper: _NaNClipper(), + child: + const SizedBox(key: Key('view'), width: 300, height: 300), + ), + ), + ), + ), + ); + tester.takeException(); + + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 300, 300), + ); + }); + testWidgets('a clipper attached with Clip.none does not shrink the mask', (tester) async { // Flutter paints the view in full, so the clipper describes nothing. @@ -452,12 +542,17 @@ void main() { alignment: Alignment.topLeft, child: SizedBox( key: const Key('ancestor'), - width: 300, - height: 300, - child: ClipRect( - clipper: _UnderReportingClipper(), - child: - const SizedBox(key: Key('view'), width: 300, height: 300), + width: 400, + height: 400, + // Offset so the clip's frame differs from the ancestor's, which + // a walk measuring against the wrong node would not notice. + child: Padding( + padding: const EdgeInsets.only(left: 40, top: 60), + child: ClipRect( + clipper: _UnderReportingClipper(), + child: + const SizedBox(key: Key('view'), width: 300, height: 300), + ), ), ), ), @@ -620,3 +715,35 @@ class _PinnedHeader extends SliverPersistentHeaderDelegate { bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => false; } + +class _UnderReportingRRectClipper extends CustomClipper { + @override + RRect getClip(Size size) => + RRect.fromRectXY(const Rect.fromLTWH(0, 0, 100, 100), 8, 8); + + @override + Rect getApproximateClipRect(Size size) => const Rect.fromLTWH(0, 0, 10, 10); + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} + +class _UnderReportingPathClipper extends CustomClipper { + @override + Path getClip(Size size) => + Path()..addRect(const Rect.fromLTWH(0, 0, 100, 100)); + + @override + Rect getApproximateClipRect(Size size) => const Rect.fromLTWH(0, 0, 10, 10); + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} + +class _NaNClipper extends CustomClipper { + @override + Rect getClip(Size size) => Rect.fromLTRB(double.nan, 0, 100, 100); + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} From 1cc268cba757d01bb66a8cbbee4cdb33b91ee77d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 09:54:35 -0400 Subject: [PATCH 11/17] fix(replay): put a revealed view's native pixels back where they were cropped Native crops an axis-aligned region of the screen, so the bytes already hold the view's on-screen appearance. Clipping the composite moved the destination into the view's own space, which applied a rotation or flip a second time and landed a revealed view's pixels turned or mirrored. The clip stays in the view's space, where a rotated edge is exact; the draw goes back to device space. Pixel tests cover the turned, mirrored, clipped and singular cases. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/platform-view-mask-spill.md | 2 +- .../screenshot/screenshot_capturer.dart | 60 ++++++-- .../test/platform_view_clip_test.dart | 2 +- .../test/platform_view_composite_test.dart | 138 ++++++++++++++++++ 4 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 posthog_flutter/test/platform_view_composite_test.dart diff --git a/.changeset/platform-view-mask-spill.md b/.changeset/platform-view-mask-spill.md index a88bb071..cdd968ce 100644 --- a/.changeset/platform-view-mask-spill.md +++ b/.changeset/platform-view-mask-spill.md @@ -2,4 +2,4 @@ 'posthog_flutter': patch --- -Fix session replay painting a clipped map, WebView, or camera preview past its visible edge and over the widgets around it +Fix session replay painting a clipped map, WebView, or camera preview past its visible edge and over the widgets around it. Masked regions shrink to what the view actually shows, so widgets a mask used to bury are visible in replays again diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 00a0b8a1..837b054a 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -372,21 +372,12 @@ class ScreenshotCapturer { _imageMaskPainter.drawMaskedImage(canvas, [fallbackMask], pixelRatio); return; } - canvas.save(); - // Clipping in the view's own space keeps a rotated or skewed clip exact; - // its bounding box would let native pixels past the real clip edge. An - // antialiased edge would blend them a hairline past it too. - canvas.transform(transform.storage); - canvas.clipRect(view.visibleRect, doAntiAlias: false); - canvas.drawImageRect( - nativeImage, - Rect.fromLTWH( - 0, 0, nativeImage.width.toDouble(), nativeImage.height.toDouble()), - viewRect.rect, - Paint()..blendMode = ui.BlendMode.srcOver, - ); - canvas.restore(); - nativeImage.dispose(); + try { + compositeRevealedImage( + canvas, nativeImage, transform, viewRect.rect, view.visibleRect); + } finally { + nativeImage.dispose(); + } } Future _decodeRawPixels(Uint8List bytes, int width, int height) { @@ -935,6 +926,45 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { return clipped; } +/// Paints [image] — an axis-aligned screen crop of the platform view — back +/// over the region the view occupies, showing only the part [visibleRect] +/// leaves. [viewRect] and [visibleRect] are in the view's own space and +/// [transform] maps that space to the canvas. +/// +/// The crop already holds the view's on-screen appearance, so it goes back +/// into the same device-space rect; painting it in the view's own space would +/// apply the view's rotation or flip a second time. +@visibleForTesting +void compositeRevealedImage( + Canvas canvas, + ui.Image image, + Matrix4 transform, + Rect viewRect, + Rect visibleRect, +) { + final toDevice = Matrix4.tryInvert(transform); + if (toDevice == null) return; + canvas.save(); + try { + // The clip is set in the view's own space so a rotated or skewed edge + // stays exact; its device-space hull would let native pixels past it. An + // antialiased edge would blend them a hairline past it too. visibleRect is + // itself a hull of the ancestor clip, so a rotated view can still reveal a + // corner of native content outside that clip. + canvas.transform(transform.storage); + canvas.clipRect(visibleRect, doAntiAlias: false); + canvas.transform(toDevice.storage); + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + MatrixUtils.transformRect(transform, viewRect), + Paint()..blendMode = ui.BlendMode.srcOver, + ); + } finally { + canvas.restore(); + } +} + @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 index 1fb76b64..6b2a45ae 100644 --- a/posthog_flutter/test/platform_view_clip_test.dart +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -278,7 +278,7 @@ void main() { // The viewport excludes the band the header covers. That is right for an // opaque header, and masking it would black the header out of the replay; - // a translucent header leaves the band visible and unmasked. + // under a translucent one the band can still show the view's own pixels. expect( clippedPaintBounds( tester.renderObject(find.byKey(const Key('view'))), diff --git a/posthog_flutter/test/platform_view_composite_test.dart b/posthog_flutter/test/platform_view_composite_test.dart new file mode 100644 index 00000000..e658a310 --- /dev/null +++ b/posthog_flutter/test/platform_view_composite_test.dart @@ -0,0 +1,138 @@ +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/replay/screenshot/screenshot_capturer.dart'; + +const _topLeft = Color(0xFFFF0000); +const _topRight = Color(0xFF0000FF); +const _bottomLeft = Color(0xFFFFFF00); +const _bottomRight = Color(0xFFFF00FF); +const _background = Color(0xFF00FF00); + +/// A stand-in for the native crop: an axis-aligned screen capture of the view, +/// [w] x [h], with each quadrant a different colour so a rotation or a flip of +/// the result is detectable. +Future _screenCrop(int w, int h) { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final halfW = w / 2; + final halfH = h / 2; + canvas.drawRect(Rect.fromLTWH(0, 0, halfW, halfH), Paint()..color = _topLeft); + canvas.drawRect( + Rect.fromLTWH(halfW, 0, halfW, halfH), Paint()..color = _topRight); + canvas.drawRect( + Rect.fromLTWH(0, halfH, halfW, halfH), Paint()..color = _bottomLeft); + canvas.drawRect( + Rect.fromLTWH(halfW, halfH, halfW, halfH), Paint()..color = _bottomRight); + return recorder.endRecording().toImage(w, h); +} + +Future _composite( + ui.Image crop, + Matrix4 transform, + Rect viewRect, + Rect visibleRect, +) { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + const Rect.fromLTWH(0, 0, 100, 200), Paint()..color = _background); + compositeRevealedImage(canvas, crop, transform, viewRect, visibleRect); + return recorder.endRecording().toImage(100, 200); +} + +Future _pixel(ui.Image image, int x, int y) async { + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final bytes = data!.buffer.asUint8List(); + final i = (y * image.width + x) * 4; + return Color.fromARGB(bytes[i + 3], bytes[i], bytes[i + 1], bytes[i + 2]); +} + +/// The composited screen must reproduce the crop quadrant for quadrant. +Future _expectCropReproduced(ui.Image image) async { + expect(await _pixel(image, 25, 50), _topLeft); + expect(await _pixel(image, 75, 50), _topRight); + expect(await _pixel(image, 25, 150), _bottomLeft); + expect(await _pixel(image, 75, 150), _bottomRight); +} + +void main() { + // A quarter turn about the origin, shifted back so the view's 200x100 local + // rect lands on the 100x200 screen footprint the crop was taken from. + final quarterTurn = Matrix4.identity() + ..translateByDouble(100.0, 0.0, 0.0, 1.0) + ..rotateZ(pi / 2); + final mirrored = Matrix4.identity() + ..translateByDouble(100.0, 0.0, 0.0, 1.0) + ..multiply(Matrix4.diagonal3Values(-1, 1, 1)); + + test('an unrotated view lands where the crop was taken', () async { + final image = await _composite( + await _screenCrop(100, 200), + Matrix4.identity(), + const Rect.fromLTWH(0, 0, 100, 200), + const Rect.fromLTWH(0, 0, 100, 200), + ); + await _expectCropReproduced(image); + }); + + test('a quarter-turned view is not turned a second time', () async { + final image = await _composite( + await _screenCrop(100, 200), + quarterTurn, + const Rect.fromLTWH(0, 0, 200, 100), + const Rect.fromLTWH(0, 0, 200, 100), + ); + await _expectCropReproduced(image); + }); + + test('a mirrored view is not mirrored a second time', () async { + final image = await _composite( + await _screenCrop(100, 200), + mirrored, + const Rect.fromLTWH(0, 0, 100, 200), + const Rect.fromLTWH(0, 0, 100, 200), + ); + await _expectCropReproduced(image); + }); + + test('only the visible part of a clipped view is painted', () async { + final image = await _composite( + await _screenCrop(100, 200), + Matrix4.identity(), + const Rect.fromLTWH(0, 0, 100, 200), + const Rect.fromLTWH(0, 0, 100, 100), + ); + expect(await _pixel(image, 25, 50), _topLeft); + expect(await _pixel(image, 75, 50), _topRight); + expect(await _pixel(image, 25, 150), _background); + expect(await _pixel(image, 75, 150), _background); + }); + + test('a clipped quarter-turned view is clipped in its own space', () async { + final image = await _composite( + await _screenCrop(100, 200), + quarterTurn, + const Rect.fromLTWH(0, 0, 200, 100), + const Rect.fromLTWH(0, 0, 60, 100), + ); + // The turn sends the visible 60 of the view's 200 wide span to the top + // 60 px of the screen footprint, leaving the crop's own colours in place. + expect(await _pixel(image, 25, 20), _topLeft); + expect(await _pixel(image, 75, 20), _topRight); + expect(await _pixel(image, 25, 150), _background); + expect(await _pixel(image, 75, 150), _background); + }); + + test('a singular transform paints nothing rather than throwing', () async { + final image = await _composite( + await _screenCrop(100, 200), + Matrix4.diagonal3Values(0, 1, 1), + const Rect.fromLTWH(0, 0, 100, 200), + const Rect.fromLTWH(0, 0, 100, 200), + ); + expect(await _pixel(image, 50, 100), _background); + }); +} From 5ffc068e4b81768e57d48e657bed4b52e34b46d2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 10:11:31 -0400 Subject: [PATCH 12/17] test(example): add the platform view spill repro cases Seven cases put a sentinel banner directly below a clipped platform view, so a replay frame missing the banner means the view's rect overran its visible region. Four more show a revealed view's four coloured quadrants under a transform, so a swapped corner means the native crop was composited in the view's own space and the view's rotation or flip was applied twice. Co-Authored-By: Claude Opus 5 (1M context) --- example/lib/main.dart | 19 + example/lib/platform_view_spill_screen.dart | 415 ++++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 example/lib/platform_view_spill_screen.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 741ea663..0880ceb6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,6 +8,7 @@ import 'package:posthog_flutter_example/error_example.dart'; import 'exception_steps_screen.dart'; import 'masking_tests_screen.dart'; +import 'platform_view_spill_screen.dart'; import 'platform_views_screen.dart'; import 'survey_nested_navigator_screen.dart'; @@ -219,6 +220,24 @@ class InitialScreenState extends State { }, child: const Text('Platform Views (Replay)'), ), + const SizedBox(height: 8), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PlatformViewSpillScreen(), + settings: + const RouteSettings(name: 'platform_view_spill'), + ), + ); + }, + child: const Text('Platform View Spill (Replay)'), + ), const Padding( padding: EdgeInsets.all(8.0), child: Text( diff --git a/example/lib/platform_view_spill_screen.dart b/example/lib/platform_view_spill_screen.dart new file mode 100644 index 00000000..fb4751e4 --- /dev/null +++ b/example/lib/platform_view_spill_screen.dart @@ -0,0 +1,415 @@ +import 'package:flutter/material.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Repro cases for the platform-view mask/reveal spill. +/// +/// Two families of case, checked differently in a replay recording: +/// +/// * **Spill** — a sentinel banner sits directly below a clipped platform view. +/// The banner is plain Flutter, so replay must always show it. A missing +/// banner means the view's rect overran its visible region and painted over +/// it. +/// * **Orientation** — a revealed platform view shows four coloured quadrants. +/// The native side crops an axis-aligned region of the *screen*, so replay +/// must show the same quadrant in the same corner. A swapped corner means the +/// crop was composited in the view's own space and the view's rotation or +/// flip was applied a second time. +const spillSentinels = { + 'clipped_masked': 'SPILLONE', + 'scrolled_masked': 'SPILLTWO', + 'nested_clip_masked': 'SPILLTHREE', + 'clipped_revealed': 'SPILLFOUR', + 'scrolled_revealed': 'SPILLFIVE', + 'masked_over_revealed': 'SPILLSIX', + 'unclipped_masked_control': 'SPILLSEVEN', +}; + +WebViewController _wv() => + WebViewController()..loadRequest(Uri.parse('https://www.wikipedia.org')); + +/// Red top-left, blue top-right, yellow bottom-left, magenta bottom-right. +const _quadrantPage = ''' + +
+
+'''; + +WebViewController _quadrantWv() => WebViewController() + ..loadRequest(Uri.dataFromString(_quadrantPage, mimeType: 'text/html')); + +class _Sentinel extends StatelessWidget { + final String token; + const _Sentinel(this.token); + + @override + Widget build(BuildContext context) => Container( + height: 90, + width: double.infinity, + color: const Color(0xFFFFE24A), + alignment: Alignment.center, + child: Text( + token, + style: const TextStyle( + color: Colors.black, + fontSize: 34, + fontWeight: FontWeight.bold, + ), + ), + ); +} + +/// A tall platform view trimmed by a `ClipRect`, sentinel directly below. +class ClippedMasked extends StatefulWidget { + final PostHogPlatformViewPrivacy privacy; + final String token; + const ClippedMasked({super.key, required this.privacy, required this.token}); + @override + State createState() => _ClippedMaskedState(); +} + +class _ClippedMaskedState extends State { + late final WebViewController _c = _wv(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('spill repro')), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 160, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, + child: SizedBox( + height: 520, + child: PostHogPlatformView( + privacy: widget.privacy, + child: WebViewWidget(controller: _c), + ), + ), + ), + ), + ), + _Sentinel(widget.token), + ], + ), + ); +} + +/// Nested clips: the innermost one wins and the mask must respect it. +class NestedClipMasked extends StatefulWidget { + final String token; + const NestedClipMasked({super.key, required this.token}); + @override + State createState() => _NestedClipMaskedState(); +} + +class _NestedClipMaskedState extends State { + late final WebViewController _c = _wv(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('spill repro')), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 300, + child: ClipRect( + child: SizedBox( + height: 140, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, + child: SizedBox( + height: 520, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _c), + ), + ), + ), + ), + ), + ), + ), + _Sentinel(widget.token), + ], + ), + ); +} + +/// A platform view scrolled half out of a viewport, sentinel pinned below it. +class ScrolledPartlyOut extends StatefulWidget { + final PostHogPlatformViewPrivacy privacy; + final String token; + const ScrolledPartlyOut({ + super.key, + required this.privacy, + required this.token, + }); + @override + State createState() => _ScrolledPartlyOutState(); +} + +class _ScrolledPartlyOutState extends State { + late final WebViewController _c = _wv(); + final _controller = ScrollController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_controller.hasClients) _controller.jumpTo(260); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('spill repro')), + body: ListView( + controller: _controller, + children: [ + const SizedBox(height: 40), + SizedBox( + height: 460, + child: PostHogPlatformView( + privacy: widget.privacy, + child: WebViewWidget(controller: _c), + ), + ), + _Sentinel(widget.token), + const SizedBox(height: 700), + ], + ), + ); +} + +/// A masked view directly above a revealed one: the mask must not overrun and +/// black out the view the developer asked to reveal. +class MaskedOverRevealed extends StatefulWidget { + final String token; + const MaskedOverRevealed({super.key, required this.token}); + @override + State createState() => _MaskedOverRevealedState(); +} + +class _MaskedOverRevealedState extends State { + late final WebViewController _top = _wv(); + late final WebViewController _bottom = _wv(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('spill repro')), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 150, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, + child: SizedBox( + height: 520, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _top), + ), + ), + ), + ), + ), + _Sentinel(widget.token), + Expanded( + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.capture, + child: WebViewWidget(controller: _bottom), + ), + ), + ], + ), + ); +} + +/// Control: no clip anywhere. The mask must be unchanged by the fix. +class UnclippedMaskedControl extends StatefulWidget { + final String token; + const UnclippedMaskedControl({super.key, required this.token}); + @override + State createState() => _UnclippedMaskedControlState(); +} + +class _UnclippedMaskedControlState extends State { + late final WebViewController _c = _wv(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('spill repro')), + body: Column( + children: [ + SizedBox( + height: 200, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _c), + ), + ), + _Sentinel(widget.token), + ], + ), + ); +} + +/// A revealed platform view under a transform, showing four coloured +/// quadrants. Replay must reproduce the quadrants in the same corners as the +/// screen; a swapped corner means the crop was turned or mirrored twice. +class TransformedRevealed extends StatefulWidget { + /// Null for the control case, which applies no transform. + final Matrix4? transform; + + /// Trims the view so the clip and the destination are exercised together. + final bool clipped; + + const TransformedRevealed({ + super.key, + required this.transform, + this.clipped = false, + }); + + @override + State createState() => _TransformedRevealedState(); +} + +class _TransformedRevealedState extends State { + late final WebViewController _c = _quadrantWv(); + + @override + Widget build(BuildContext context) { + Widget view = SizedBox( + width: 300, + height: 300, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.capture, + child: WebViewWidget(controller: _c), + ), + ); + if (widget.clipped) { + view = ClipRect( + child: SizedBox( + width: 300, + height: 150, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 300, + child: view, + ), + ), + ); + } + final transform = widget.transform; + if (transform != null) { + view = Transform( + alignment: Alignment.center, + transform: transform, + child: view, + ); + } + return Scaffold( + appBar: AppBar(title: const Text('spill repro')), + body: Center(child: view), + ); + } +} + +/// Every repro case, in the order the device matrix runs them. +List<(String, Widget)> spillCases() => [ + ( + '1_clipped_masked', + const ClippedMasked( + privacy: PostHogPlatformViewPrivacy.mask, token: 'SPILLONE') + ), + ( + '2_scrolled_masked', + const ScrolledPartlyOut( + privacy: PostHogPlatformViewPrivacy.mask, token: 'SPILLTWO') + ), + ('3_nested_clip_masked', const NestedClipMasked(token: 'SPILLTHREE')), + ( + '4_clipped_revealed', + const ClippedMasked( + privacy: PostHogPlatformViewPrivacy.capture, token: 'SPILLFOUR') + ), + ( + '5_scrolled_revealed', + const ScrolledPartlyOut( + privacy: PostHogPlatformViewPrivacy.capture, token: 'SPILLFIVE') + ), + ('6_masked_over_revealed', const MaskedOverRevealed(token: 'SPILLSIX')), + ( + '7_unclipped_control', + const UnclippedMaskedControl(token: 'SPILLSEVEN') + ), + ('8_revealed_untransformed', const TransformedRevealed(transform: null)), + ( + '9_revealed_quarter_turn', + TransformedRevealed(transform: Matrix4.rotationZ(1.5707963267948966)) + ), + ( + '10_revealed_mirrored', + TransformedRevealed(transform: Matrix4.diagonal3Values(-1, 1, 1)) + ), + ( + '11_revealed_quarter_turn_clipped', + TransformedRevealed( + transform: Matrix4.rotationZ(1.5707963267948966), clipped: true) + ), + ]; + +/// Menu listing every case, so the repros are reachable from the example app. +class PlatformViewSpillScreen extends StatelessWidget { + const PlatformViewSpillScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cases = spillCases(); + return Scaffold( + appBar: AppBar(title: const Text('Platform View Spill — Replay')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text( + 'Cases 1-7: the yellow sentinel banner must be visible in replay. ' + 'Cases 8-11: the four quadrants must appear in replay in the same ' + 'corners as on screen.', + ), + const SizedBox(height: 16), + for (final (name, screen) in cases) + ListTile( + title: Text(name), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => screen, + settings: RouteSettings(name: 'spill_$name'), + ), + ), + ), + ], + ), + ); + } +} From bfbb225a51dccf063f193b552507ef95860beace Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 10:20:24 -0400 Subject: [PATCH 13/17] test(replay): cover the rects the mask painter is handed, and a 45-degree clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quarter turn maps an axis-aligned rect onto another axis-aligned rect, so the composite's clip could be swapped for its device-space hull with every test still green. At 45 degrees the hull is twice the area. Nothing covered the four lines that produce the fix either — the masked rect, the dropped fully-clipped view, the revealed view's visible region, and the capture-failure fallback. A seam over the collected rects and one over the fallback close all four; each was checked by mutating the line and watching a test fail. The composite's doc comment claimed native returns an axis-aligned screen crop. That holds for Android's PixelCopy. iOS snapshots a WKWebView in the view's own space, so a rotated or scaled revealed view composites unrotated there — the same on main as here, measured on a simulator. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/platform-view-mask-spill.md | 2 +- .../screenshot/screenshot_capturer.dart | 48 +++++-- .../test/platform_view_composite_test.dart | 37 ++++++ .../test/platform_view_rects_test.dart | 119 ++++++++++++++++++ 4 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 posthog_flutter/test/platform_view_rects_test.dart diff --git a/.changeset/platform-view-mask-spill.md b/.changeset/platform-view-mask-spill.md index cdd968ce..a37cc20e 100644 --- a/.changeset/platform-view-mask-spill.md +++ b/.changeset/platform-view-mask-spill.md @@ -2,4 +2,4 @@ 'posthog_flutter': patch --- -Fix session replay painting a clipped map, WebView, or camera preview past its visible edge and over the widgets around it. Masked regions shrink to what the view actually shows, so widgets a mask used to bury are visible in replays again +Fix session replay painting a clipped map, WebView, or camera preview past its visible edge and over the widgets around it. A platform view's mask now covers only the part an ancestor clip leaves visible, and a revealed one is clipped to the same region, so widgets a platform view used to bury are visible in replays again diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 837b054a..6d76bfb3 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -334,6 +334,36 @@ class ScreenshotCapturer { } } + /// Masks a revealed view whose native capture came back empty, so a test can + /// check the fallback covers only the visible region. + @visibleForTesting + Future debugMaskFailedCapture( + Canvas canvas, Rect viewRect, Rect visibleRect, Matrix4 transform) => + _compositeRevealedView( + canvas, + _CapturedView( + data: ElementData( + rect: viewRect, type: 'platformView', transform: transform), + visibleRect: visibleRect, + ), + null, + 0, + 0, + 1.0, + ); + + /// The rects the mask painter is handed for the platform views on screen, + /// and the visible region each revealed view is clipped to. + @visibleForTesting + ({List masked, List revealed}) debugPlatformViewRects( + PostHogPlatformViewPrivacy defaultPolicy) { + final rects = _collectPlatformViewRects(defaultPolicy); + return ( + masked: rects.masked.map((e) => e.rect).toList(), + revealed: rects.captured.map((v) => v.visibleRect).toList(), + ); + } + Map _viewSpec(ElementData viewRect, Offset globalPosition) { final transform = viewRect.transform; if (transform == null) return {'x': 0, 'y': 0, 'width': 0, 'height': 0}; @@ -926,14 +956,18 @@ Rect clippedPaintBounds(RenderBox ro, RenderObject? ancestor) { return clipped; } -/// Paints [image] — an axis-aligned screen crop of the platform view — back -/// over the region the view occupies, showing only the part [visibleRect] -/// leaves. [viewRect] and [visibleRect] are in the view's own space and -/// [transform] maps that space to the canvas. +/// Paints [image] — the platform view's native pixels — back over the region +/// the view occupies, showing only the part [visibleRect] leaves. [viewRect] +/// and [visibleRect] are in the view's own space and [transform] maps that +/// space to the canvas. /// -/// The crop already holds the view's on-screen appearance, so it goes back -/// into the same device-space rect; painting it in the view's own space would -/// apply the view's rotation or flip a second time. +/// Android crops an axis-aligned region of the screen, so the crop already +/// holds the view's on-screen appearance and goes back into the same +/// device-space rect; painting it in the view's own space would apply the +/// view's rotation or flip a second time. iOS snapshots a `WKWebView` in the +/// view's own space instead, so a rotated or scaled revealed view composites +/// unrotated there — measured on a simulator, unchanged by this function, and +/// tracked separately. @visibleForTesting void compositeRevealedImage( Canvas canvas, diff --git a/posthog_flutter/test/platform_view_composite_test.dart b/posthog_flutter/test/platform_view_composite_test.dart index e658a310..f11a5989 100644 --- a/posthog_flutter/test/platform_view_composite_test.dart +++ b/posthog_flutter/test/platform_view_composite_test.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/src/replay/screenshot/screenshot_capturer.dart'; const _topLeft = Color(0xFFFF0000); @@ -126,6 +127,26 @@ void main() { expect(await _pixel(image, 75, 150), _background); }); + test('an eighth turn does not paint the corners of the clip hull', () async { + // A quarter turn maps an axis-aligned rect onto another axis-aligned rect, + // so it cannot tell the view-space clip apart from that rect's device-space + // hull. At 45 degrees the hull is twice the area of the real clip. + final eighthTurn = Matrix4.identity() + ..translateByDouble(50.0, 20.0, 0.0, 1.0) + ..rotateZ(pi / 4); + final image = await _composite( + await _screenCrop(100, 200), + eighthTurn, + const Rect.fromLTWH(0, 0, 60, 60), + const Rect.fromLTWH(0, 0, 60, 60), + ); + expect(await _pixel(image, 40, 60), _topLeft); + expect(await _pixel(image, 15, 30), _background); + expect(await _pixel(image, 85, 30), _background); + expect(await _pixel(image, 15, 95), _background); + expect(await _pixel(image, 85, 95), _background); + }); + test('a singular transform paints nothing rather than throwing', () async { final image = await _composite( await _screenCrop(100, 200), @@ -135,4 +156,20 @@ void main() { ); expect(await _pixel(image, 50, 100), _background); }); + + test('a failed native capture masks only the visible region', () async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + const Rect.fromLTWH(0, 0, 100, 200), Paint()..color = _background); + await ScreenshotCapturer(PostHogConfig('test')).debugMaskFailedCapture( + canvas, + const Rect.fromLTWH(0, 0, 100, 200), + const Rect.fromLTWH(0, 0, 100, 100), + Matrix4.identity(), + ); + final image = await recorder.endRecording().toImage(100, 200); + expect(await _pixel(image, 50, 50), const Color(0xFF000000)); + expect(await _pixel(image, 50, 150), _background); + }); } diff --git a/posthog_flutter/test/platform_view_rects_test.dart b/posthog_flutter/test/platform_view_rects_test.dart new file mode 100644 index 00000000..ed797e08 --- /dev/null +++ b/posthog_flutter/test/platform_view_rects_test.dart @@ -0,0 +1,119 @@ +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/replay/screenshot/screenshot_capturer.dart'; + +/// A platform view with no native side, so the walk sees a real +/// [PlatformViewRenderBox] without a platform channel behind it. +class _FakePlatformView extends LeafRenderObjectWidget { + final double width; + final double height; + const _FakePlatformView({required this.width, required this.height}); + + @override + RenderObject createRenderObject(BuildContext context) => + PlatformViewRenderBox( + controller: _FakeController(), + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: const {}, + ); +} + +class _FakeController extends PlatformViewController { + @override + int get viewId => 0; + @override + Future clearFocus() async {} + @override + Future dispatchPointerEvent(PointerEvent event) async {} + @override + Future dispose() async {} +} + +Widget _clippedView({required double clipHeight, required double viewHeight}) => + Directionality( + textDirection: TextDirection.ltr, + child: PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: ClipRect( + child: SizedBox( + width: 100, + height: clipHeight, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: viewHeight, + child: SizedBox( + width: 100, + height: viewHeight, + child: const _FakePlatformView(width: 100, height: 0), + ), + ), + ), + ), + ), + ), + ); + +void main() { + late ScreenshotCapturer capturer; + + setUp(() => capturer = ScreenshotCapturer(PostHogConfig('test'))); + + testWidgets('a masked platform view is masked to its visible region', + (tester) async { + await tester.pumpWidget(_clippedView(clipHeight: 100, viewHeight: 300)); + final rects = + capturer.debugPlatformViewRects(PostHogPlatformViewPrivacy.mask); + expect(rects.masked, [const Rect.fromLTWH(0, 0, 100, 100)]); + expect(rects.revealed, isEmpty); + }); + + testWidgets('a revealed platform view carries its visible region', + (tester) async { + await tester.pumpWidget(_clippedView(clipHeight: 100, viewHeight: 300)); + final rects = + capturer.debugPlatformViewRects(PostHogPlatformViewPrivacy.capture); + expect(rects.masked, isEmpty); + expect(rects.revealed, [const Rect.fromLTWH(0, 0, 100, 100)]); + }); + + testWidgets('a fully clipped platform view is dropped, not masked at zero', + (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: ClipRect( + child: SizedBox( + width: 100, + height: 100, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 300, + child: Transform.translate( + offset: const Offset(0, 200), + child: const SizedBox( + width: 100, + height: 100, + child: _FakePlatformView(width: 100, height: 100), + ), + ), + ), + ), + ), + ), + ), + ), + ); + final rects = + capturer.debugPlatformViewRects(PostHogPlatformViewPrivacy.mask); + expect(rects.masked, isEmpty); + }); +} From 0d0760ca49108f00d62530e1d42162d4457c7548 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 10:27:48 -0400 Subject: [PATCH 14/17] style(example): format the spill screen the way the example package formats `dart format` runs over the repo root in CI, and the example package resolves a newer language version than the SDK package, so it wraps differently. Co-Authored-By: Claude Opus 5 (1M context) --- example/lib/main.dart | 5 +- example/lib/platform_view_spill_screen.dart | 308 ++++++++++---------- pubspec.lock | 16 +- 3 files changed, 169 insertions(+), 160 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 0880ceb6..ce4cd592 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -231,8 +231,9 @@ class InitialScreenState extends State { context, MaterialPageRoute( builder: (context) => const PlatformViewSpillScreen(), - settings: - const RouteSettings(name: 'platform_view_spill'), + settings: const RouteSettings( + name: 'platform_view_spill', + ), ), ); }, diff --git a/example/lib/platform_view_spill_screen.dart b/example/lib/platform_view_spill_screen.dart index fb4751e4..da2e7109 100644 --- a/example/lib/platform_view_spill_screen.dart +++ b/example/lib/platform_view_spill_screen.dart @@ -36,8 +36,9 @@ grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;height:100vh">
'''; -WebViewController _quadrantWv() => WebViewController() - ..loadRequest(Uri.dataFromString(_quadrantPage, mimeType: 'text/html')); +WebViewController _quadrantWv() => + WebViewController() + ..loadRequest(Uri.dataFromString(_quadrantPage, mimeType: 'text/html')); class _Sentinel extends StatelessWidget { final String token; @@ -45,19 +46,19 @@ class _Sentinel extends StatelessWidget { @override Widget build(BuildContext context) => Container( - height: 90, - width: double.infinity, - color: const Color(0xFFFFE24A), - alignment: Alignment.center, - child: Text( - token, - style: const TextStyle( - color: Colors.black, - fontSize: 34, - fontWeight: FontWeight.bold, - ), - ), - ); + height: 90, + width: double.infinity, + color: const Color(0xFFFFE24A), + alignment: Alignment.center, + child: Text( + token, + style: const TextStyle( + color: Colors.black, + fontSize: 34, + fontWeight: FontWeight.bold, + ), + ), + ); } /// A tall platform view trimmed by a `ClipRect`, sentinel directly below. @@ -74,30 +75,30 @@ class _ClippedMaskedState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('spill repro')), - body: Column( - children: [ - ClipRect( + appBar: AppBar(title: const Text('spill repro')), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 160, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, child: SizedBox( - height: 160, - child: OverflowBox( - alignment: Alignment.topLeft, - minHeight: 0, - maxHeight: 520, - child: SizedBox( - height: 520, - child: PostHogPlatformView( - privacy: widget.privacy, - child: WebViewWidget(controller: _c), - ), - ), + height: 520, + child: PostHogPlatformView( + privacy: widget.privacy, + child: WebViewWidget(controller: _c), ), ), ), - _Sentinel(widget.token), - ], + ), ), - ); + _Sentinel(widget.token), + ], + ), + ); } /// Nested clips: the innermost one wins and the mask must respect it. @@ -113,35 +114,35 @@ class _NestedClipMaskedState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('spill repro')), - body: Column( - children: [ - ClipRect( + appBar: AppBar(title: const Text('spill repro')), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 300, + child: ClipRect( child: SizedBox( - height: 300, - child: ClipRect( + height: 140, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, child: SizedBox( - height: 140, - child: OverflowBox( - alignment: Alignment.topLeft, - minHeight: 0, - maxHeight: 520, - child: SizedBox( - height: 520, - child: PostHogPlatformView( - privacy: PostHogPlatformViewPrivacy.mask, - child: WebViewWidget(controller: _c), - ), - ), + height: 520, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _c), ), ), ), ), ), - _Sentinel(widget.token), - ], + ), ), - ); + _Sentinel(widget.token), + ], + ), + ); } /// A platform view scrolled half out of a viewport, sentinel pinned below it. @@ -177,23 +178,23 @@ class _ScrolledPartlyOutState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('spill repro')), - body: ListView( - controller: _controller, - children: [ - const SizedBox(height: 40), - SizedBox( - height: 460, - child: PostHogPlatformView( - privacy: widget.privacy, - child: WebViewWidget(controller: _c), - ), - ), - _Sentinel(widget.token), - const SizedBox(height: 700), - ], + appBar: AppBar(title: const Text('spill repro')), + body: ListView( + controller: _controller, + children: [ + const SizedBox(height: 40), + SizedBox( + height: 460, + child: PostHogPlatformView( + privacy: widget.privacy, + child: WebViewWidget(controller: _c), + ), ), - ); + _Sentinel(widget.token), + const SizedBox(height: 700), + ], + ), + ); } /// A masked view directly above a revealed one: the mask must not overrun and @@ -211,36 +212,36 @@ class _MaskedOverRevealedState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('spill repro')), - body: Column( - children: [ - ClipRect( + appBar: AppBar(title: const Text('spill repro')), + body: Column( + children: [ + ClipRect( + child: SizedBox( + height: 150, + child: OverflowBox( + alignment: Alignment.topLeft, + minHeight: 0, + maxHeight: 520, child: SizedBox( - height: 150, - child: OverflowBox( - alignment: Alignment.topLeft, - minHeight: 0, - maxHeight: 520, - child: SizedBox( - height: 520, - child: PostHogPlatformView( - privacy: PostHogPlatformViewPrivacy.mask, - child: WebViewWidget(controller: _top), - ), - ), + height: 520, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _top), ), ), ), - _Sentinel(widget.token), - Expanded( - child: PostHogPlatformView( - privacy: PostHogPlatformViewPrivacy.capture, - child: WebViewWidget(controller: _bottom), - ), - ), - ], + ), ), - ); + _Sentinel(widget.token), + Expanded( + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.capture, + child: WebViewWidget(controller: _bottom), + ), + ), + ], + ), + ); } /// Control: no clip anywhere. The mask must be unchanged by the fix. @@ -256,20 +257,20 @@ class _UnclippedMaskedControlState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('spill repro')), - body: Column( - children: [ - SizedBox( - height: 200, - child: PostHogPlatformView( - privacy: PostHogPlatformViewPrivacy.mask, - child: WebViewWidget(controller: _c), - ), - ), - _Sentinel(widget.token), - ], + appBar: AppBar(title: const Text('spill repro')), + body: Column( + children: [ + SizedBox( + height: 200, + child: PostHogPlatformView( + privacy: PostHogPlatformViewPrivacy.mask, + child: WebViewWidget(controller: _c), + ), ), - ); + _Sentinel(widget.token), + ], + ), + ); } /// A revealed platform view under a transform, showing four coloured @@ -336,47 +337,54 @@ class _TransformedRevealedState extends State { /// Every repro case, in the order the device matrix runs them. List<(String, Widget)> spillCases() => [ - ( - '1_clipped_masked', - const ClippedMasked( - privacy: PostHogPlatformViewPrivacy.mask, token: 'SPILLONE') - ), - ( - '2_scrolled_masked', - const ScrolledPartlyOut( - privacy: PostHogPlatformViewPrivacy.mask, token: 'SPILLTWO') - ), - ('3_nested_clip_masked', const NestedClipMasked(token: 'SPILLTHREE')), - ( - '4_clipped_revealed', - const ClippedMasked( - privacy: PostHogPlatformViewPrivacy.capture, token: 'SPILLFOUR') - ), - ( - '5_scrolled_revealed', - const ScrolledPartlyOut( - privacy: PostHogPlatformViewPrivacy.capture, token: 'SPILLFIVE') - ), - ('6_masked_over_revealed', const MaskedOverRevealed(token: 'SPILLSIX')), - ( - '7_unclipped_control', - const UnclippedMaskedControl(token: 'SPILLSEVEN') - ), - ('8_revealed_untransformed', const TransformedRevealed(transform: null)), - ( - '9_revealed_quarter_turn', - TransformedRevealed(transform: Matrix4.rotationZ(1.5707963267948966)) - ), - ( - '10_revealed_mirrored', - TransformedRevealed(transform: Matrix4.diagonal3Values(-1, 1, 1)) - ), - ( - '11_revealed_quarter_turn_clipped', - TransformedRevealed( - transform: Matrix4.rotationZ(1.5707963267948966), clipped: true) - ), - ]; + ( + '1_clipped_masked', + const ClippedMasked( + privacy: PostHogPlatformViewPrivacy.mask, + token: 'SPILLONE', + ), + ), + ( + '2_scrolled_masked', + const ScrolledPartlyOut( + privacy: PostHogPlatformViewPrivacy.mask, + token: 'SPILLTWO', + ), + ), + ('3_nested_clip_masked', const NestedClipMasked(token: 'SPILLTHREE')), + ( + '4_clipped_revealed', + const ClippedMasked( + privacy: PostHogPlatformViewPrivacy.capture, + token: 'SPILLFOUR', + ), + ), + ( + '5_scrolled_revealed', + const ScrolledPartlyOut( + privacy: PostHogPlatformViewPrivacy.capture, + token: 'SPILLFIVE', + ), + ), + ('6_masked_over_revealed', const MaskedOverRevealed(token: 'SPILLSIX')), + ('7_unclipped_control', const UnclippedMaskedControl(token: 'SPILLSEVEN')), + ('8_revealed_untransformed', const TransformedRevealed(transform: null)), + ( + '9_revealed_quarter_turn', + TransformedRevealed(transform: Matrix4.rotationZ(1.5707963267948966)), + ), + ( + '10_revealed_mirrored', + TransformedRevealed(transform: Matrix4.diagonal3Values(-1, 1, 1)), + ), + ( + '11_revealed_quarter_turn_clipped', + TransformedRevealed( + transform: Matrix4.rotationZ(1.5707963267948966), + clipped: true, + ), + ), +]; /// Menu listing every case, so the repros are reachable from the example app. class PlatformViewSpillScreen extends StatelessWidget { diff --git a/pubspec.lock b/pubspec.lock index 1f8ddde5..9000081f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -332,10 +332,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -348,10 +348,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" package_config: dependency: transitive description: @@ -513,10 +513,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" tuple: dependency: transitive description: @@ -545,10 +545,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: From b74329c7fbe1749ca46d0d83d12e1ba59df8d9cd Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 10:38:01 -0400 Subject: [PATCH 15/17] chore: restore pubspec.lock The lockfile was swept into the formatting commit by a `flutter pub get` that `flutter analyze` ran in the example package. The local Flutter pins older SDK-vendored packages than the committed lock, so it read as a downgrade of matcher, meta, test_api and vector_math. Nothing in this PR needs it. Co-Authored-By: Claude Opus 5 (1M context) --- pubspec.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 9000081f..1f8ddde5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -332,10 +332,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -348,10 +348,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" package_config: dependency: transitive description: @@ -513,10 +513,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" tuple: dependency: transitive description: @@ -545,10 +545,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: From a6fb71a815165e64109cac5eb7148271fe9049b2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 10:52:54 -0400 Subject: [PATCH 16/17] chore(example): drop the unused sentinel token map The tokens are written inline in the case list; nothing read the map. Co-Authored-By: Claude Opus 5 (1M context) --- example/lib/platform_view_spill_screen.dart | 9 --------- 1 file changed, 9 deletions(-) diff --git a/example/lib/platform_view_spill_screen.dart b/example/lib/platform_view_spill_screen.dart index da2e7109..aee32e2c 100644 --- a/example/lib/platform_view_spill_screen.dart +++ b/example/lib/platform_view_spill_screen.dart @@ -15,15 +15,6 @@ import 'package:webview_flutter/webview_flutter.dart'; /// must show the same quadrant in the same corner. A swapped corner means the /// crop was composited in the view's own space and the view's rotation or /// flip was applied a second time. -const spillSentinels = { - 'clipped_masked': 'SPILLONE', - 'scrolled_masked': 'SPILLTWO', - 'nested_clip_masked': 'SPILLTHREE', - 'clipped_revealed': 'SPILLFOUR', - 'scrolled_revealed': 'SPILLFIVE', - 'masked_over_revealed': 'SPILLSIX', - 'unclipped_masked_control': 'SPILLSEVEN', -}; WebViewController _wv() => WebViewController()..loadRequest(Uri.parse('https://www.wikipedia.org')); From ab2d30f7da6c09c5b1f133ae67783c545bd4c427 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 28 Aug 2026 12:06:59 -0400 Subject: [PATCH 17/17] fix(replay): drop the frame when a masked platform view cannot be measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collection walk swallowed a throw and moved on, so the view lost its mask for that frame while the frame still shipped. A texture-backed view's pixels are already in the toImage() snapshot — verified on Android under Impeller and Skia and on iOS, where TextureLayer::Paint draws into the Flutter canvas that PlatformViewLayer only leaves a hole in — so a camera or video frame reached the recording unmasked. The walk now reports failure and the capture drops the frame, matching what the widget mask walk already does. A revealed view still just skips: it has no mask to lose. Co-Authored-By: Claude Opus 5 (1M context) --- .../screenshot/screenshot_capturer.dart | 78 ++++++++++++++----- .../test/platform_view_rects_test.dart | 31 +++++++- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 6d76bfb3..fe1cd913 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -251,18 +251,23 @@ class ScreenshotCapturer { ro is RenderDarwinPlatformView || ro is TextureBox; - _PlatformViewRects _collectPlatformViewRects( - PostHogPlatformViewPrivacy defaultPolicy) { + /// Null when a view that must be masked could not be measured; the caller + /// drops the frame rather than ship it unmasked. + _PlatformViewRects? _collectPlatformViewRects( + PostHogPlatformViewPrivacy defaultPolicy, + [RenderObject? ancestorOverride]) { final masked = []; final captured = <_CapturedView>[]; - final ancestor = PostHogMaskController.instance.containerKey.currentContext - ?.findRenderObject(); + final ancestor = ancestorOverride ?? + PostHogMaskController.instance.containerKey.currentContext + ?.findRenderObject(); final seen = {}; final rootElement = WidgetsBinding.instance.rootElement; - if (rootElement != null) { - _visitElementForPlatformViews( - rootElement, ancestor, masked, captured, seen, defaultPolicy); + if (rootElement != null && + !_visitElementForPlatformViews( + rootElement, ancestor, masked, captured, seen, defaultPolicy)) { + return null; } if (masked.isNotEmpty || captured.isNotEmpty) { @@ -272,7 +277,7 @@ class ScreenshotCapturer { return _PlatformViewRects(masked: masked, captured: captured); } - void _visitElementForPlatformViews( + bool _visitElementForPlatformViews( Element element, RenderObject? ancestor, List masked, @@ -282,20 +287,26 @@ class ScreenshotCapturer { ) { final policy = resolvePrivacyPolicyForElement(element, inheritedPolicy); + var safe = true; final ro = element.renderObject; if (ro is RenderBox && ro.hasSize && ro.size.isValidSize && _isPlatformViewRenderObject(ro)) { - _addIfNew(ro, ancestor, masked, captured, seen, policy); + safe = _addIfNew(ro, ancestor, masked, captured, seen, policy); } - element.visitChildren( - (child) => _visitElementForPlatformViews( - child, ancestor, masked, captured, seen, policy), - ); + element.visitChildren((child) { + if (!_visitElementForPlatformViews( + child, ancestor, masked, captured, seen, policy)) { + safe = false; + } + }); + return safe; } - void _addIfNew( + /// Returns false when a view that must be masked could not be measured, so + /// the caller drops the frame rather than ship it with the mask missing. + bool _addIfNew( RenderBox ro, RenderObject? ancestor, List masked, @@ -303,16 +314,16 @@ class ScreenshotCapturer { Set seen, PostHogPlatformViewPrivacy policy, ) { - if (!seen.add(identityHashCode(ro))) return; + if (!seen.add(identityHashCode(ro))) return true; // TextureBox content is already composited into the Flutter image, so no // native screenshot is needed when revealing. Only mask it when requested. if (ro is TextureBox && policy == PostHogPlatformViewPrivacy.capture) { - return; + return true; } try { final transform = ro.getTransformTo(ancestor); final visible = clippedPaintBounds(ro, ancestor); - if (visible.isEmpty) return; + if (visible.isEmpty) return true; if (policy == PostHogPlatformViewPrivacy.capture) { captured.add(_CapturedView( data: ElementData( @@ -329,8 +340,13 @@ class ScreenshotCapturer { transform: transform, )); } + return true; } catch (e) { printIfDebug('Error collecting platform view rect: $e'); + // A revealed view loses nothing here — it has no mask to place. A masked + // one does, and a texture-backed view's pixels are already in the + // screenshot, so the frame cannot be shipped without its mask. + return policy == PostHogPlatformViewPrivacy.capture; } } @@ -355,9 +371,17 @@ class ScreenshotCapturer { /// The rects the mask painter is handed for the platform views on screen, /// and the visible region each revealed view is clipped to. @visibleForTesting - ({List masked, List revealed}) debugPlatformViewRects( - PostHogPlatformViewPrivacy defaultPolicy) { - final rects = _collectPlatformViewRects(defaultPolicy); + ({List masked, List revealed})? debugPlatformViewRects( + PostHogPlatformViewPrivacy defaultPolicy) => + debugPlatformViewRectsAgainst(defaultPolicy, null); + + /// As [debugPlatformViewRects], but measured against [ancestor] so a test can + /// force the walk to fail. + @visibleForTesting + ({List masked, List revealed})? debugPlatformViewRectsAgainst( + PostHogPlatformViewPrivacy defaultPolicy, RenderObject? ancestor) { + final rects = _collectPlatformViewRects(defaultPolicy, ancestor); + if (rects == null) return null; return ( masked: rects.masked.map((e) => e.rect).toList(), revealed: rects.captured.map((v) => v.visibleRect).toList(), @@ -707,6 +731,20 @@ class ScreenshotCapturer { ? PostHogPlatformViewPrivacy.mask : PostHogPlatformViewPrivacy.capture; final pvRects = _collectPlatformViewRects(defaultPolicy); + // Fail closed, like the widget mask walk above: a platform view we + // could not measure would ship unmasked, and a texture-backed one's + // pixels are already in the screenshot. + if (pvRects == null) { + printIfDebug( + 'The platform view mask walk failed, dropping the frame.', + ); + currentRecorder.endRecording().dispose(); + recorder = null; + currentImage.dispose(); + image = null; + completer.complete(null); + return; + } final hasCapturedViews = pvRects.captured.isNotEmpty; hasCapturedPlatformViews = hasCapturedViews; diff --git a/posthog_flutter/test/platform_view_rects_test.dart b/posthog_flutter/test/platform_view_rects_test.dart index ed797e08..7c1b4559 100644 --- a/posthog_flutter/test/platform_view_rects_test.dart +++ b/posthog_flutter/test/platform_view_rects_test.dart @@ -68,7 +68,8 @@ void main() { await tester.pumpWidget(_clippedView(clipHeight: 100, viewHeight: 300)); final rects = capturer.debugPlatformViewRects(PostHogPlatformViewPrivacy.mask); - expect(rects.masked, [const Rect.fromLTWH(0, 0, 100, 100)]); + expect(rects, isNotNull); + expect(rects!.masked, [const Rect.fromLTWH(0, 0, 100, 100)]); expect(rects.revealed, isEmpty); }); @@ -77,7 +78,8 @@ void main() { await tester.pumpWidget(_clippedView(clipHeight: 100, viewHeight: 300)); final rects = capturer.debugPlatformViewRects(PostHogPlatformViewPrivacy.capture); - expect(rects.masked, isEmpty); + expect(rects, isNotNull); + expect(rects!.masked, isEmpty); expect(rects.revealed, [const Rect.fromLTWH(0, 0, 100, 100)]); }); @@ -114,6 +116,29 @@ void main() { ); final rects = capturer.debugPlatformViewRects(PostHogPlatformViewPrivacy.mask); - expect(rects.masked, isEmpty); + expect(rects, isNotNull); + expect(rects!.masked, isEmpty); + }); + + testWidgets('a masked view that cannot be measured drops the frame', + (tester) async { + // The container the walk measures against sits in a detached tree, so + // getTransformTo throws "not in the same render tree". + final detached = RenderConstrainedBox( + additionalConstraints: const BoxConstraints.tightFor(width: 1)); + addTearDown(detached.dispose); + await tester.pumpWidget(_clippedView(clipHeight: 100, viewHeight: 300)); + + expect( + capturer.debugPlatformViewRectsAgainst( + PostHogPlatformViewPrivacy.mask, detached), + isNull, + ); + // A revealed view has no mask to lose, so the frame still ships. + expect( + capturer.debugPlatformViewRectsAgainst( + PostHogPlatformViewPrivacy.capture, detached), + isNotNull, + ); }); }