diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7c919c957..866a450519a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f81aea8a674..d659e43438c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 2e58418c3ac..f421ff9ad07 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -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 } /** @@ -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. """ @@ -157,7 +160,7 @@ 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, @@ -165,7 +168,7 @@ internal object ComposeViewHierarchyNode { } val isVisible = - !node.outerCoordinator.isTransparent() && + !SentryLayoutNodeHelper.isTransparent(node) && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && visibleRect.height() > 0 && visibleRect.width() > 0 @@ -301,7 +304,7 @@ internal object ComposeViewHierarchyNode { options: SentryMaskingOptions, logger: ILogger, ) { - val children = this.children + val children = SentryLayoutNodeHelper.getChildren(this) if (children.isEmpty()) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt new file mode 100644 index 00000000000..6cfe5ec6fc0 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt @@ -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 { + when (useFallback) { + false -> return node.children + true -> { + return getFallback().getChildren!!.invoke(node) as List + } + null -> { + try { + return node.children.also { useFallback = false } + } catch (_: NoSuchMethodError) { + useFallback = true + return getFallback().getChildren!!.invoke(node) as List + } + } + } + } + + 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 } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index 801c8b6e12b..e043b035668 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -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 @@ -183,7 +182,7 @@ class ComposeMaskingOptionsTest { val node = mock() whenever(node.semanticsConfiguration).thenThrow(RuntimeException("Compose Runtime Error")) - assertThrows(InvocationTargetException::class.java) { + assertThrows(RuntimeException::class.java) { ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node) } } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt index bf6a55110be..54deb774c53 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -44,68 +44,56 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT val rootLayoutNode = root.root - val queue: Queue = LinkedList() - queue.add(rootLayoutNode) + // Pair + val queue: Queue> = 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) { @@ -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" } diff --git a/sentry-compose/src/androidUnitTest/kotlin/androidx/compose/foundation/GestureModifierStubs.kt b/sentry-compose/src/androidUnitTest/kotlin/androidx/compose/foundation/GestureModifierStubs.kt new file mode 100644 index 00000000000..43b3e53bc67 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/androidx/compose/foundation/GestureModifierStubs.kt @@ -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 diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocatorTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocatorTest.kt new file mode 100644 index 00000000000..8efdf3ebe30 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocatorTest.kt @@ -0,0 +1,703 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package io.sentry.compose.gestures + +import androidx.compose.runtime.collection.mutableVectorOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.ModifierInfo +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.unit.IntSize +import io.sentry.NoOpLogger +import io.sentry.internal.gestures.UiElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ComposeGestureTargetLocatorTest { + + private val locator = ComposeGestureTargetLocator(NoOpLogger.getInstance()) + + /** + * Maps each child [LayoutCoordinates] to its bounding rect. Used by [FakeRootCoordinates] to + * return correct bounds when [LayoutCoordinates.localBoundingBoxOf] is called. + */ + private val coordsBounds = mutableMapOf() + + private lateinit var rootCoordinates: LayoutCoordinates + + @Before + fun setUp() { + coordsBounds.clear() + rootCoordinates = FakeRootCoordinates(1000, 1000, coordsBounds) + coordsBounds[rootCoordinates] = Rect(0f, 0f, 1000f, 1000f) + } + + @Test + fun `returns null for non-Owner root`() { + val result = locator.locate("not an owner", 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `returns null for null root`() { + val result = locator.locate(null, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `returns null when no clickable elements`() { + val root = mockLayoutNode(isPlaced = true, tag = "root", width = 100, height = 100) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `detects clickable via SemanticsModifier OnClick`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("btn", result!!.tag) + assertEquals("jetpack_compose", result.origin) + } + + @Test + fun `detects scrollable via SemanticsModifier ScrollBy`() { + val scrollableChild = + mockLayoutNode( + isPlaced = true, + tag = "list", + width = 50, + height = 50, + semanticsKeys = listOf("ScrollBy"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(scrollableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNotNull(result) + assertEquals("list", result!!.tag) + } + + @Test + fun `detects clickable via ClickableElement modifier`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + nodeModifierClassName = "androidx.compose.foundation.ClickableElement", + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("btn", result!!.tag) + } + + @Test + fun `detects clickable via CombinedClickableElement modifier`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + nodeModifierClassName = "androidx.compose.foundation.CombinedClickableElement", + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("btn", result!!.tag) + } + + @Test + fun `detects scrollable via ScrollingLayoutElement modifier`() { + val scrollableChild = + mockLayoutNode( + isPlaced = true, + tag = "scroll", + width = 50, + height = 50, + nodeModifierClassName = "androidx.compose.foundation.ScrollingLayoutElement", + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(scrollableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNotNull(result) + assertEquals("scroll", result!!.tag) + } + + @Test + fun `detects scrollable via ScrollingContainerElement modifier`() { + val scrollableChild = + mockLayoutNode( + isPlaced = true, + tag = "scroll", + width = 50, + height = 50, + nodeModifierClassName = "androidx.compose.foundation.ScrollingContainerElement", + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(scrollableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNotNull(result) + assertEquals("scroll", result!!.tag) + } + + @Test + fun `ignores clickable when looking for scrollable`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNull(result) + } + + @Test + fun `ignores scrollable when looking for clickable`() { + val scrollableChild = + mockLayoutNode( + isPlaced = true, + tag = "list", + width = 50, + height = 50, + semanticsKeys = listOf("ScrollBy"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(scrollableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `skips unplaced nodes`() { + val unplacedClickable = + mockLayoutNode( + isPlaced = false, + tag = "btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(unplacedClickable), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `skips nodes outside bounds`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + left = 200f, + top = 200f, + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 300, + height = 300, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + // click at (5, 5) is outside the child bounds (200-250, 200-250) + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `child inherits parent tag`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val taggedParent = + mockLayoutNode( + isPlaced = true, + tag = "parent_tag", + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 200, + height = 200, + children = listOf(taggedParent), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("parent_tag", result!!.tag) + } + + @Test + fun `returns deepest clickable for clicks`() { + val deepChild = + mockLayoutNode( + isPlaced = true, + tag = "deep_btn", + width = 20, + height = 20, + semanticsKeys = listOf("OnClick"), + ) + val parentClickable = + mockLayoutNode( + isPlaced = true, + tag = "parent_btn", + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + children = listOf(deepChild), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(parentClickable), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("deep_btn", result!!.tag) + } + + @Test + fun `returns first scrollable immediately`() { + val deepScrollable = + mockLayoutNode( + isPlaced = true, + tag = "deep_scroll", + width = 20, + height = 20, + semanticsKeys = listOf("ScrollBy"), + ) + val parentScrollable = + mockLayoutNode( + isPlaced = true, + tag = "parent_scroll", + width = 50, + height = 50, + semanticsKeys = listOf("ScrollBy"), + children = listOf(deepScrollable), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(parentScrollable), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.SCROLLABLE) + assertNotNull(result) + assertEquals("parent_scroll", result!!.tag) + } + + @Test + fun `returns null when node has no tag and no parent tag`() { + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 50, + height = 50, + semanticsKeys = listOf("OnClick"), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(clickableChild), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNull(result) + } + + @Test + fun `finds tagged clickable nested under untagged containers`() { + // Tree: root(no tag) -> container(no tag) -> container(no tag) -> clickable(tag="deep_btn") + val clickableChild = + mockLayoutNode( + isPlaced = true, + tag = "deep_btn", + width = 10, + height = 10, + semanticsKeys = listOf("OnClick"), + ) + val innerContainer = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 30, + height = 30, + children = listOf(clickableChild), + ) + val outerContainer = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 60, + height = 60, + children = listOf(innerContainer), + ) + val root = + mockLayoutNode( + isPlaced = true, + tag = null, + width = 100, + height = 100, + children = listOf(outerContainer), + ) + val owner = mockOwner(root) + + val result = locator.locate(owner, 5f, 5f, UiElement.Type.CLICKABLE) + assertNotNull(result) + assertEquals("deep_btn", result!!.tag) + } + + // -- helpers -- + + private fun mockOwner(rootNode: LayoutNode): Owner { + // Wire the root node's coordinates to the shared rootCoordinates + whenever(rootNode.coordinates).thenReturn(rootCoordinates) + val owner = mock() + whenever(owner.root).thenReturn(rootNode) + return owner + } + + private fun mockLayoutNode( + isPlaced: Boolean, + tag: String?, + width: Int, + height: Int, + children: List = emptyList(), + semanticsKeys: List = emptyList(), + nodeModifierClassName: String? = null, + left: Float = 0f, + top: Float = 0f, + ): LayoutNode { + val node = Mockito.mock(LayoutNode::class.java) + whenever(node.isPlaced).thenReturn(isPlaced) + + val modifierInfoList = mutableListOf() + + if (tag != null) { + val tagModifierInfo = mockTestTagModifierInfo(tag) + if (tagModifierInfo != null) { + modifierInfoList.add(tagModifierInfo) + } else { + modifierInfoList.add(mockSemanticsTagModifierInfo(tag)) + } + } + + if (semanticsKeys.isNotEmpty()) { + modifierInfoList.add(mockSemanticsKeysModifierInfo(semanticsKeys)) + } + + if (nodeModifierClassName != null) { + modifierInfoList.add(mockNodeModifierInfo(nodeModifierClassName)) + } + + whenever(node.getModifierInfo()).thenReturn(modifierInfoList) + whenever(node.zSortedChildren) + .thenReturn(mutableVectorOf().apply { addAll(children) }) + + val coordinates = FakeChildCoordinates(left, top, width.toFloat(), height.toFloat()) + coordsBounds[coordinates] = Rect(left, top, left + width, top + height) + whenever(node.coordinates).thenReturn(coordinates) + + return node + } + + /** + * Fake root [LayoutCoordinates] that avoids Mockito issues with inline classes like [Offset]. + * Implements [localBoundingBoxOf] by looking up child bounds in [coordsBounds], and + * [localToWindow] as an identity transform. + */ + private class FakeRootCoordinates( + private val width: Int, + private val height: Int, + private val boundsMap: Map, + ) : LayoutCoordinates { + override val size: IntSize + get() = IntSize(width, height) + + override val isAttached: Boolean + get() = true + + override val parentLayoutCoordinates: LayoutCoordinates? + get() = null + + override val parentCoordinates: LayoutCoordinates? + get() = null + + override val providedAlignmentLines: Set + get() = emptySet() + + override fun get(alignmentLine: AlignmentLine): Int = AlignmentLine.Unspecified + + override fun windowToLocal(relativeToWindow: Offset): Offset = relativeToWindow + + override fun localToWindow(relativeToLocal: Offset): Offset = relativeToLocal + + override fun localToRoot(relativeToLocal: Offset): Offset = relativeToLocal + + override fun localPositionOf( + sourceCoordinates: LayoutCoordinates, + relativeToSource: Offset, + ): Offset = relativeToSource + + override fun localBoundingBoxOf( + sourceCoordinates: LayoutCoordinates, + clipBounds: Boolean, + ): Rect = boundsMap[sourceCoordinates] ?: Rect.Zero + + @Deprecated("Deprecated in interface") + override fun localToScreen(relativeToLocal: Offset): Offset = relativeToLocal + + @Deprecated("Deprecated in interface") + override fun screenToLocal(relativeToScreen: Offset): Offset = relativeToScreen + } + + /** + * Minimal fake [LayoutCoordinates] for child nodes. The actual bounds are resolved via + * [FakeRootCoordinates.localBoundingBoxOf], so this only needs identity implementations. + */ + private class FakeChildCoordinates( + private val left: Float, + private val top: Float, + private val width: Float, + private val height: Float, + ) : LayoutCoordinates { + override val size: IntSize + get() = IntSize(width.toInt(), height.toInt()) + + override val isAttached: Boolean + get() = true + + override val parentLayoutCoordinates: LayoutCoordinates? + get() = null + + override val parentCoordinates: LayoutCoordinates? + get() = null + + override val providedAlignmentLines: Set + get() = emptySet() + + override fun get(alignmentLine: AlignmentLine): Int = AlignmentLine.Unspecified + + override fun windowToLocal(relativeToWindow: Offset): Offset = relativeToWindow + + override fun localToWindow(relativeToLocal: Offset): Offset = relativeToLocal + + override fun localToRoot(relativeToLocal: Offset): Offset = relativeToLocal + + override fun localPositionOf( + sourceCoordinates: LayoutCoordinates, + relativeToSource: Offset, + ): Offset = relativeToSource + + override fun localBoundingBoxOf( + sourceCoordinates: LayoutCoordinates, + clipBounds: Boolean, + ): Rect = Rect(left, top, left + width, top + height) + + @Deprecated("Deprecated in interface") + override fun localToScreen(relativeToLocal: Offset): Offset = relativeToLocal + + @Deprecated("Deprecated in interface") + override fun screenToLocal(relativeToScreen: Offset): Offset = relativeToScreen + } + + companion object { + private fun mockTestTagModifierInfo(tag: String): ModifierInfo? { + return try { + val clazz = Class.forName("androidx.compose.ui.platform.TestTagElement") + val constructor = clazz.declaredConstructors.firstOrNull() ?: return null + constructor.isAccessible = true + val instance = constructor.newInstance(tag) as Modifier + val modifierInfo = Mockito.mock(ModifierInfo::class.java) + whenever(modifierInfo.modifier).thenReturn(instance) + modifierInfo + } catch (_: Throwable) { + null + } + } + + private fun mockSemanticsTagModifierInfo(tag: String): ModifierInfo { + val modifierInfo = Mockito.mock(ModifierInfo::class.java) + whenever(modifierInfo.modifier) + .thenReturn( + object : SemanticsModifier { + override val semanticsConfiguration: SemanticsConfiguration + get() { + val config = SemanticsConfiguration() + config.set(SemanticsPropertyKey("TestTag") { s: String?, _: String? -> s }, tag) + return config + } + } + ) + return modifierInfo + } + + private fun mockSemanticsKeysModifierInfo(keys: List): ModifierInfo { + val modifierInfo = Mockito.mock(ModifierInfo::class.java) + whenever(modifierInfo.modifier) + .thenReturn( + object : SemanticsModifier { + override val semanticsConfiguration: SemanticsConfiguration + get() { + val config = SemanticsConfiguration() + for (key in keys) { + config.set(SemanticsPropertyKey(key) { _, _ -> }, Unit) + } + return config + } + } + ) + return modifierInfo + } + + private fun mockNodeModifierInfo(className: String): ModifierInfo { + val modifierWithClassName = + Mockito.mock( + try { + Class.forName(className) + } catch (_: ClassNotFoundException) { + Modifier::class.java + } + ) as Modifier + val modifierInfo = Mockito.mock(ModifierInfo::class.java) + whenever(modifierInfo.modifier).thenReturn(modifierWithClassName) + return modifierInfo + } + } +}