@@ -11,6 +11,7 @@ import androidx.compose.ui.layout.findRootCoordinates
1111import androidx.compose.ui.node.LayoutNode
1212import androidx.compose.ui.node.Owner
1313import androidx.compose.ui.semantics.SemanticsActions
14+ import androidx.compose.ui.semantics.SemanticsConfiguration
1415import androidx.compose.ui.semantics.SemanticsProperties
1516import androidx.compose.ui.semantics.getOrNull
1617import androidx.compose.ui.text.TextLayoutResult
@@ -29,26 +30,51 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera
2930import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
3031import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
3132import java.lang.ref.WeakReference
33+ import java.lang.reflect.Method
3234
3335@TargetApi(26 )
3436internal object ComposeViewHierarchyNode {
3537
38+ private val getSemanticsConfigurationMethod: Method ? by lazy {
39+ try {
40+ return @lazy LayoutNode ::class .java.getDeclaredMethod(" getSemanticsConfiguration" ).apply {
41+ isAccessible = true
42+ }
43+ } catch (_: Throwable ) {
44+ // ignore, as this method may not be available
45+ }
46+ return @lazy null
47+ }
48+
49+ private var semanticsRetrievalErrorLogged: Boolean = false
50+
51+ @JvmStatic
52+ internal fun retrieveSemanticsConfiguration (node : LayoutNode ): SemanticsConfiguration ? {
53+ // Jetpack Compose 1.8 or newer provides SemanticsConfiguration via SemanticsInfo
54+ // See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
55+ // and https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
56+ getSemanticsConfigurationMethod?.let {
57+ return it.invoke(node) as SemanticsConfiguration ?
58+ }
59+
60+ // for backwards compatibility
61+ return node.collapsedSemantics
62+ }
63+
3664 /* *
3765 * Since Compose doesn't have a concept of a View class (they are all composable functions),
3866 * we need to map the semantics node to a corresponding old view system class.
3967 */
40- private fun LayoutNode. getProxyClassName (isImage : Boolean ): String {
68+ private fun getProxyClassName (isImage : Boolean , config : SemanticsConfiguration ? ): String {
4169 return when {
4270 isImage -> SentryReplayOptions .IMAGE_VIEW_CLASS_NAME
43- collapsedSemantics?.contains(SemanticsProperties .Text ) == true ||
44- collapsedSemantics?.contains(SemanticsActions .SetText ) == true ||
45- collapsedSemantics?.contains(SemanticsProperties .EditableText ) == true -> SentryReplayOptions .TEXT_VIEW_CLASS_NAME
71+ config != null && (config.contains(SemanticsProperties .Text ) || config.contains(SemanticsActions .SetText ) || config.contains(SemanticsProperties .EditableText )) -> SentryReplayOptions .TEXT_VIEW_CLASS_NAME
4672 else -> " android.view.View"
4773 }
4874 }
4975
50- private fun LayoutNode .shouldMask (isImage : Boolean , options : SentryOptions ): Boolean {
51- val sentryPrivacyModifier = collapsedSemantics ?.getOrNull(SentryReplayModifiers .SentryPrivacy )
76+ private fun SemanticsConfiguration? .shouldMask (isImage : Boolean , options : SentryOptions ): Boolean {
77+ val sentryPrivacyModifier = this ?.getOrNull(SentryReplayModifiers .SentryPrivacy )
5278 if (sentryPrivacyModifier == " unmask" ) {
5379 return false
5480 }
@@ -57,7 +83,7 @@ internal object ComposeViewHierarchyNode {
5783 return true
5884 }
5985
60- val className = getProxyClassName(isImage)
86+ val className = getProxyClassName(isImage, this )
6187 if (options.sessionReplay.unmaskViewClasses.contains(className)) {
6288 return false
6389 }
@@ -83,16 +109,53 @@ internal object ComposeViewHierarchyNode {
83109 _rootCoordinates = WeakReference (node.coordinates.findRootCoordinates())
84110 }
85111
86- val semantics = node.collapsedSemantics
87112 val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates ?.get())
113+ val semantics: SemanticsConfiguration ?
114+
115+ try {
116+ semantics = retrieveSemanticsConfiguration(node)
117+ } catch (t: Throwable ) {
118+ if (! semanticsRetrievalErrorLogged) {
119+ semanticsRetrievalErrorLogged = true
120+ options.logger.log(
121+ SentryLevel .ERROR ,
122+ t,
123+ """
124+ Error retrieving semantics information from Compose tree. Most likely you're using
125+ an unsupported version of androidx.compose.ui:ui. The supported
126+ version range is 1.5.0 - 1.8.0.
127+ If you're using a newer version, please open a github issue with the version
128+ you're using, so we can add support for it.
129+ """ .trimIndent()
130+ )
131+ }
132+
133+ // If we're unable to retrieve the semantics configuration
134+ // we should play safe and mask the whole node.
135+ return GenericViewHierarchyNode (
136+ x = visibleRect.left.toFloat(),
137+ y = visibleRect.top.toFloat(),
138+ width = node.width,
139+ height = node.height,
140+ elevation = (parent?.elevation ? : 0f ),
141+ distance = distance,
142+ parent = parent,
143+ shouldMask = true ,
144+ isImportantForContentCapture = false , /* will be set by children */
145+ isVisible = ! node.outerCoordinator.isTransparent() && visibleRect.height() > 0 && visibleRect.width() > 0 ,
146+ visibleRect = visibleRect
147+ )
148+ }
149+
88150 val isVisible = ! node.outerCoordinator.isTransparent() &&
89151 (semantics == null || ! semantics.contains(SemanticsProperties .InvisibleToUser )) &&
90152 visibleRect.height() > 0 && visibleRect.width() > 0
91153 val isEditable = semantics?.contains(SemanticsActions .SetText ) == true ||
92154 semantics?.contains(SemanticsProperties .EditableText ) == true
155+
93156 return when {
94157 semantics?.contains(SemanticsProperties .Text ) == true || isEditable -> {
95- val shouldMask = isVisible && node .shouldMask(isImage = false , options)
158+ val shouldMask = isVisible && semantics .shouldMask(isImage = false , options)
96159
97160 parent?.setImportantForCaptureToAncestors(true )
98161 // TODO: if we get reports that it's slow, we can drop this, and just mask
@@ -133,7 +196,7 @@ internal object ComposeViewHierarchyNode {
133196 else -> {
134197 val painter = node.findPainter()
135198 if (painter != null ) {
136- val shouldMask = isVisible && node .shouldMask(isImage = true , options)
199+ val shouldMask = isVisible && semantics .shouldMask(isImage = true , options)
137200
138201 parent?.setImportantForCaptureToAncestors(true )
139202 ImageViewHierarchyNode (
@@ -150,7 +213,7 @@ internal object ComposeViewHierarchyNode {
150213 visibleRect = visibleRect
151214 )
152215 } else {
153- val shouldMask = isVisible && node .shouldMask(isImage = false , options)
216+ val shouldMask = isVisible && semantics .shouldMask(isImage = false , options)
154217
155218 // TODO: this currently does not support embedded AndroidViews, we'd have to
156219 // TODO: traverse the ViewHierarchyNode here again. For now we can recommend
0 commit comments