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..e892980cd 100644 --- a/posthog-android/consumer-rules.pro +++ b/posthog-android/consumer-rules.pro @@ -108,6 +108,25 @@ 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(); + *** 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 # 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..4b80aa0a7 --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/replay/ComposeImageMask.kt @@ -0,0 +1,139 @@ +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 +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 painters instead. +// Only call on the main thread: the caches below are not synchronized. + +// 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 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]. + * 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.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()) { + val node = stack.removeLast() + if (node in unmaskedLayouts || !node.isPlaced) { + continue + } + node.collectPainterRects(chain, rects) + (children.invoke(node) as List<*>).forEach { child -> + (child as? LayoutInfo)?.let { stack.addLast(it) } + } + } +} + +private fun Class<*>.findMethodOrNull(names: List): Method? { + for (name in names) { + try { + return getMethod(name) + } catch (_: NoSuchMethodException) { + } + } + 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 hasModifierNode(): Boolean = + try { + Class.forName("androidx.compose.ui.Modifier\$Node") + true + } catch (_: ClassNotFoundException) { + false + } + +// 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) } + } +} + +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 { + 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..7ae4bdacb --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayComposeImageMaskTest.kt @@ -0,0 +1,258 @@ +package com.posthog.android.replay + +import android.graphics.Bitmap +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.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 +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.postHogMask +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) + } + } + + // 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() { + 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, + ) + } + + @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 `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 = + 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 { + 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 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 = + 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 + } +}