Skip to content

Fix broken view hierarchy retrieval for Jetpack Compose 1.8+ #4485

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 17, 2025
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixes

- Fix Session Replay masking for newer versions of Jetpack Compose (1.8+) ([#4485](https://github.com/getsentry/sentry-java/pull/4485))

### Features

- Add New User Feedback form ([#4384](https://github.com/getsentry/sentry-java/pull/4384))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.compose.ui.layout.findRootCoordinates
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.node.Owner
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.TextLayoutResult
Expand All @@ -29,26 +30,51 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
import java.lang.ref.WeakReference
import java.lang.reflect.Method

@TargetApi(26)
internal object ComposeViewHierarchyNode {

private val getSemanticsConfigurationMethod: Method? by lazy {
try {
return@lazy LayoutNode::class.java.getDeclaredMethod("getSemanticsConfiguration").apply {
isAccessible = true
}
} catch (_: Throwable) {
// ignore, as this method may not be available
}
return@lazy null
}

private var semanticsRetrievalErrorLogged: Boolean = false

@JvmStatic
internal fun retrieveSemanticsConfiguration(node: LayoutNode): SemanticsConfiguration? {
// Jetpack Compose 1.8 or newer provides SemanticsConfiguration via SemanticsInfo
// See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
// and https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
getSemanticsConfigurationMethod?.let {
return it.invoke(node) as SemanticsConfiguration?
}

// for backwards compatibility
return node.collapsedSemantics
}

/**
* Since Compose doesn't have a concept of a View class (they are all composable functions),
* we need to map the semantics node to a corresponding old view system class.
*/
private fun LayoutNode.getProxyClassName(isImage: Boolean): String {
private fun getProxyClassName(isImage: Boolean, config: SemanticsConfiguration?): String {
return when {
isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME
collapsedSemantics?.contains(SemanticsProperties.Text) == true ||
collapsedSemantics?.contains(SemanticsActions.SetText) == true ||
collapsedSemantics?.contains(SemanticsProperties.EditableText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME
config != null && (config.contains(SemanticsProperties.Text) || config.contains(SemanticsActions.SetText) || config.contains(SemanticsProperties.EditableText)) -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME
else -> "android.view.View"
}
}

private fun LayoutNode.shouldMask(isImage: Boolean, options: SentryOptions): Boolean {
val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy)
private fun SemanticsConfiguration?.shouldMask(isImage: Boolean, options: SentryOptions): Boolean {
val sentryPrivacyModifier = this?.getOrNull(SentryReplayModifiers.SentryPrivacy)
if (sentryPrivacyModifier == "unmask") {
return false
}
Expand All @@ -57,7 +83,7 @@ internal object ComposeViewHierarchyNode {
return true
}

val className = getProxyClassName(isImage)
val className = getProxyClassName(isImage, this)
if (options.sessionReplay.unmaskViewClasses.contains(className)) {
return false
}
Expand All @@ -83,16 +109,53 @@ internal object ComposeViewHierarchyNode {
_rootCoordinates = WeakReference(node.coordinates.findRootCoordinates())
}

val semantics = node.collapsedSemantics
val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates?.get())
val semantics: SemanticsConfiguration?

try {
semantics = retrieveSemanticsConfiguration(node)
} catch (t: Throwable) {
if (!semanticsRetrievalErrorLogged) {
semanticsRetrievalErrorLogged = true
options.logger.log(
SentryLevel.ERROR,
t,
"""
Error retrieving semantics information from Compose tree. Most likely you're using
an unsupported version of androidx.compose.ui:ui. The supported
version range is 1.5.0 - 1.8.0.
If you're using a newer version, please open a github issue with the version
you're using, so we can add support for it.
""".trimIndent()
)
}

// If we're unable to retrieve the semantics configuration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking if we should check if any masking is actually enabled? Thinking of a case where nothing should be masked, but we still would weirdly mask something when failed to retrieve the semantics.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(maskAllText and maskAllImages, that is)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should even process the view hierarchy if no masking is active? I'll get this merged now, as by default masking is active anyway. We can always improve on top.

// we should play safe and mask the whole node.
return GenericViewHierarchyNode(
x = visibleRect.left.toFloat(),
y = visibleRect.top.toFloat(),
width = node.width,
height = node.height,
elevation = (parent?.elevation ?: 0f),
distance = distance,
parent = parent,
shouldMask = true,
isImportantForContentCapture = false, /* will be set by children */
isVisible = !node.outerCoordinator.isTransparent() && visibleRect.height() > 0 && visibleRect.width() > 0,
visibleRect = visibleRect
)
}

val isVisible = !node.outerCoordinator.isTransparent() &&
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
visibleRect.height() > 0 && visibleRect.width() > 0
val isEditable = semantics?.contains(SemanticsActions.SetText) == true ||
semantics?.contains(SemanticsProperties.EditableText) == true

return when {
semantics?.contains(SemanticsProperties.Text) == true || isEditable -> {
val shouldMask = isVisible && node.shouldMask(isImage = false, options)
val shouldMask = isVisible && semantics.shouldMask(isImage = false, options)

parent?.setImportantForCaptureToAncestors(true)
// TODO: if we get reports that it's slow, we can drop this, and just mask
Expand Down Expand Up @@ -133,7 +196,7 @@ internal object ComposeViewHierarchyNode {
else -> {
val painter = node.findPainter()
if (painter != null) {
val shouldMask = isVisible && node.shouldMask(isImage = true, options)
val shouldMask = isVisible && semantics.shouldMask(isImage = true, options)

parent?.setImportantForCaptureToAncestors(true)
ImageViewHierarchyNode(
Expand All @@ -150,7 +213,7 @@ internal object ComposeViewHierarchyNode {
visibleRect = visibleRect
)
} else {
val shouldMask = isVisible && node.shouldMask(isImage = false, options)
val shouldMask = isVisible && semantics.shouldMask(isImage = false, options)

// TODO: this currently does not support embedded AndroidViews, we'd have to
// TODO: traverse the ViewHierarchyNode here again. For now we can recommend
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")

package io.sentry.android.replay.viewhierarchy

import android.app.Activity
import android.net.Uri
import android.os.Bundle
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
Expand All @@ -15,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.editableText
Expand All @@ -37,15 +42,22 @@ import io.sentry.android.replay.util.traverse
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.runner.RunWith
import org.mockito.MockedStatic
import org.mockito.Mockito
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.Robolectric.buildActivity
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

Expand Down Expand Up @@ -139,6 +151,44 @@ class ComposeMaskingOptionsTest {
assertTrue(imageNodes.all { it.shouldMask })
}

@Test
fun `when retrieving the semantics fails, a node should be masked`() {
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
shadowOf(Looper.getMainLooper()).idle()
val options = SentryOptions()

Mockito.mockStatic(ComposeViewHierarchyNode.javaClass)
.use { mock: MockedStatic<ComposeViewHierarchyNode> ->
mock.`when`<Any> {
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(any<LayoutNode>())
}.thenThrow(RuntimeException())

val root = activity.get().window.decorView
val composeView = root.lookupComposeView()
assertNotNull(composeView)

val rootNode = GenericViewHierarchyNode(0f, 0f, 0, 0, 1.0f, -1, shouldMask = true)
ComposeViewHierarchyNode.fromView(composeView, rootNode, options)

assertEquals(1, rootNode.children?.size)

rootNode.traverse { node ->
assertTrue(node.shouldMask)
true
}
}
}

@Test
fun `when retrieving the semantics fails, an error is thrown`() {
val node = mock<LayoutNode>()
whenever(node.collapsedSemantics).thenThrow(RuntimeException("Compose Runtime Error"))

assertThrows(RuntimeException::class.java) {
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node)
}
}

@Test
fun `when maskAllImages is set to false all Image nodes are unmasked`() {
val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup()
Expand Down Expand Up @@ -246,6 +296,22 @@ class ComposeMaskingOptionsTest {
}
return nodes
}

private fun View.lookupComposeView(): View? {
if (this.javaClass.name.contains("AndroidComposeView")) {
return this
}
if (this is ViewGroup) {
for (i in 0 until childCount) {
val child = getChildAt(i)
val composeView = child.lookupComposeView()
if (composeView != null) {
return composeView
}
}
}
return null
}
}

private class ComposeMaskingOptionsActivity : ComponentActivity() {
Expand Down
Loading