From 4ffeb6abbff22fe36ff6af02aa7a8952c3c31915 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 17:22:23 -0400 Subject: [PATCH 1/2] fix(replay): mask descendants of ph-no-capture containers A ph-no-capture view only masked its own rect and stopped the walk, so a child of an unclipped container could draw outside that rect unmasked. Descendants are now walked and masked too; a ComposeView inside the subtree is masked whole. ph-no-mask on a descendant still wins. Co-Authored-By: Claude Opus 5.5 --- .changeset/mask-no-capture-descendants.md | 5 ++ .../replay/PostHogReplayIntegration.kt | 20 ++++++-- .../replay/PostHogReplayIntegrationTest.kt | 48 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 .changeset/mask-no-capture-descendants.md diff --git a/.changeset/mask-no-capture-descendants.md b/.changeset/mask-no-capture-descendants.md new file mode 100644 index 000000000..a2049e60b --- /dev/null +++ b/.changeset/mask-no-capture-descendants.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Session replay now masks the children of a `ph-no-capture` view, so content that draws outside an unclipped container is no longer recorded. While one of those children is animating, the snapshot may be skipped. diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index daec3775b..cf4711c7b 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -1104,6 +1104,7 @@ public class PostHogReplayIntegration( internal fun findMaskableWidgets( view: View, walk: MaskWalk, + insideNoCapture: Boolean = false, ) { if (walk.shouldStop) { return @@ -1115,23 +1116,32 @@ public class PostHogReplayIntegration( } var walkChildren = false + var childrenInsideNoCapture = false when { view.isComposeView() -> { - findMaskableComposeWidgets(view, walk) - if (walk.shouldStop) { - return + if (insideNoCapture) { + view.addGlobalVisibleRect(walk) + } else { + findMaskableComposeWidgets(view, walk) + if (walk.shouldStop) { + return + } } // Also walk View children for interop scenarios (AndroidView, FragmentContainerView, etc.) walkChildren = true + childrenInsideNoCapture = insideNoCapture } view.isUnmasked() -> { // ph-no-mask has precedence, skip masking } - view.isNoCapture() -> { + // Children of an unclipped container can draw outside its rect, so they are masked too. + insideNoCapture || view.isNoCapture() -> { view.addGlobalVisibleRect(walk) + walkChildren = true + childrenInsideNoCapture = true } view is TextView -> { @@ -1182,7 +1192,7 @@ public class PostHogReplayIntegration( continue } - findMaskableWidgets(viewChild, walk) + findMaskableWidgets(viewChild, walk, childrenInsideNoCapture) } } } diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index fcf0b0a72..ec7609a2e 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -258,6 +258,54 @@ internal class PostHogReplayIntegrationTest { assertEquals(listOf(Rect(unpanned[0]).apply { offset(0, -150) }), panned) } + // An unclipped no-capture container lets its children draw outside its own rect. + @Test + fun `no-capture container masks descendants that overflow its bounds`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + // getGlobalVisibleRect clips a child to its parent unless the grandparent is unclipped. + val root = FrameLayout(activity).apply { clipChildren = false } + val wrapper = + FrameLayout(activity).apply { + tag = "ph-no-capture" + clipChildren = false + clipToPadding = false + } + val text = TextView(activity).apply { text = "secret" } + val plain = View(activity) + val compose = FakeAndroidComposeView(activity) + val unmasked = View(activity).apply { tag = "ph-no-mask" } + listOf(text, plain, compose, unmasked).forEach { wrapper.addView(it) } + root.addView(wrapper) + activity.setContentView(root) + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(activity.window.decorView) + root.layout(0, 0, 100, 400) + wrapper.layout(0, 0, 100, 20) + text.layout(0, 30, 100, 50) + plain.layout(0, 60, 100, 80) + compose.layout(0, 90, 100, 110) + unmasked.layout(0, 120, 100, 140) + val sut = PostHogReplayIntegration(activity, PostHogAndroidConfig(PROJECT_TOKEN), MainHandler()) + + fun rectOf(view: View) = Rect().also { view.getGlobalVisibleRect(it) } + + val walk = PostHogReplayIntegration.MaskWalk().also { sut.findMaskableWidgets(root, it) } + assertFalse(walk.poisoned) + assertEquals(listOf(wrapper, text, plain, compose).map(::rectOf), walk.rects) + + // The verified path re-walks at draw time and compares against the capture's rects. + val verify = PostHogReplayIntegration.MaskWalk().apply { resetForCompareAgainst(walk.rects) } + sut.findMaskableWidgets(root, verify) + assertFalse(verify.isMisaligned()) + + // Without the tag only the text keeps its own mask. The fake Compose view is removed + // because an untagged walk would run a real semantics traversal on it. + wrapper.removeView(compose) + wrapper.tag = null + val untagged = PostHogReplayIntegration.MaskWalk().also { sut.findMaskableWidgets(root, it) } + assertEquals(listOf(rectOf(text)), untagged.rects) + } + @Test fun `timed out view collection does not run later or emit a snapshot`() { val h = screenshotCaptureHarness() From 1c46cc3aa40ea6c38717bae0405f3c0fcbbb0ef4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 10:23:10 -0400 Subject: [PATCH 2/2] fix(replay): mask a ph-no-capture Compose view whole Co-Authored-By: Claude Opus 5.5 --- .../android/replay/PostHogReplayIntegration.kt | 4 ++-- .../replay/PostHogReplayIntegrationTest.kt | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index cf4711c7b..b60fe134c 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -1120,8 +1120,9 @@ public class PostHogReplayIntegration( when { view.isComposeView() -> { - if (insideNoCapture) { + if (insideNoCapture || view.isNoCapture()) { view.addGlobalVisibleRect(walk) + childrenInsideNoCapture = true } else { findMaskableComposeWidgets(view, walk) if (walk.shouldStop) { @@ -1130,7 +1131,6 @@ public class PostHogReplayIntegration( } // Also walk View children for interop scenarios (AndroidView, FragmentContainerView, etc.) walkChildren = true - childrenInsideNoCapture = insideNoCapture } view.isUnmasked() -> { diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index ec7609a2e..94311d9b4 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -306,6 +306,24 @@ internal class PostHogReplayIntegrationTest { assertEquals(listOf(rectOf(text)), untagged.rects) } + @Test + fun `no-capture tag on a Compose view masks it whole`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val compose = FakeAndroidComposeView(activity).apply { tag = "ph-no-capture" } + val root = FrameLayout(activity).apply { addView(compose) } + activity.setContentView(root) + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(activity.window.decorView) + root.layout(0, 0, 100, 100) + compose.layout(0, 0, 100, 20) + val sut = PostHogReplayIntegration(activity, PostHogAndroidConfig(PROJECT_TOKEN), MainHandler()) + + val walk = PostHogReplayIntegration.MaskWalk().also { sut.findMaskableWidgets(root, it) } + + assertFalse(walk.poisoned) + assertEquals(listOf(Rect().also { compose.getGlobalVisibleRect(it) }), walk.rects) + } + @Test fun `timed out view collection does not run later or emit a snapshot`() { val h = screenshotCaptureHarness()