diff --git a/.changeset/platform-view-mask-spill.md b/.changeset/platform-view-mask-spill.md new file mode 100644 index 00000000..a37cc20e --- /dev/null +++ b/.changeset/platform-view-mask-spill.md @@ -0,0 +1,5 @@ +--- +'posthog_flutter': patch +--- + +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/example/lib/main.dart b/example/lib/main.dart index 741ea663..ce4cd592 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,25 @@ 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..aee32e2c --- /dev/null +++ b/example/lib/platform_view_spill_screen.dart @@ -0,0 +1,414 @@ +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. + +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'), + ), + ), + ), + ], + ), + ); + } +} diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 239e0fa0..fe1cd913 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -60,9 +60,19 @@ class ViewTreeSnapshotStatus { ViewTreeSnapshotStatus(this.sentMetaEvent); } +/// 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 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; + const _CapturedView({required this.data, required this.visibleRect}); +} + class _PlatformViewRects { final List masked; - final List captured; + final List<_CapturedView> captured; const _PlatformViewRects({required this.masked, required this.captured}); } @@ -241,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 = []; - final ancestor = PostHogMaskController.instance.containerKey.currentContext - ?.findRenderObject(); + final captured = <_CapturedView>[]; + 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) { @@ -262,60 +277,117 @@ class ScreenshotCapturer { return _PlatformViewRects(masked: masked, captured: captured); } - void _visitElementForPlatformViews( + bool _visitElementForPlatformViews( Element element, RenderObject? ancestor, List masked, - List captured, + List<_CapturedView> captured, Set seen, PostHogPlatformViewPrivacy inheritedPolicy, ) { 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, - List captured, + List<_CapturedView> captured, 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 data = ElementData( - rect: ro.paintBounds, - type: 'platformView', - transform: transform, - ); + final visible = clippedPaintBounds(ro, ancestor); + if (visible.isEmpty) return true; 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(ElementData( + rect: visible, + type: 'platformView', + 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; } } + /// 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) => + 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(), + ); + } + Map _viewSpec(ElementData viewRect, Offset globalPosition) { final transform = viewRect.transform; if (transform == null) return {'x': 0, 'y': 0, 'width': 0, 'height': 0}; @@ -330,32 +402,36 @@ class ScreenshotCapturer { 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); + // The native crop covers the whole view; only the clipped part may be painted. + 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; } - canvas.drawImageRect( - nativeImage, - Rect.fromLTWH( - 0, 0, nativeImage.width.toDouble(), nativeImage.height.toDouble()), - transformedRect, - Paint()..blendMode = ui.BlendMode.srcOver, - ); - nativeImage.dispose(); + try { + compositeRevealedImage( + canvas, nativeImage, transform, viewRect.rect, view.visibleRect); + } finally { + nativeImage.dispose(); + } } Future _decodeRawPixels(Uint8List bytes, int width, int height) { @@ -655,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; @@ -701,7 +791,7 @@ class ScreenshotCapturer { } 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 +910,133 @@ class ScreenshotCapturer { } } +/// 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.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; +} + +/// 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; + RenderObject child = ro; + RenderObject? node = ro.parent; + while (node != null && !identical(node, ancestor)) { + // 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 { + // 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 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 + // 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); + } + } catch (e) { + printIfDebug('Skipping an ancestor clip that could not be mapped: $e'); + clip = null; + toRo = null; + } + if (clip != null && toRo != null) { + clipped = clipped.intersect(MatrixUtils.transformRect(toRo, clip)); + if (clipped.isEmpty) return Rect.zero; + } + child = node; + node = node.parent; + } + return clipped; +} + +/// 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. +/// +/// 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, + 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 new file mode 100644 index 00000000..6b2a45ae --- /dev/null +++ b/posthog_flutter/test/platform_view_clip_test.dart @@ -0,0 +1,749 @@ +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 clip is mapped through a non-zero ancestor offset', + (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + 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'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTWH(0, 0, 250, 200), + ); + }); + + 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('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 — overlapping sliver header', () { + testWidgets('a pinned header trims the band it covers', (tester) async { + 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(); + + // 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; + // under a translucent one the band can still show the view's own pixels. + expect( + clippedPaintBounds( + tester.renderObject(find.byKey(const Key('view'))), + tester.renderObject(find.byKey(const Key('ancestor'))), + ), + const Rect.fromLTRB(0, 100, 300, 200), + ); + }); + }); + + 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('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 + // 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('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. + 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 + // 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: 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), + ), + ), + ), + ), + ), + ); + + 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 { + 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), + ), + ), + ), + ), + ); + + // 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'))), + 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) => 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) => const Rect.fromLTWH(0, 0, 10, 10); + + @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; +} + +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; +} + +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; +} 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..f11a5989 --- /dev/null +++ b/posthog_flutter/test/platform_view_composite_test.dart @@ -0,0 +1,175 @@ +import 'dart:math'; +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); +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('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), + 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); + }); + + 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..7c1b4559 --- /dev/null +++ b/posthog_flutter/test/platform_view_rects_test.dart @@ -0,0 +1,144 @@ +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, isNotNull); + 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, isNotNull); + 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, 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, + ); + }); +}