Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mask-no-capture-descendants.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,7 @@ public class PostHogReplayIntegration(
internal fun findMaskableWidgets(
view: View,
walk: MaskWalk,
insideNoCapture: Boolean = false,
) {
if (walk.shouldStop) {
return
Expand All @@ -1115,12 +1116,18 @@ public class PostHogReplayIntegration(
}

var walkChildren = false
var childrenInsideNoCapture = false

when {
view.isComposeView() -> {
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
findMaskableComposeWidgets(view, walk)
if (walk.shouldStop) {
return
if (insideNoCapture || view.isNoCapture()) {
view.addGlobalVisibleRect(walk)
childrenInsideNoCapture = true
} else {
findMaskableComposeWidgets(view, walk)
if (walk.shouldStop) {
return
}
}
// Also walk View children for interop scenarios (AndroidView, FragmentContainerView, etc.)
walkChildren = true
Expand All @@ -1130,8 +1137,11 @@ public class PostHogReplayIntegration(
// 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 -> {
Expand Down Expand Up @@ -1182,7 +1192,7 @@ public class PostHogReplayIntegration(
continue
}

findMaskableWidgets(viewChild, walk)
findMaskableWidgets(viewChild, walk, childrenInsideNoCapture)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,72 @@ 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 `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()
Expand Down
Loading