Skip to content
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

- Support masking/unmasking and click/scroll detection for Jetpack Compose 1.10+ ([#5189](https://github.com/getsentry/sentry-java/pull/5189))

### Dependencies

- Bump Native SDK from v0.13.1 to v0.13.2 ([#5181](https://github.com/getsentry/sentry-java/pull/5181))
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ androidx-compose-material-icons-core = { module = "androidx.compose.material:mat
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version="1.7.8" }
androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidxCompose" }
# Note: don't change without testing forwards compatibility
androidx-compose-ui-replay = { module = "androidx.compose.ui:ui", version = "1.5.0" }
androidx-compose-ui-replay = { module = "androidx.compose.ui:ui", version = "1.10.2" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.1.3" }
androidx-core = { module = "androidx.core:core", version = "1.3.2" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,35 @@ import java.lang.reflect.Method
@SuppressLint("UseRequiresApi")
@TargetApi(26)
internal object ComposeViewHierarchyNode {
private val getSemanticsConfigurationMethod: Method? by lazy {
try {
return@lazy LayoutNode::class.java.getDeclaredMethod("getSemanticsConfiguration").apply {
isAccessible = true
private val getCollapsedSemanticsMethod: Method? by
lazy(LazyThreadSafetyMode.NONE) {
try {
return@lazy LayoutNode::class
.java
.getDeclaredMethod("getCollapsedSemantics\$ui_release")
.apply { isAccessible = true }
} catch (_: Throwable) {
// ignore, as this method may not be available
}
} catch (_: Throwable) {
// ignore, as this method may not be available
return@lazy null
}
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?
return try {
node.semanticsConfiguration
} catch (t: Throwable) {
// for backwards compatibility
// Jetpack Compose 1.8 or older
if (getCollapsedSemanticsMethod != null) {
getCollapsedSemanticsMethod!!.invoke(node) as SemanticsConfiguration?
} else {
// re-throw t if there's no way to retrieve semantics
throw t
}
}

// for backwards compatibility
return node.collapsedSemantics
}

/**
Expand Down Expand Up @@ -136,7 +139,7 @@ internal object ComposeViewHierarchyNode {
"""
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.
version range is 1.5.0 - 1.10.2.
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.
"""
Expand All @@ -157,15 +160,15 @@ internal object ComposeViewHierarchyNode {
shouldMask = true,
isImportantForContentCapture = false, // will be set by children
isVisible =
!node.outerCoordinator.isTransparent() &&
!SentryLayoutNodeHelper.isTransparent(node) &&
visibleRect.height() > 0 &&
visibleRect.width() > 0,
visibleRect = visibleRect,
)
}

val isVisible =
!node.outerCoordinator.isTransparent() &&
!SentryLayoutNodeHelper.isTransparent(node) &&
(semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) &&
visibleRect.height() > 0 &&
visibleRect.width() > 0
Expand Down Expand Up @@ -301,7 +304,7 @@ internal object ComposeViewHierarchyNode {
options: SentryMaskingOptions,
logger: ILogger,
) {
val children = this.children
val children = SentryLayoutNodeHelper.getChildren(this)
if (children.isEmpty()) {
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
@file:Suppress(
"INVISIBLE_MEMBER",
"INVISIBLE_REFERENCE",
"EXPOSED_PARAMETER_TYPE",
"EXPOSED_RETURN_TYPE",
"EXPOSED_FUNCTION_RETURN_TYPE",
)

package io.sentry.android.replay.viewhierarchy

import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.node.NodeCoordinator
import java.lang.reflect.Method

/**
* Provides access to internal LayoutNode members that are subject to Kotlin name-mangling.
*
* This class is not thread-safe, as Compose UI operations are expected to be performed on the main
* thread.
*
* Compiled against Compose >= 1.10 where the mangled names use the "ui" module suffix (e.g.
* getChildren$ui()). For apps still on Compose < 1.10 (where the suffix is "$ui_release"), the
* direct call will throw [NoSuchMethodError] and we fall back to reflection-based accessors that
* are resolved and cached on first use.
*/
internal object SentryLayoutNodeHelper {
private class Fallback(val getChildren: Method?, val getOuterCoordinator: Method?)

private var useFallback: Boolean? = null
private var fallback: Fallback? = null

private fun tryResolve(clazz: Class<*>, name: String): Method? {
return try {
clazz.getDeclaredMethod(name).apply { isAccessible = true }
} catch (_: NoSuchMethodException) {
null
}
}

@Suppress("UNCHECKED_CAST")
fun getChildren(node: LayoutNode): List<LayoutNode> {
when (useFallback) {
false -> return node.children
true -> {
return getFallback().getChildren!!.invoke(node) as List<LayoutNode>
}
null -> {
try {
return node.children.also { useFallback = false }
} catch (_: NoSuchMethodError) {
useFallback = true
return getFallback().getChildren!!.invoke(node) as List<LayoutNode>
}
}
}
}

fun isTransparent(node: LayoutNode): Boolean {
when (useFallback) {
false -> return node.outerCoordinator.isTransparent()
true -> {
val fb = getFallback()
val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator
return coordinator.isTransparent()
}
null -> {
try {
return node.outerCoordinator.isTransparent().also { useFallback = false }
} catch (_: NoSuchMethodError) {
useFallback = true
val fb = getFallback()
val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator
return coordinator.isTransparent()
}
}
}
}

private fun getFallback(): Fallback {
fallback?.let {
return it
}

val layoutNodeClass = LayoutNode::class.java
val getChildren = tryResolve(layoutNodeClass, "getChildren\$ui_release")
val getOuterCoordinator = tryResolve(layoutNodeClass, "getOuterCoordinator\$ui_release")

return Fallback(getChildren, getOuterCoordinator).also { fallback = it }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ 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.io.File
import java.lang.reflect.InvocationTargetException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
Expand Down Expand Up @@ -183,7 +182,7 @@ class ComposeMaskingOptionsTest {
val node = mock<LayoutNode>()
whenever(node.semanticsConfiguration).thenThrow(RuntimeException("Compose Runtime Error"))

assertThrows(InvocationTargetException::class.java) {
assertThrows(RuntimeException::class.java) {
ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,68 +44,56 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT

val rootLayoutNode = root.root

val queue: Queue<LayoutNode> = LinkedList()
queue.add(rootLayoutNode)
// Pair<Node, ParentTag>
val queue: Queue<Pair<LayoutNode, String?>> = LinkedList()
queue.add(Pair(rootLayoutNode, null))

// the final tag to return
// the final tag to return, only relevant for clicks
// as for scrolls, we return the first matching element
var targetTag: String? = null

// the last known tag when iterating the node tree
var lastKnownTag: String? = null
while (!queue.isEmpty()) {
val node = queue.poll() ?: continue
val (node, parentTag) = queue.poll() ?: continue
if (node.isPlaced && layoutNodeBoundsContain(rootLayoutNode, node, x, y)) {
var isClickable = false
var isScrollable = false

val modifiers = node.getModifierInfo()
for (index in modifiers.indices) {
val modifierInfo = modifiers[index]
val tag = composeHelper!!.extractTag(modifierInfo.modifier)
if (tag != null) {
lastKnownTag = tag
}

if (modifierInfo.modifier is SemanticsModifier) {
val semanticsModifierCore = modifierInfo.modifier as SemanticsModifier
val semanticsConfiguration = semanticsModifierCore.semanticsConfiguration

for (item in semanticsConfiguration) {
val key: String = item.key.name
if ("ScrollBy" == key) {
isScrollable = true
} else if ("OnClick" == key) {
isClickable = true
val tag = extractTag(composeHelper!!, node) ?: parentTag
if (tag != null) {
val modifiers = node.getModifierInfo()
for (index in modifiers.indices) {
val modifierInfo = modifiers[index]
if (modifierInfo.modifier is SemanticsModifier) {
val semanticsModifierCore = modifierInfo.modifier as SemanticsModifier
val semanticsConfiguration = semanticsModifierCore.semanticsConfiguration

for (item in semanticsConfiguration) {
val key: String = item.key.name
if (targetType == UiElement.Type.SCROLLABLE && "ScrollBy" == key) {
return UiElement(null, null, null, tag, ORIGIN)
} else if (targetType == UiElement.Type.CLICKABLE && "OnClick" == key) {
targetTag = tag
}
}
} else {
// Jetpack Compose 1.5+: uses Node modifiers elements for clicks/scrolls
val modifier = modifierInfo.modifier
val type = modifier.javaClass.name
if (
targetType == UiElement.Type.CLICKABLE &&
("androidx.compose.foundation.ClickableElement" == type ||
"androidx.compose.foundation.CombinedClickableElement" == type)
) {
targetTag = tag
} else if (
targetType == UiElement.Type.SCROLLABLE &&
("androidx.compose.foundation.ScrollingLayoutElement" == type ||
"androidx.compose.foundation.ScrollingContainerElement" == type)
) {
return UiElement(null, null, null, tag, ORIGIN)
}
}
} else {
val modifier = modifierInfo.modifier
// Newer Jetpack Compose 1.5 uses Node modifiers for clicks/scrolls
val type = modifier.javaClass.name
if (
"androidx.compose.foundation.ClickableElement" == type ||
"androidx.compose.foundation.CombinedClickableElement" == type
) {
isClickable = true
} else if (
"androidx.compose.foundation.ScrollingLayoutElement" == type ||
"androidx.compose.foundation.ScrollingContainerElement" == type
) {
isScrollable = true
}
}
}

if (isClickable && targetType == UiElement.Type.CLICKABLE) {
targetTag = lastKnownTag
}
if (isScrollable && targetType == UiElement.Type.SCROLLABLE) {
targetTag = lastKnownTag
// skip any children for scrollable targets
break
}
queue.addAll(node.zSortedChildren.asMutableList().map { Pair(it, tag) })
}
queue.addAll(node.zSortedChildren.asMutableList())
}

return if (targetTag == null) {
Expand All @@ -125,6 +113,18 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT
return bounds.contains(Offset(x, y))
}

private fun extractTag(composeHelper: SentryComposeHelper, node: LayoutNode): String? {
val modifiers = node.getModifierInfo()
for (index in modifiers.indices) {
val modifierInfo = modifiers[index]
val tag = composeHelper.extractTag(modifierInfo.modifier)
if (tag != null) {
return tag
}
}
return null
}

public companion object {
private const val ORIGIN = "jetpack_compose"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package androidx.compose.foundation

import androidx.compose.ui.Modifier

/**
* Stub classes used by [io.sentry.compose.gestures.ComposeGestureTargetLocatorTest] so that Mockito
* mocks of these classes return the correct [Class.getName] values at runtime.
*/
internal open class ClickableElement : Modifier.Element

internal open class CombinedClickableElement : Modifier.Element

internal open class ScrollingLayoutElement : Modifier.Element

internal open class ScrollingContainerElement : Modifier.Element
Loading
Loading