Skip to content

Commit e0f5f6c

Browse files
authored
Merge f7cca22 into 5914404
2 parents 5914404 + f7cca22 commit e0f5f6c

7 files changed

Lines changed: 726 additions & 75 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
### Fixes
6+
7+
- Support masking/unmasking and click/scroll detection for Jetpack Compose 1.10+ ([#5189](https://github.com/getsentry/sentry-java/pull/5189))
8+
### Dependencies
9+
510
### Dependencies
611

712
- Bump Native SDK from v0.13.1 to v0.13.2 ([#5181](https://github.com/getsentry/sentry-java/pull/5181))

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ androidx-compose-material-icons-core = { module = "androidx.compose.material:mat
8585
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version="1.7.8" }
8686
androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" }
8787
# Note: don't change without testing forwards compatibility
88-
androidx-compose-ui-replay = { module = "androidx.compose.ui:ui", version = "1.5.0" }
88+
androidx-compose-ui-replay = { module = "androidx.compose.ui:ui", version = "1.10.2" }
8989
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.1.3" }
9090
androidx-core = { module = "androidx.core:core", version = "1.3.2" }
9191
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" }

sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,32 +36,31 @@ import java.lang.reflect.Method
3636
@SuppressLint("UseRequiresApi")
3737
@TargetApi(26)
3838
internal object ComposeViewHierarchyNode {
39-
private val getSemanticsConfigurationMethod: Method? by lazy {
40-
try {
41-
return@lazy LayoutNode::class.java.getDeclaredMethod("getSemanticsConfiguration").apply {
42-
isAccessible = true
39+
private val getCollapsedSemanticsMethod: Method? by
40+
lazy(LazyThreadSafetyMode.NONE) {
41+
try {
42+
return@lazy LayoutNode::class.java.getDeclaredMethod("getCollapsedSemantics\$ui_release").apply {
43+
isAccessible = true
44+
}
45+
} catch (_: Throwable) {
46+
// ignore, as this method may not be available
4347
}
44-
} catch (_: Throwable) {
45-
// ignore, as this method may not be available
48+
return@lazy null
4649
}
47-
return@lazy null
48-
}
4950

5051
private var semanticsRetrievalErrorLogged: Boolean = false
5152

5253
@JvmStatic
5354
internal fun retrieveSemanticsConfiguration(node: LayoutNode): SemanticsConfiguration? {
54-
// Jetpack Compose 1.8 or newer provides SemanticsConfiguration via SemanticsInfo
55-
// See
56-
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
57-
// and
58-
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
59-
getSemanticsConfigurationMethod?.let {
60-
return it.invoke(node) as SemanticsConfiguration?
55+
try {
56+
return node.semanticsConfiguration
57+
} catch (_: Throwable) {
58+
// for backwards compatibility
59+
// Jetpack Compose 1.8 or older
60+
return getCollapsedSemanticsMethod?.let {
61+
return it.invoke(node) as SemanticsConfiguration?
62+
}
6163
}
62-
63-
// for backwards compatibility
64-
return node.collapsedSemantics
6564
}
6665

6766
/**
@@ -136,7 +135,7 @@ internal object ComposeViewHierarchyNode {
136135
"""
137136
Error retrieving semantics information from Compose tree. Most likely you're using
138137
an unsupported version of androidx.compose.ui:ui. The supported
139-
version range is 1.5.0 - 1.8.0.
138+
version range is 1.5.0 - 1.10.2.
140139
If you're using a newer version, please open a github issue with the version
141140
you're using, so we can add support for it.
142141
"""
@@ -157,15 +156,15 @@ internal object ComposeViewHierarchyNode {
157156
shouldMask = true,
158157
isImportantForContentCapture = false, // will be set by children
159158
isVisible =
160-
!node.outerCoordinator.isTransparent() &&
159+
!SentryLayoutNodeHelper.isTransparent(node) &&
161160
visibleRect.height() > 0 &&
162161
visibleRect.width() > 0,
163162
visibleRect = visibleRect,
164163
)
165164
}
166165

167166
val isVisible =
168-
!node.outerCoordinator.isTransparent() &&
167+
!SentryLayoutNodeHelper.isTransparent(node) &&
169168
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
170169
visibleRect.height() > 0 &&
171170
visibleRect.width() > 0
@@ -301,7 +300,7 @@ internal object ComposeViewHierarchyNode {
301300
options: SentryMaskingOptions,
302301
logger: ILogger,
303302
) {
304-
val children = this.children
303+
val children = SentryLayoutNodeHelper.getChildren(this)
305304
if (children.isEmpty()) {
306305
return
307306
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
@file:Suppress(
2+
"INVISIBLE_MEMBER",
3+
"INVISIBLE_REFERENCE",
4+
"EXPOSED_PARAMETER_TYPE",
5+
"EXPOSED_RETURN_TYPE",
6+
"EXPOSED_FUNCTION_RETURN_TYPE",
7+
)
8+
9+
package io.sentry.android.replay.viewhierarchy
10+
11+
import androidx.compose.ui.node.LayoutNode
12+
import androidx.compose.ui.node.NodeCoordinator
13+
import java.lang.reflect.Method
14+
15+
/**
16+
* Provides access to internal LayoutNode members that are subject to Kotlin name-mangling.
17+
*
18+
* This class is not thread-safe, as Compose UI operations are expected to be performed on the main
19+
* thread.
20+
*
21+
* Compiled against Compose >= 1.10 where the mangled names use the "ui" module suffix (e.g.
22+
* getChildren$ui()). For apps still on Compose < 1.10 (where the suffix is "$ui_release"), the
23+
* direct call will throw [NoSuchMethodError] and we fall back to reflection-based accessors that
24+
* are resolved and cached on first use.
25+
*/
26+
internal object SentryLayoutNodeHelper {
27+
private class Fallback(val getChildren: Method?, val getOuterCoordinator: Method?)
28+
29+
private var useFallback: Boolean? = null
30+
private var fallback: Fallback? = null
31+
32+
private fun tryResolve(clazz: Class<*>, name: String): Method? {
33+
return try {
34+
clazz.getDeclaredMethod(name).apply { isAccessible = true }
35+
} catch (_: NoSuchMethodException) {
36+
null
37+
}
38+
}
39+
40+
@Suppress("UNCHECKED_CAST")
41+
fun getChildren(node: LayoutNode): List<LayoutNode> {
42+
when (useFallback) {
43+
false -> return node.children
44+
true -> {
45+
return getFallback().getChildren!!.invoke(node) as List<LayoutNode>
46+
}
47+
null -> {
48+
try {
49+
return node.children.also { useFallback = false }
50+
} catch (_: NoSuchMethodError) {
51+
useFallback = true
52+
return getFallback().getChildren!!.invoke(node) as List<LayoutNode>
53+
}
54+
}
55+
}
56+
}
57+
58+
fun isTransparent(node: LayoutNode): Boolean {
59+
when (useFallback) {
60+
false -> return node.outerCoordinator.isTransparent()
61+
true -> {
62+
val fb = getFallback()
63+
val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator
64+
return coordinator.isTransparent()
65+
}
66+
null -> {
67+
try {
68+
return node.outerCoordinator.isTransparent().also { useFallback = false }
69+
} catch (_: NoSuchMethodError) {
70+
useFallback = true
71+
val fb = getFallback()
72+
val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator
73+
return coordinator.isTransparent()
74+
}
75+
}
76+
}
77+
}
78+
79+
private fun getFallback(): Fallback {
80+
fallback?.let {
81+
return it
82+
}
83+
84+
val layoutNodeClass = LayoutNode::class.java
85+
val getChildren = tryResolve(layoutNodeClass, "getChildren\$ui_release")
86+
val getOuterCoordinator = tryResolve(layoutNodeClass, "getOuterCoordinator\$ui_release")
87+
88+
return Fallback(getChildren, getOuterCoordinator).also { fallback = it }
89+
}
90+
}

sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt

Lines changed: 53 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -44,68 +44,56 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT
4444

4545
val rootLayoutNode = root.root
4646

47-
val queue: Queue<LayoutNode> = LinkedList()
48-
queue.add(rootLayoutNode)
47+
// Pair<Node, ParentTag>
48+
val queue: Queue<Pair<LayoutNode, String?>> = LinkedList()
49+
queue.add(Pair(rootLayoutNode, null))
4950

50-
// the final tag to return
51+
// the final tag to return, only relevant for clicks
52+
// as for scrolls, we return the first matching element
5153
var targetTag: String? = null
5254

53-
// the last known tag when iterating the node tree
54-
var lastKnownTag: String? = null
5555
while (!queue.isEmpty()) {
56-
val node = queue.poll() ?: continue
56+
val (node, parentTag) = queue.poll() ?: continue
5757
if (node.isPlaced && layoutNodeBoundsContain(rootLayoutNode, node, x, y)) {
58-
var isClickable = false
59-
var isScrollable = false
60-
61-
val modifiers = node.getModifierInfo()
62-
for (index in modifiers.indices) {
63-
val modifierInfo = modifiers[index]
64-
val tag = composeHelper!!.extractTag(modifierInfo.modifier)
65-
if (tag != null) {
66-
lastKnownTag = tag
67-
}
68-
69-
if (modifierInfo.modifier is SemanticsModifier) {
70-
val semanticsModifierCore = modifierInfo.modifier as SemanticsModifier
71-
val semanticsConfiguration = semanticsModifierCore.semanticsConfiguration
72-
73-
for (item in semanticsConfiguration) {
74-
val key: String = item.key.name
75-
if ("ScrollBy" == key) {
76-
isScrollable = true
77-
} else if ("OnClick" == key) {
78-
isClickable = true
58+
val tag = extractTag(composeHelper!!, node) ?: parentTag
59+
if (tag != null) {
60+
val modifiers = node.getModifierInfo()
61+
for (index in modifiers.indices) {
62+
val modifierInfo = modifiers[index]
63+
if (modifierInfo.modifier is SemanticsModifier) {
64+
val semanticsModifierCore = modifierInfo.modifier as SemanticsModifier
65+
val semanticsConfiguration = semanticsModifierCore.semanticsConfiguration
66+
67+
for (item in semanticsConfiguration) {
68+
val key: String = item.key.name
69+
if (targetType == UiElement.Type.SCROLLABLE && "ScrollBy" == key) {
70+
return UiElement(null, null, null, tag, ORIGIN)
71+
} else if (targetType == UiElement.Type.CLICKABLE && "OnClick" == key) {
72+
targetTag = tag
73+
}
74+
}
75+
} else {
76+
// Jetpack Compose 1.5+: uses Node modifiers elements for clicks/scrolls
77+
val modifier = modifierInfo.modifier
78+
val type = modifier.javaClass.name
79+
if (
80+
targetType == UiElement.Type.CLICKABLE &&
81+
("androidx.compose.foundation.ClickableElement" == type ||
82+
"androidx.compose.foundation.CombinedClickableElement" == type)
83+
) {
84+
targetTag = tag
85+
} else if (
86+
targetType == UiElement.Type.SCROLLABLE &&
87+
("androidx.compose.foundation.ScrollingLayoutElement" == type ||
88+
"androidx.compose.foundation.ScrollingContainerElement" == type)
89+
) {
90+
return UiElement(null, null, null, tag, ORIGIN)
7991
}
80-
}
81-
} else {
82-
val modifier = modifierInfo.modifier
83-
// Newer Jetpack Compose 1.5 uses Node modifiers for clicks/scrolls
84-
val type = modifier.javaClass.name
85-
if (
86-
"androidx.compose.foundation.ClickableElement" == type ||
87-
"androidx.compose.foundation.CombinedClickableElement" == type
88-
) {
89-
isClickable = true
90-
} else if (
91-
"androidx.compose.foundation.ScrollingLayoutElement" == type ||
92-
"androidx.compose.foundation.ScrollingContainerElement" == type
93-
) {
94-
isScrollable = true
9592
}
9693
}
9794
}
98-
99-
if (isClickable && targetType == UiElement.Type.CLICKABLE) {
100-
targetTag = lastKnownTag
101-
}
102-
if (isScrollable && targetType == UiElement.Type.SCROLLABLE) {
103-
targetTag = lastKnownTag
104-
// skip any children for scrollable targets
105-
break
106-
}
95+
queue.addAll(node.zSortedChildren.asMutableList().map { Pair(it, tag) })
10796
}
108-
queue.addAll(node.zSortedChildren.asMutableList())
10997
}
11098

11199
return if (targetTag == null) {
@@ -125,6 +113,19 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT
125113
return bounds.contains(Offset(x, y))
126114
}
127115

116+
private fun extractTag(composeHelper: SentryComposeHelper, node: LayoutNode): String? {
117+
var lastKnownTag: String? = null
118+
val modifiers = node.getModifierInfo()
119+
for (index in modifiers.indices) {
120+
val modifierInfo = modifiers[index]
121+
val tag = composeHelper.extractTag(modifierInfo.modifier)
122+
if (tag != null) {
123+
lastKnownTag = tag
124+
}
125+
}
126+
return lastKnownTag
127+
}
128+
128129
public companion object {
129130
private const val ORIGIN = "jetpack_compose"
130131
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package androidx.compose.foundation
2+
3+
import androidx.compose.ui.Modifier
4+
5+
/**
6+
* Stub classes used by [io.sentry.compose.gestures.ComposeGestureTargetLocatorTest] so that
7+
* Mockito mocks of these classes return the correct [Class.getName] values at runtime.
8+
*/
9+
internal open class ClickableElement : Modifier.Element
10+
11+
internal open class CombinedClickableElement : Modifier.Element
12+
13+
internal open class ScrollingLayoutElement : Modifier.Element
14+
15+
internal open class ScrollingContainerElement : Modifier.Element

0 commit comments

Comments
 (0)