Skip to content

Commit 9c1b406

Browse files
Support apps compiled against Jetpack Compose 1.10 (#5189)
* Compile against JPC 1.10 * Format code * Fix click/scroll target detection * Update Changelog * Move changes into replay module * Switch to reflection * Fix Changelog * Improve LayoutNode iteration * Fix tag propagation * Add tests * Address PR feedback * Format code * Update CHANGELOG for Jetpack Compose and SDK version * return first tag instead of last one * Fix exception propagation * Return first non-null tag * Allow nullable semantics in case there are really none --------- Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io>
1 parent 37ec571 commit 9c1b406

File tree

8 files changed

+891
-77
lines changed

8 files changed

+891
-77
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
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+
59
### Dependencies
610

711
- 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: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,32 +36,35 @@ 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
43+
.java
44+
.getDeclaredMethod("getCollapsedSemantics\$ui_release")
45+
.apply { isAccessible = true }
46+
} catch (_: Throwable) {
47+
// ignore, as this method may not be available
4348
}
44-
} catch (_: Throwable) {
45-
// ignore, as this method may not be available
49+
return@lazy null
4650
}
47-
return@lazy null
48-
}
4951

5052
private var semanticsRetrievalErrorLogged: Boolean = false
5153

5254
@JvmStatic
5355
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?
56+
return try {
57+
node.semanticsConfiguration
58+
} catch (t: Throwable) {
59+
// for backwards compatibility
60+
// Jetpack Compose 1.8 or older
61+
if (getCollapsedSemanticsMethod != null) {
62+
getCollapsedSemanticsMethod!!.invoke(node) as SemanticsConfiguration?
63+
} else {
64+
// re-throw t if there's no way to retrieve semantics
65+
throw t
66+
}
6167
}
62-
63-
// for backwards compatibility
64-
return node.collapsedSemantics
6568
}
6669

6770
/**
@@ -136,7 +139,7 @@ internal object ComposeViewHierarchyNode {
136139
"""
137140
Error retrieving semantics information from Compose tree. Most likely you're using
138141
an unsupported version of androidx.compose.ui:ui. The supported
139-
version range is 1.5.0 - 1.8.0.
142+
version range is 1.5.0 - 1.10.2.
140143
If you're using a newer version, please open a github issue with the version
141144
you're using, so we can add support for it.
142145
"""
@@ -157,15 +160,15 @@ internal object ComposeViewHierarchyNode {
157160
shouldMask = true,
158161
isImportantForContentCapture = false, // will be set by children
159162
isVisible =
160-
!node.outerCoordinator.isTransparent() &&
163+
!SentryLayoutNodeHelper.isTransparent(node) &&
161164
visibleRect.height() > 0 &&
162165
visibleRect.width() > 0,
163166
visibleRect = visibleRect,
164167
)
165168
}
166169

167170
val isVisible =
168-
!node.outerCoordinator.isTransparent() &&
171+
!SentryLayoutNodeHelper.isTransparent(node) &&
169172
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
170173
visibleRect.height() > 0 &&
171174
visibleRect.width() > 0
@@ -301,7 +304,7 @@ internal object ComposeViewHierarchyNode {
301304
options: SentryMaskingOptions,
302305
logger: ILogger,
303306
) {
304-
val children = this.children
307+
val children = SentryLayoutNodeHelper.getChildren(this)
305308
if (children.isEmpty()) {
306309
return
307310
}
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-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera
4444
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
4545
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
4646
import java.io.File
47-
import java.lang.reflect.InvocationTargetException
4847
import kotlin.test.Test
4948
import kotlin.test.assertEquals
5049
import kotlin.test.assertFalse
@@ -183,7 +182,7 @@ class ComposeMaskingOptionsTest {
183182
val node = mock<LayoutNode>()
184183
whenever(node.semanticsConfiguration).thenThrow(RuntimeException("Compose Runtime Error"))
185184

186-
assertThrows(InvocationTargetException::class.java) {
185+
assertThrows(RuntimeException::class.java) {
187186
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node)
188187
}
189188
}

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

Lines changed: 52 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,18 @@ 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+
val modifiers = node.getModifierInfo()
118+
for (index in modifiers.indices) {
119+
val modifierInfo = modifiers[index]
120+
val tag = composeHelper.extractTag(modifierInfo.modifier)
121+
if (tag != null) {
122+
return tag
123+
}
124+
}
125+
return null
126+
}
127+
128128
public companion object {
129129
private const val ORIGIN = "jetpack_compose"
130130
}
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 Mockito
7+
* 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)