From 1d96a474030ad7dd1fbc945aa8e84f5242d2e3f8 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:44:58 +0000 Subject: [PATCH 1/4] fix(replay): mask Compose images that have no content description maskAllImages found Compose images only through the ContentDescription semantics property. Image and Coil AsyncImage with contentDescription = null add no semantics, so session replay recorded them unmasked. The Compose mask pass now also walks the layout tree and masks each node whose modifier holds a non-vector, non-color Painter. postHogUnmask still skips the subtree. Consumer R8 rules keep the LayoutNode children accessor. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 548ab6c2-2e92-481f-bc28-098f88bef3e7 --- .changeset/quiet-images-hide.md | 5 + posthog-android/consumer-rules.pro | 7 + .../android/replay/ComposeImageMask.kt | 90 +++++++++++ .../replay/PostHogReplayIntegration.kt | 11 +- .../PostHogReplayComposeImageMaskTest.kt | 149 ++++++++++++++++++ 5 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-images-hide.md create mode 100644 posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt create mode 100644 posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt diff --git a/.changeset/quiet-images-hide.md b/.changeset/quiet-images-hide.md new file mode 100644 index 000000000..fb7001686 --- /dev/null +++ b/.changeset/quiet-images-hide.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Session replay now masks Jetpack Compose images that have no content description, such as `Image` or Coil `AsyncImage` with `contentDescription = null`, when `maskAllImages` is enabled. diff --git a/posthog-android/consumer-rules.pro b/posthog-android/consumer-rules.pro index 14d6b20f6..933eb2563 100644 --- a/posthog-android/consumer-rules.pro +++ b/posthog-android/consumer-rules.pro @@ -108,6 +108,13 @@ public androidx.compose.ui.node.LayoutNode getLayoutNode(); } +# Session replay walks the Compose layout tree to mask images that have no semantics. +-keepclassmembers class androidx.compose.ui.node.LayoutNode { + java.util.List getChildren$ui(); + java.util.List getChildren$ui_release(); + java.util.List getChildrenInfo(); +} + # Uncomment this to preserve the line number information for # debugging stack traces. -keepattributes SourceFile,LineNumberTable diff --git a/posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt b/posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt new file mode 100644 index 000000000..0d38a7b87 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt @@ -0,0 +1,90 @@ +package com.posthog.android.replay + +import android.graphics.Rect +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.layout.LayoutInfo +import androidx.compose.ui.layout.boundsInWindow +import java.lang.reflect.Field +import java.lang.reflect.Method + +// Image and Coil's AsyncImage add no semantics when contentDescription is null, so the semantics +// tree cannot find them. Walk the layout tree and find the painter modifiers instead. +// Only call on the main thread: the caches below are not synchronized. + +// The internal name of LayoutNode.children depends on the Compose version. +private val layoutNodeChildrenNames = listOf("getChildren\$ui", "getChildren\$ui_release", "getChildrenInfo") +private var layoutNodeChildren: Method? = null + +// Keyed by modifier class. A null value means that the class holds no Painter field. +private val painterFields = HashMap, Field?>() + +/** + * Adds the window bounds of each painter-backed layout node below [root] to [rects]. + * Skips the subtree of each node in [unmaskedLayouts]. + * Throws if Compose internals are not available, so that the caller can fail closed. + */ +internal fun collectComposeImageRects( + root: LayoutInfo, + unmaskedLayouts: Set, + rects: MutableList, +) { + val children = layoutNodeChildren ?: root.javaClass.findChildrenMethod().also { layoutNodeChildren = it } + val stack = ArrayDeque() + stack.addLast(root) + while (stack.isNotEmpty()) { + val node = stack.removeLast() + if (node in unmaskedLayouts || !node.isPlaced) { + continue + } + if (node.hasMaskablePainter()) { + val coordinates = node.coordinates + if (coordinates.isAttached) { + val bounds = coordinates.boundsInWindow() + if (bounds.width > 0 && bounds.height > 0) { + rects.add(Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())) + } + } + } + (children.invoke(node) as List<*>).forEach { child -> + (child as? LayoutInfo)?.let { stack.addLast(it) } + } + } +} + +private fun Class<*>.findChildrenMethod(): Method { + for (name in layoutNodeChildrenNames) { + try { + return getMethod(name) + } catch (_: NoSuchMethodException) { + } + } + throw NoSuchMethodException("$name has no children accessor") +} + +private fun LayoutInfo.hasMaskablePainter(): Boolean = + getModifierInfo().any { info -> + val modifier = info.modifier + val field = modifier.javaClass.painterField() ?: return@any false + (field.get(modifier) as? Painter).shouldMaskPainter() + } + +private fun Class<*>.painterField(): Field? { + if (painterFields.containsKey(this)) { + return painterFields[this] + } + val field = + declaredFields.firstOrNull { Painter::class.java.isAssignableFrom(it.type) }?.apply { isAccessible = true } + painterFields[this] = field + return field +} + +// Same rule as Drawable.shouldMaskDrawable: vectors and plain fills are not images. +private fun Painter?.shouldMaskPainter(): Boolean = + when { + this == null -> false + this is VectorPainter || this is ColorPainter -> false + javaClass.name == "androidx.compose.ui.graphics.painter.BrushPainter" -> false + else -> true + } 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..b03db0f74 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 @@ -49,6 +49,7 @@ import android.widget.RatingBar import android.widget.Spinner import android.widget.Switch import android.widget.TextView +import androidx.compose.ui.layout.LayoutInfo import androidx.compose.ui.node.RootForTest import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsProperties @@ -1303,6 +1304,7 @@ public class PostHogReplayIntegration( return@Runnable } val semanticsNodes = semanticsOwner.getAllSemanticsNodes(true) + val unmaskedLayouts = mutableSetOf() semanticsNodes.forEach { node -> val hasText = node.config.contains(SemanticsProperties.Text) @@ -1318,7 +1320,7 @@ public class PostHogReplayIntegration( when { // postHogUnmask has precedence over everything, skip masking isUnmaskEnabled -> { - // do not mask this node + unmaskedLayouts.add(node.layoutInfo) } // postHogMask forces masking @@ -1341,6 +1343,13 @@ public class PostHogReplayIntegration( } } } + if (config.sessionReplayConfig.maskAllImages) { + collectComposeImageRects( + semanticsOwner.unmergedRootSemanticsNode.layoutInfo, + unmaskedLayouts, + maskableWidgets, + ) + } traversalSucceeded = true } catch (e: Throwable) { // Compose APIs vary by version. Missing masks must fail closed. diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt new file mode 100644 index 000000000..7cadf6283 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt @@ -0,0 +1,149 @@ +package com.posthog.android.replay + +import android.graphics.Rect +import android.view.View +import android.view.ViewGroup +import androidx.activity.compose.setContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.posthog.android.PROJECT_TOKEN +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.internal.InteractionActivity +import com.posthog.android.internal.InteractionTestApplication +import com.posthog.android.internal.MainHandler +import com.posthog.android.replay.PostHogMaskModifier.postHogUnmask +import com.posthog.internal.PostHogLogger +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(application = InteractionTestApplication::class, sdk = [35]) +internal class PostHogReplayComposeImageMaskTest { + @get:Rule + val compose = createAndroidComposeRule() + + // Stands in for a loaded bitmap or Coil's AsyncImagePainter. + private class PhotoPainter : Painter() { + override val intrinsicSize: Size = Size(4f, 4f) + + override fun DrawScope.onDraw() { + drawRect(Color.Red) + } + } + + @Composable + @Suppress("ktlint:standard:function-naming") + private fun PhotoImage() { + Image(painter = PhotoPainter(), contentDescription = null, modifier = Modifier.size(40.dp)) + } + + @Test + fun `masks a photo image without a content description`() { + compose.activity.setContent { + Box(Modifier.testTag("image")) { PhotoImage() } + } + + val rects = maskRects() + + val expected = compose.onNodeWithTag("image").fetchSemanticsNode().boundsInWindow + assertEquals( + listOf(Rect(expected.left.toInt(), expected.top.toInt(), expected.right.toInt(), expected.bottom.toInt())), + rects, + ) + } + + @Test + fun `does not mask a vector image without a content description`() { + val vector = + ImageVector.Builder(defaultWidth = 4.dp, defaultHeight = 4.dp, viewportWidth = 4f, viewportHeight = 4f) + .path(fill = SolidColor(Color.Black)) { + moveTo(0f, 0f) + lineTo(4f, 0f) + lineTo(4f, 4f) + close() + } + .build() + compose.activity.setContent { + Image(painter = rememberVectorPainter(vector), contentDescription = null, modifier = Modifier.size(40.dp)) + } + + assertTrue(maskRects().isEmpty()) + } + + @Test + fun `does not mask an image without a content description inside postHogUnmask`() { + compose.activity.setContent { + Column { + Box(Modifier.postHogUnmask()) { PhotoImage() } + } + } + + assertTrue(maskRects().isEmpty()) + } + + @Test + fun `does not mask an image without a content description when maskAllImages is off`() { + compose.activity.setContent { PhotoImage() } + + assertTrue(maskRects(maskAllImages = false).isEmpty()) + } + + private fun maskRects(maskAllImages: Boolean = true): List { + val messages = CopyOnWriteArrayList() + val config = + PostHogAndroidConfig(PROJECT_TOKEN).apply { + logger = + object : PostHogLogger { + override fun log(message: String) { + messages.add(message) + } + + override fun isEnabled(): Boolean = true + } + } + config.sessionReplayConfig.maskAllImages = maskAllImages + val sut = PostHogReplayIntegration(compose.activity, config, MainHandler()) + val walk = PostHogReplayIntegration.MaskWalk() + compose.runOnIdle { + // On the main thread the Compose traversal runs inline. + sut.findMaskableWidgets(findComposeView(compose.activity.window.decorView)!!, walk) + } + assertFalse(walk.poisoned, "The Compose traversal failed: $messages") + return walk.rects + } + + private fun findComposeView(view: View): View? { + if (view.javaClass.name == PostHogReplayIntegration.ANDROID_COMPOSE_VIEW_CLASS_NAME) { + return view + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + findComposeView(view.getChildAt(i))?.let { return it } + } + } + return null + } +} From 84b9cbcb90e1cc39e34681863669a29f99f5a250 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:45:43 +0000 Subject: [PATCH 2/4] test(replay): drop an unused wrapper in the Compose image mask test Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 548ab6c2-2e92-481f-bc28-098f88bef3e7 --- .../android/replay/PostHogReplayComposeImageMaskTest.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt index 7cadf6283..5fc76bd8c 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt @@ -6,7 +6,6 @@ import android.view.ViewGroup import androidx.activity.compose.setContent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -96,9 +95,7 @@ internal class PostHogReplayComposeImageMaskTest { @Test fun `does not mask an image without a content description inside postHogUnmask`() { compose.activity.setContent { - Column { - Box(Modifier.postHogUnmask()) { PhotoImage() } - } + Box(Modifier.postHogUnmask()) { PhotoImage() } } assertTrue(maskRects().isEmpty()) From 4f887c360dd909f1dd6e9b123df5637914c2c36f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 17:33:56 -0400 Subject: [PATCH 3/4] test(replay): cover bitmap images and the mask controls for Compose images Adds the Image(bitmap = ..., contentDescription = null) case from review, plus the described-image, masking-off and postHogMask controls. Co-Authored-By: Claude Opus 5.5 --- .../PostHogReplayComposeImageMaskTest.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt index 5fc76bd8c..11b279842 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt @@ -1,5 +1,6 @@ package com.posthog.android.replay +import android.graphics.Bitmap import android.graphics.Rect import android.view.View import android.view.ViewGroup @@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @@ -26,6 +28,7 @@ import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.InteractionActivity import com.posthog.android.internal.InteractionTestApplication import com.posthog.android.internal.MainHandler +import com.posthog.android.replay.PostHogMaskModifier.postHogMask import com.posthog.android.replay.PostHogMaskModifier.postHogUnmask import com.posthog.internal.PostHogLogger import org.junit.Rule @@ -74,6 +77,51 @@ internal class PostHogReplayComposeImageMaskTest { ) } + @Composable + @Suppress("ktlint:standard:function-naming") + private fun BitmapImage(contentDescription: String?) { + Image( + bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888).asImageBitmap(), + contentDescription = contentDescription, + modifier = Modifier.size(40.dp), + ) + } + + @Test + fun `masks a bitmap image without a content description`() { + compose.activity.setContent { + Box(Modifier.testTag("image")) { BitmapImage(contentDescription = null) } + } + + assertEquals(listOf(boundsOf("image")), maskRects()) + } + + @Test + fun `masks a bitmap image with a content description`() { + compose.activity.setContent { + Box(Modifier.testTag("image")) { BitmapImage(contentDescription = "photo") } + } + + // Both the semantics pass and the layout pass find it. + assertEquals(setOf(boundsOf("image")), maskRects().toSet()) + } + + @Test + fun `does not mask a bitmap image with a content description when maskAllImages is off`() { + compose.activity.setContent { BitmapImage(contentDescription = "photo") } + + assertTrue(maskRects(maskAllImages = false).isEmpty()) + } + + @Test + fun `masks an image without a content description inside postHogMask when maskAllImages is off`() { + compose.activity.setContent { + Box(Modifier.testTag("image").postHogMask()) { BitmapImage(contentDescription = null) } + } + + assertEquals(setOf(boundsOf("image")), maskRects(maskAllImages = false).toSet()) + } + @Test fun `does not mask a vector image without a content description`() { val vector = @@ -108,6 +156,11 @@ internal class PostHogReplayComposeImageMaskTest { assertTrue(maskRects(maskAllImages = false).isEmpty()) } + private fun boundsOf(tag: String): Rect { + val bounds = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInWindow + return Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) + } + private fun maskRects(maskAllImages: Boolean = true): List { val messages = CopyOnWriteArrayList() val config = From 31b9d0873dd4bdda8fc22b27192dbe7cc7ca1728 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 18:07:54 -0400 Subject: [PATCH 4/4] fix(replay): find Compose image painters on modifier nodes Coil 3's AsyncImage and Glide keep the painter on the Modifier.Node, not on the modifier element, so a content-description-less AsyncImage was still recorded unmasked. Scan each modifier and its paired node, and mask at that modifier's coordinates so a painter followed by padding covers the padding too. Fail closed when Modifier.Node exists but its accessors moved, and keep the BrushPainter name under R8. Co-Authored-By: Claude Opus 5.5 --- posthog-android/consumer-rules.pro | 12 ++ .../android/replay/ComposeImageMask.kt | 107 +++++++++++++----- .../PostHogReplayComposeImageMaskTest.kt | 59 ++++++++++ 3 files changed, 149 insertions(+), 29 deletions(-) diff --git a/posthog-android/consumer-rules.pro b/posthog-android/consumer-rules.pro index 933eb2563..e892980cd 100644 --- a/posthog-android/consumer-rules.pro +++ b/posthog-android/consumer-rules.pro @@ -113,6 +113,18 @@ java.util.List getChildren$ui(); java.util.List getChildren$ui_release(); java.util.List getChildrenInfo(); + *** getNodes$ui(); + *** getNodes$ui_release(); +} +-keepclassmembers class androidx.compose.ui.node.NodeChain { + *** getHead$ui(); + *** getHead$ui_release(); +} +-keepnames class androidx.compose.ui.Modifier$Node +-keepnames class androidx.compose.ui.graphics.painter.BrushPainter +-keepclassmembers class androidx.compose.ui.Modifier$Node { + *** getChild$ui(); + *** getChild$ui_release(); } # Uncomment this to preserve the line number information for diff --git a/posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt b/posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt index 0d38a7b87..4b80aa0a7 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt @@ -8,17 +8,26 @@ import androidx.compose.ui.layout.LayoutInfo import androidx.compose.ui.layout.boundsInWindow import java.lang.reflect.Field import java.lang.reflect.Method +import java.lang.reflect.Modifier // Image and Coil's AsyncImage add no semantics when contentDescription is null, so the semantics -// tree cannot find them. Walk the layout tree and find the painter modifiers instead. +// tree cannot find them. Walk the layout tree and find the painters instead. // Only call on the main thread: the caches below are not synchronized. -// The internal name of LayoutNode.children depends on the Compose version. +// These internal names depend on the Compose version. private val layoutNodeChildrenNames = listOf("getChildren\$ui", "getChildren\$ui_release", "getChildrenInfo") +private val layoutNodeNodesNames = listOf("getNodes\$ui", "getNodes\$ui_release") +private val nodeChainHeadNames = listOf("getHead\$ui", "getHead\$ui_release") +private val modifierNodeChildNames = listOf("getChild\$ui", "getChild\$ui_release") + +private class NodeChainAccess(val nodes: Method, val head: Method, val child: Method) + private var layoutNodeChildren: Method? = null +private var nodeChainResolved = false +private var nodeChainAccess: NodeChainAccess? = null -// Keyed by modifier class. A null value means that the class holds no Painter field. -private val painterFields = HashMap, Field?>() +// Keyed by class: the Painter fields it and its superclasses declare. +private val painterFields = HashMap, List>() /** * Adds the window bounds of each painter-backed layout node below [root] to [rects]. @@ -30,7 +39,12 @@ internal fun collectComposeImageRects( unmaskedLayouts: Set, rects: MutableList, ) { - val children = layoutNodeChildren ?: root.javaClass.findChildrenMethod().also { layoutNodeChildren = it } + val children = layoutNodeChildren ?: root.javaClass.findMethod(layoutNodeChildrenNames).also { layoutNodeChildren = it } + if (!nodeChainResolved) { + nodeChainAccess = root.javaClass.findNodeChainAccess() + nodeChainResolved = true + } + val chain = nodeChainAccess val stack = ArrayDeque() stack.addLast(root) while (stack.isNotEmpty()) { @@ -38,48 +52,83 @@ internal fun collectComposeImageRects( if (node in unmaskedLayouts || !node.isPlaced) { continue } - if (node.hasMaskablePainter()) { - val coordinates = node.coordinates - if (coordinates.isAttached) { - val bounds = coordinates.boundsInWindow() - if (bounds.width > 0 && bounds.height > 0) { - rects.add(Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())) - } - } - } + node.collectPainterRects(chain, rects) (children.invoke(node) as List<*>).forEach { child -> (child as? LayoutInfo)?.let { stack.addLast(it) } } } } -private fun Class<*>.findChildrenMethod(): Method { - for (name in layoutNodeChildrenNames) { +private fun Class<*>.findMethodOrNull(names: List): Method? { + for (name in names) { try { return getMethod(name) } catch (_: NoSuchMethodException) { } } - throw NoSuchMethodException("$name has no children accessor") + return null +} + +private fun Class<*>.findMethod(names: List): Method = + findMethodOrNull(names) ?: throw NoSuchMethodException("$name has none of $names") + +// Compose before 1.3 has no Modifier.Node, so painters are on the modifiers only. Once it exists, +// a missing accessor means the internals moved, so throw to fail closed. +private fun Class<*>.findNodeChainAccess(): NodeChainAccess? { + val nodes = + findMethodOrNull(layoutNodeNodesNames) + ?: if (hasModifierNode()) throw NoSuchMethodException("$name has none of $layoutNodeNodesNames") else return null + val head = nodes.returnType.findMethod(nodeChainHeadNames) + val child = head.returnType.findMethod(modifierNodeChildNames) + return NodeChainAccess(nodes, head, child) } -private fun LayoutInfo.hasMaskablePainter(): Boolean = - getModifierInfo().any { info -> - val modifier = info.modifier - val field = modifier.javaClass.painterField() ?: return@any false - (field.get(modifier) as? Painter).shouldMaskPainter() +private fun hasModifierNode(): Boolean = + try { + Class.forName("androidx.compose.ui.Modifier\$Node") + true + } catch (_: ClassNotFoundException) { + false } -private fun Class<*>.painterField(): Field? { - if (painterFields.containsKey(this)) { - return painterFields[this] +// Same walk as NodeChain.getModifierInfo: the i-th ModifierInfo pairs with the i-th Modifier.Node +// from the head, and the tail node has no ModifierInfo. Old-style modifiers keep the painter on +// the modifier; Coil 3's AsyncImage and Glide keep it on the node only. Each match uses its own +// modifier's coordinates: a painter followed by padding also paints the padding. +private fun LayoutInfo.collectPainterRects( + chain: NodeChainAccess?, + rects: MutableList, +) { + var node = chain?.let { it.head.invoke(it.nodes.invoke(this)) } + for (info in getModifierInfo()) { + if (chain != null && node == null) { + throw IllegalStateException("Compose modifier node chain is shorter than its modifiers") + } + if (info.modifier.holdsMaskablePainter() || node?.holdsMaskablePainter() == true) { + val coordinates = info.coordinates + if (coordinates.isAttached) { + val bounds = coordinates.boundsInWindow() + if (bounds.width > 0 && bounds.height > 0) { + rects.add(Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())) + } + } + } + node = node?.let { chain?.child?.invoke(it) } } - val field = - declaredFields.firstOrNull { Painter::class.java.isAssignableFrom(it.type) }?.apply { isAccessible = true } - painterFields[this] = field - return field } +private fun Any.holdsMaskablePainter(): Boolean = + javaClass.painterFields().any { field -> (field.get(this) as? Painter).shouldMaskPainter() } + +private fun Class<*>.painterFields(): List = + painterFields.getOrPut(this) { + generateSequence(this) { it.superclass } + .flatMap { it.declaredFields.asSequence() } + .filter { !Modifier.isStatic(it.modifiers) && Painter::class.java.isAssignableFrom(it.type) } + .onEach { it.isAccessible = true } + .toList() + } + // Same rule as Drawable.shouldMaskDrawable: vectors and plain fills are not images. private fun Painter?.shouldMaskPainter(): Boolean = when { diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt index 11b279842..7ae4bdacb 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt @@ -7,18 +7,24 @@ import android.view.ViewGroup import androidx.activity.compose.setContent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.DrawModifier +import androidx.compose.ui.draw.paint import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -56,6 +62,32 @@ internal class PostHogReplayComposeImageMaskTest { } } + // Shaped like Coil 3's AsyncImage: the element holds no painter, and the node inherits its + // painter field from a superclass. + private open class PainterHolderNode(private val painter: Painter) : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { + with(painter) { draw(size) } + } + } + + private class NodePainterNode(painter: Painter) : PainterHolderNode(painter) + + private class NodePainterElement : ModifierNodeElement() { + override fun create() = NodePainterNode(PhotoPainter()) + + override fun update(node: NodePainterNode) {} + + override fun hashCode() = 0 + + override fun equals(other: Any?) = other is NodePainterElement + } + + private class LegacyPainterModifier(val painter: Painter) : DrawModifier { + override fun ContentDrawScope.draw() { + with(painter) { draw(size) } + } + } + @Composable @Suppress("ktlint:standard:function-naming") private fun PhotoImage() { @@ -122,6 +154,33 @@ internal class PostHogReplayComposeImageMaskTest { assertEquals(setOf(boundsOf("image")), maskRects(maskAllImages = false).toSet()) } + @Test + fun `masks an image whose painter is only on its modifier node`() { + compose.activity.setContent { + Box(Modifier.testTag("image").size(40.dp).then(NodePainterElement())) + } + + assertEquals(listOf(boundsOf("image")), maskRects()) + } + + @Test + fun `masks an image whose painter is on an old-style draw modifier`() { + compose.activity.setContent { + Box(Modifier.testTag("image").size(40.dp).then(LegacyPainterModifier(PhotoPainter()))) + } + + assertEquals(listOf(boundsOf("image")), maskRects()) + } + + @Test + fun `masks the whole painted area when padding follows the painter`() { + compose.activity.setContent { + Box(Modifier.testTag("image").size(100.dp).paint(PhotoPainter()).padding(30.dp)) + } + + assertEquals(listOf(boundsOf("image")), maskRects()) + } + @Test fun `does not mask a vector image without a content description`() { val vector =