diff --git a/demo-app/build.gradle.kts b/demo-app/build.gradle.kts index e89984a6e..ce4879c72 100644 --- a/demo-app/build.gradle.kts +++ b/demo-app/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { coreLibraryDesugaring(libs.desugarJdkLibs) + implementation("io.opentelemetry.android:instrumentation-compose") implementation("io.opentelemetry.android:android-agent") //parent dir implementation("io.opentelemetry.android:instrumentation-sessions") implementation(libs.androidx.core.ktx) diff --git a/demo-app/settings.gradle.kts b/demo-app/settings.gradle.kts index ff3da2563..933d5a96a 100644 --- a/demo-app/settings.gradle.kts +++ b/demo-app/settings.gradle.kts @@ -22,6 +22,8 @@ dependencyResolutionManagement { includeBuild("..") { dependencySubstitution { + substitute(module("io.opentelemetry.android:instrumentation-compose")) + .using(project(":instrumentation:compose")) substitute(module("io.opentelemetry.android:android-agent")) .using(project(":android-agent")) substitute(module("io.opentelemetry.android:instrumentation-sessions")) diff --git a/demo-app/src/main/java/io/opentelemetry/android/demo/MainOtelButton.kt b/demo-app/src/main/java/io/opentelemetry/android/demo/MainOtelButton.kt index bafea6f4d..41add7e9d 100644 --- a/demo-app/src/main/java/io/opentelemetry/android/demo/MainOtelButton.kt +++ b/demo-app/src/main/java/io/opentelemetry/android/demo/MainOtelButton.kt @@ -17,6 +17,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import io.opentelemetry.api.metrics.LongCounter import io.opentelemetry.api.trace.SpanKind @@ -28,7 +30,9 @@ fun MainOtelButton(icon: Painter, Spacer(modifier = Modifier.height(5.dp)) Button( onClick = { generateClickEvent(clickCounter) }, - modifier = Modifier.padding(20.dp), + modifier = Modifier.padding(20.dp).semantics{ + onClick("MainOtelButton") { true } + }, colors = ButtonDefaults.buttonColors(containerColor = Color.Black), content = { Image( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab5510490..f280c376a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ androidPlugin = "8.10.1" junitKtx = "1.2.1" autoService = "1.1.1" androidx-navigation = "2.7.7" +compose = "1.5.4" [libraries] opentelemetry-platform-alpha = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha", version.ref = "opentelemetry-instrumentation-alpha" } @@ -43,6 +44,7 @@ opentelemetry-exporter-otlp = { module = "io.opentelemetry:opentelemetry-exporte volley = "com.android.volley:volley:1.2.1" auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" } auto-service-processor = { module = "com.google.auto.service:auto-service", version.ref = "autoService" } +compose = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" } #Test tools opentelemetry-sdk-testing = { module = "io.opentelemetry:opentelemetry-sdk-testing" } diff --git a/instrumentation/compose/click/README.md b/instrumentation/compose/click/README.md new file mode 100644 index 000000000..b5cb74324 --- /dev/null +++ b/instrumentation/compose/click/README.md @@ -0,0 +1,36 @@ + +# Compose Instrumentation + +Status: development + +## Compose version +`1.3.0` to `1.5.4` + +This instrumentation has the ability to generate events when the user +performs click actions. A click is not differentiated from touch or other +input pointer events. + +When an Activity becomes active, the instrumentation begins tracking +its window by registering a callback that receives events. + +This instrumentation is not currently enabled by default. + +## Telemetry + +Data produced by this instrumentation will have an instrumentation scope +name of `io.opentelemetry.android.instrumentation.compose.click`. +This instrumentation produces the following telemetry: + +### Clicks + +* Type: Event +* Name: `app.screen.click` +* Description: This event is emitted when the user taps or clicks on the screen. +* See the [semantic convention definition](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/app/app.md#event-appscreenclick) + for more details. + +* Type: Event +* Name: `event.app.widget.click` +* Description: This event is emitted when the user taps on a composable that is clickable. +* See the [semantic convention definition](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/app/app.md#event-appwidgetclick) + for more details. diff --git a/instrumentation/compose/click/build.gradle.kts b/instrumentation/compose/click/build.gradle.kts new file mode 100644 index 000000000..0be5e9fb6 --- /dev/null +++ b/instrumentation/compose/click/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("otel.android-library-conventions") + id("otel.publish-conventions") +} + +description = "OpenTelemetry Android compose click instrumentation" + +android { + namespace = "io.opentelemetry.android.instrumentation.compose.click" + + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + } +} + +dependencies { + api(project(":services")) + api(libs.opentelemetry.api) + api(platform(libs.opentelemetry.platform.alpha)) + api(project(":instrumentation:android-instrumentation")) + + compileOnly(libs.compose) + implementation(libs.opentelemetry.api.incubator) + implementation(libs.opentelemetry.instrumentation.apiSemconv) + implementation(libs.opentelemetry.semconv.incubating) + + testImplementation(project(":test-common")) + testImplementation(project(":session")) + + testImplementation(libs.compose) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) +} diff --git a/instrumentation/compose/click/consumer-rules.pro b/instrumentation/compose/click/consumer-rules.pro new file mode 100644 index 000000000..99b751087 --- /dev/null +++ b/instrumentation/compose/click/consumer-rules.pro @@ -0,0 +1,8 @@ +# Keep the Compose internals class name. We need this for compose click capture. +-keep class androidx.compose.foundation.ClickableElement { + ; +} +-keep class androidx.compose.foundation.CombinedClickableElement { + ; +} +-keepnames class androidx.compose.foundation.selection.ToggleableElement \ No newline at end of file diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickActivityCallback.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickActivityCallback.kt new file mode 100644 index 000000000..72aaf8c1d --- /dev/null +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickActivityCallback.kt @@ -0,0 +1,21 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.compose.click + +import android.app.Activity +import io.opentelemetry.android.internal.services.visiblescreen.activities.DefaultingActivityLifecycleCallbacks + +internal class ComposeClickActivityCallback( + private val composeClickEventGenerator: ComposeClickEventGenerator, +) : DefaultingActivityLifecycleCallbacks { + override fun onActivityResumed(activity: Activity) { + composeClickEventGenerator.startTracking(activity.window) + } + + override fun onActivityPaused(activity: Activity) { + composeClickEventGenerator.stopTracking() + } +} diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGenerator.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGenerator.kt new file mode 100644 index 000000000..7be3862aa --- /dev/null +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGenerator.kt @@ -0,0 +1,77 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package io.opentelemetry.instrumentation.compose.click + +import android.view.MotionEvent +import android.view.Window +import androidx.compose.ui.node.LayoutNode +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.incubator.logs.ExtendedLogRecordBuilder +import io.opentelemetry.api.incubator.logs.ExtendedLogger +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_COORDINATE_X +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_COORDINATE_Y +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_WIDGET_ID +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_WIDGET_NAME +import java.lang.ref.WeakReference + +internal class ComposeClickEventGenerator( + private val eventLogger: ExtendedLogger, + private val composeLayoutNodeUtil: ComposeLayoutNodeUtil = ComposeLayoutNodeUtil(), + private val composeTapTargetDetector: ComposeTapTargetDetector = ComposeTapTargetDetector(composeLayoutNodeUtil), +) { + private var windowRef: WeakReference? = null + + fun startTracking(window: Window) { + windowRef = WeakReference(window) + val currentCallback = window.callback + window.callback = WindowCallbackWrapper(currentCallback, this) + } + + fun generateClick(motionEvent: MotionEvent?) { + windowRef?.get()?.let { window -> + if (motionEvent != null && motionEvent.actionMasked == MotionEvent.ACTION_UP) { + createEvent(APP_SCREEN_CLICK_EVENT_NAME) + .setAttribute(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()) + .setAttribute(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()) + .emit() + + composeTapTargetDetector.findTapTarget(window.decorView, motionEvent.x, motionEvent.y)?.let { layoutNode -> + createEvent(VIEW_CLICK_EVENT_NAME) + .setAllAttributes(createNodeAttributes(layoutNode)) + .emit() + } + } + } + } + + fun stopTracking() { + windowRef?.get()?.run { + if (callback is WindowCallbackWrapper) { + callback = (callback as WindowCallbackWrapper).unwrap() + } + } + windowRef = null + } + + private fun createEvent(name: String): ExtendedLogRecordBuilder = + eventLogger + .logRecordBuilder() + .setEventName(name) + + private fun createNodeAttributes(node: LayoutNode): Attributes { + val builder = Attributes.builder() + builder.put(APP_WIDGET_NAME, composeTapTargetDetector.nodeToName(node)) + builder.put(APP_WIDGET_ID, node.semanticsId.toString()) + + composeLayoutNodeUtil.getLayoutNodePositionInWindow(node)?.let { + builder.put(APP_SCREEN_COORDINATE_X, it.x.toLong()) + builder.put(APP_SCREEN_COORDINATE_Y, it.y.toLong()) + } + return builder.build() + } +} diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickInstrumentation.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickInstrumentation.kt new file mode 100644 index 000000000..7c9fd96d2 --- /dev/null +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickInstrumentation.kt @@ -0,0 +1,29 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.compose.click + +import com.google.auto.service.AutoService +import io.opentelemetry.android.instrumentation.AndroidInstrumentation +import io.opentelemetry.android.instrumentation.InstallationContext +import io.opentelemetry.api.incubator.logs.ExtendedLogger + +@AutoService(AndroidInstrumentation::class) +class ComposeClickInstrumentation : AndroidInstrumentation { + override val name: String = "compose.click" + + override fun install(ctx: InstallationContext) { + ctx.application.registerActivityLifecycleCallbacks( + ComposeClickActivityCallback( + ComposeClickEventGenerator( + ctx.openTelemetry + .logsBridge + .loggerBuilder("io.opentelemetry.android.instrumentation.compose.click") + .build() as ExtendedLogger, + ), + ), + ) + } +} diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeLayoutNodeUtil.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeLayoutNodeUtil.kt new file mode 100644 index 000000000..d35c6c96e --- /dev/null +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeLayoutNodeUtil.kt @@ -0,0 +1,35 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package io.opentelemetry.instrumentation.compose.click + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.node.LayoutNode + +const val APP_SCREEN_CLICK_EVENT_NAME = "app.screen.click" +const val VIEW_CLICK_EVENT_NAME = "event.app.widget.click" + +internal class ComposeLayoutNodeUtil { + internal fun getLayoutNodeBoundsInWindow(node: LayoutNode): Rect? = + try { + node.layoutDelegate.outerCoordinator.coordinates + .boundsInWindow() + } catch (_: Exception) { + null + } + + internal fun getLayoutNodePositionInWindow(node: LayoutNode): Offset? = + try { + node.layoutDelegate.outerCoordinator.coordinates + .positionInWindow() + } catch (_: Exception) { + null + } +} diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetector.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetector.kt new file mode 100644 index 000000000..dfa3ad705 --- /dev/null +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetector.kt @@ -0,0 +1,160 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package io.opentelemetry.instrumentation.compose.click + +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import java.util.LinkedList + +internal class ComposeTapTargetDetector( + private val composeLayoutNodeUtil: ComposeLayoutNodeUtil, +) { + fun nodeToName(node: LayoutNode): String = + try { + getNodeName(node) ?: node.semanticsId.toString() + } catch (_: Throwable) { + node.semanticsId.toString() + } + + fun findTapTarget( + decorView: View, + x: Float, + y: Float, + ): LayoutNode? { + val queue = LinkedList() + queue.addFirst(decorView) + + var target: LayoutNode? = null + while (queue.isNotEmpty()) { + val view = queue.removeFirst() + if (view is ViewGroup) { + for (index in 0 until view.childCount) { + queue.add(view.getChildAt(index)) + } + (view as? Owner)?.let { + try { + target = + findTapTarget( + view as Owner, + x, + y, + ) + } catch (_: Throwable) { + // We rely on visibility suppression to access internal fields and + // classes any runtime exception must be caught here. + } + } + } + } + return target + } + + private fun findTapTarget( + owner: Owner, + x: Float, + y: Float, + ): LayoutNode? { + val queue = LinkedList() + queue.addFirst(owner.root) + var target: LayoutNode? = null + + while (queue.isNotEmpty()) { + val node = queue.removeFirst() + if (node.isPlaced && hitTest(node, x, y)) { + target = node + } + + queue.addAll(node.zSortedChildren.asMutableList()) + } + return target + } + + private fun isValidClickTarget(node: LayoutNode): Boolean { + for (info in node.getModifierInfo()) { + val modifier = info.modifier + if (modifier is SemanticsModifier) { + with(modifier.semanticsConfiguration) { + if (contains(SemanticsActions.OnClick)) { + return true + } + } + } else { + val className = modifier::class.qualifiedName + if ( + className == CLASS_NAME_CLICKABLE_ELEMENT || + className == CLASS_NAME_COMBINED_CLICKABLE_ELEMENT || + className == CLASS_NAME_TOGGLEABLE_ELEMENT + ) { + return true + } + } + } + + return false + } + + private fun getNodeName(node: LayoutNode): String? { + var className: String? = null + for (info in node.getModifierInfo()) { + val modifier = info.modifier + if (modifier is SemanticsModifier) { + with(modifier.semanticsConfiguration) { + val onClickSemanticsConfiguration = getOrNull(SemanticsActions.OnClick) + if (onClickSemanticsConfiguration != null) { + val accessibilityActionLabel = onClickSemanticsConfiguration.label + if (accessibilityActionLabel != null) { + return accessibilityActionLabel + } + } + + val contentDescriptionSemanticsConfiguration = + getOrNull(SemanticsProperties.ContentDescription) + if (contentDescriptionSemanticsConfiguration != null) { + val contentDescription = + contentDescriptionSemanticsConfiguration.getOrNull(0) + if (contentDescription != null) { + return contentDescription + } + } + } + } else { + className = modifier::class.qualifiedName + } + } + + return className + } + + private fun hitTest( + node: LayoutNode, + x: Float, + y: Float, + ): Boolean { + val bounded = + composeLayoutNodeUtil.getLayoutNodeBoundsInWindow(node)?.let { bounds -> + x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom + } == true + + return bounded && isValidClickTarget(node) + } + + companion object { + private const val CLASS_NAME_CLICKABLE_ELEMENT = + "androidx.compose.foundation.ClickableElement" + private const val CLASS_NAME_COMBINED_CLICKABLE_ELEMENT = + "androidx.compose.foundation.CombinedClickableElement" + private const val CLASS_NAME_TOGGLEABLE_ELEMENT = + "androidx.compose.foundation.selection.ToggleableElement" + } +} diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/WindowCallbackWrapper.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/WindowCallbackWrapper.kt new file mode 100644 index 000000000..4c9fcc663 --- /dev/null +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/WindowCallbackWrapper.kt @@ -0,0 +1,34 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.compose.click + +import android.os.Build.VERSION_CODES +import android.view.ActionMode +import android.view.MotionEvent +import android.view.SearchEvent +import android.view.Window.Callback +import androidx.annotation.RequiresApi + +internal class WindowCallbackWrapper( + private val callback: Callback, + private val composeClickEventGenerator: ComposeClickEventGenerator, +) : Callback by callback { + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + composeClickEventGenerator.generateClick(event) + return callback.dispatchTouchEvent(event) + } + + @RequiresApi(api = VERSION_CODES.M) + override fun onSearchRequested(searchEvent: SearchEvent?): Boolean = callback.onSearchRequested(searchEvent) + + @RequiresApi(api = VERSION_CODES.M) + override fun onWindowStartingActionMode( + callback: ActionMode.Callback?, + type: Int, + ): ActionMode? = this.callback.onWindowStartingActionMode(callback, type) + + fun unwrap(): Callback = callback +} diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickActivityCallbackTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickActivityCallbackTest.kt new file mode 100644 index 000000000..aed008430 --- /dev/null +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickActivityCallbackTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.compose.click + +import android.app.Activity +import android.view.Window +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ComposeClickActivityCallbackTest { + lateinit var composeClickActivityCallback: ComposeClickActivityCallback + + @MockK + lateinit var composeClickEventGenerator: ComposeClickEventGenerator + + @MockK + lateinit var activity: Activity + + @MockK + lateinit var window: Window + + @Before + fun setup() { + MockKAnnotations.init(this) + composeClickActivityCallback = ComposeClickActivityCallback(composeClickEventGenerator) + } + + @Test + fun `verify that call is delegated to startTracking`() { + every { composeClickEventGenerator.startTracking(any()) } returns Unit + every { activity.window } returns window + + composeClickActivityCallback.onActivityResumed(activity) + verify { composeClickEventGenerator.startTracking(any()) } + } + + @Test + fun `verify that call is delegated to stopTracking`() { + every { composeClickEventGenerator.stopTracking() } returns Unit + composeClickActivityCallback.onActivityPaused(activity) + + verify { composeClickEventGenerator.stopTracking() } + } +} diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGeneratorTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGeneratorTest.kt new file mode 100644 index 000000000..a2beedbcc --- /dev/null +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGeneratorTest.kt @@ -0,0 +1,296 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package io.opentelemetry.instrumentation.compose.click + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.Window +import android.view.Window.Callback +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.ModifierInfo +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.platform.AndroidComposeView +import androidx.compose.ui.semantics.AccessibilityAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkClass +import io.opentelemetry.api.incubator.logs.ExtendedLogger +import io.opentelemetry.sdk.logs.data.internal.ExtendedLogRecordData +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo +import io.opentelemetry.sdk.testing.junit4.OpenTelemetryRule +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_COORDINATE_X +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_COORDINATE_Y +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_WIDGET_ID +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_WIDGET_NAME +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ComposeClickEventGeneratorTest { + private lateinit var openTelemetryRule: OpenTelemetryRule + + private lateinit var composeClickEventGenerator: ComposeClickEventGenerator + + @MockK + lateinit var composeLayoutNodeUtil: ComposeLayoutNodeUtil + + @MockK + lateinit var window: Window + + @MockK + lateinit var callback: Callback + + @MockK + internal lateinit var composeView: AndroidComposeView + + @MockK + lateinit var semanticsModifier: SemanticsModifier + + @MockK + lateinit var modifier: Modifier + + @MockK + lateinit var semanticsConfiguration: SemanticsConfiguration + + @Before + fun setup() { + openTelemetryRule = OpenTelemetryRule.create() + MockKAnnotations.init(this, relaxUnitFun = true) + composeClickEventGenerator = + ComposeClickEventGenerator( + openTelemetryRule.openTelemetry.logsBridge + .loggerBuilder("io.opentelemetry.android.instrumentation.compose") + .build() as ExtendedLogger, + composeLayoutNodeUtil, + ) + + every { window.callback } returns callback + every { window.decorView } returns composeView + every { window.callback = any() } returns Unit + + composeClickEventGenerator.startTracking(window) + } + + @Test + fun `capture click for a single hit target`() { + val motionEvent = + MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + + every { composeView.childCount } returns 0 + + buildMockLayoutNodeTree( + targetX = motionEvent.x, + targetY = motionEvent.y, + hitIndexes = listOf(2), + clickableIndexes = listOf(2, 3, 4), + ) + + composeClickEventGenerator.generateClick(motionEvent) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] as ExtendedLogRecordData + OpenTelemetryAssertions + .assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + ) + + event = events[1] as ExtendedLogRecordData + assertThat(event) + .hasEventName(VIEW_CLICK_EVENT_NAME) + .hasAttributesSatisfying( + equalTo(APP_WIDGET_ID, "2"), + equalTo(APP_WIDGET_NAME, "click"), + ) + } + + @Test + fun `capture click when there are two valid targets but the top target wins`() { + val motionEvent = + MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + + every { composeView.childCount } returns 0 + + buildMockLayoutNodeTree( + targetX = motionEvent.x, + targetY = motionEvent.y, + hitIndexes = listOf(3, 4), + clickableIndexes = listOf(2, 3, 4), + ) + + composeClickEventGenerator.generateClick(motionEvent) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] as ExtendedLogRecordData + OpenTelemetryAssertions + .assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + ) + + event = events[1] as ExtendedLogRecordData + assertThat(event) + .hasEventName(VIEW_CLICK_EVENT_NAME) + .hasAttributesSatisfying( + equalTo(APP_WIDGET_ID, "3"), + equalTo(APP_WIDGET_NAME, "click"), + ) + } + + @Test + fun `capture click when there are two valid targets but the top target wins and use content description for name`() { + val motionEvent = + MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + + every { composeView.childCount } returns 0 + + buildMockLayoutNodeTree( + targetX = motionEvent.x, + targetY = motionEvent.y, + hitIndexes = listOf(3, 4), + clickableIndexes = listOf(2, 3, 4), + describableIndexes = listOf(3, 4), + ) + + composeClickEventGenerator.generateClick(motionEvent) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] as ExtendedLogRecordData + OpenTelemetryAssertions + .assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + ) + + event = events[1] as ExtendedLogRecordData + assertThat(event) + .hasEventName(VIEW_CLICK_EVENT_NAME) + .hasAttributesSatisfying( + equalTo(APP_WIDGET_ID, "3"), + equalTo(APP_WIDGET_NAME, "clickMe"), + ) + } + + private fun createMockLayoutNode( + targetX: Float = 0f, + targetY: Float = 0f, + hitOffset: IntArray = intArrayOf(10, 20), + id: Int = 100, + hit: Boolean = false, + clickable: Boolean = false, + useDescription: Boolean = false, + ): LayoutNode { + val mockNode = mockkClass(LayoutNode::class) + every { mockNode.isPlaced } returns true + + val bounds = + if (hit) { + Rect( + left = targetX - hitOffset[0], + right = targetX + hitOffset[0], + top = targetY - hitOffset[1], + bottom = targetY + hitOffset[1], + ) + } else { + Rect( + left = targetX + hitOffset[0], + right = targetX + hitOffset[0], + top = targetY + hitOffset[1], + bottom = targetY + hitOffset[1], + ) + } + + val mockModifierInfo = mockkClass(ModifierInfo::class) + every { mockNode.getModifierInfo() } returns listOf(mockModifierInfo) + if (clickable) { + every { mockModifierInfo.modifier } returns semanticsModifier + + every { semanticsModifier.semanticsConfiguration } returns semanticsConfiguration + every { semanticsConfiguration.contains(eq(SemanticsActions.OnClick)) } returns true + + if (useDescription) { + every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns null + every { semanticsConfiguration.getOrNull(eq(SemanticsProperties.ContentDescription)) } returns + listOf( + "clickMe", + ) + } else { + every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns + AccessibilityAction<() -> Boolean>("click") { true } + } + + every { mockNode.semanticsId } returns id + } else { + every { mockModifierInfo.modifier } returns modifier + } + + every { composeLayoutNodeUtil.getLayoutNodeBoundsInWindow(mockNode) } returns bounds + every { composeLayoutNodeUtil.getLayoutNodePositionInWindow(mockNode) } returns + Offset( + x = bounds.left, + y = bounds.top, + ) + + return mockNode + } + + private fun buildMockLayoutNodeTree( + targetX: Float, + targetY: Float, + hitIndexes: List = emptyList(), + clickableIndexes: List = emptyList(), + describableIndexes: List = emptyList(), + ) { + val nodeList = mutableListOf() + for (i in 0 until 5) { + nodeList.add( + createMockLayoutNode( + targetX = targetX, + targetY = targetY, + id = i, + hit = hitIndexes.contains(i), + clickable = clickableIndexes.contains(i), + useDescription = describableIndexes.contains(i), + ), + ) + } + + every { nodeList[0].zSortedChildren } returns mutableVectorOf(nodeList[1], nodeList[2]) + every { nodeList[1].zSortedChildren } returns mutableVectorOf(nodeList[4], nodeList[3]) + every { nodeList[2].zSortedChildren } returns mutableVectorOf() + + every { nodeList[3].zSortedChildren } returns mutableVectorOf() + every { nodeList[4].zSortedChildren } returns mutableVectorOf() + every { composeView.root } returns nodeList[0] + } +} diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeInstrumentationTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeInstrumentationTest.kt new file mode 100644 index 000000000..d65841946 --- /dev/null +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeInstrumentationTest.kt @@ -0,0 +1,232 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package io.opentelemetry.instrumentation.compose.click + +import android.app.Activity +import android.app.Application +import android.os.SystemClock +import android.view.MotionEvent +import android.view.Window +import android.view.Window.Callback +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.ModifierInfo +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.LayoutNodeLayoutDelegate +import androidx.compose.ui.node.NodeCoordinator +import androidx.compose.ui.platform.AndroidComposeView +import androidx.compose.ui.semantics.AccessibilityAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkClass +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import io.opentelemetry.android.instrumentation.InstallationContext +import io.opentelemetry.android.session.SessionProvider +import io.opentelemetry.sdk.logs.data.internal.ExtendedLogRecordData +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo +import io.opentelemetry.sdk.testing.junit4.OpenTelemetryRule +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_COORDINATE_X +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_COORDINATE_Y +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_WIDGET_ID +import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_WIDGET_NAME +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ComposeInstrumentationTest { + private lateinit var openTelemetryRule: OpenTelemetryRule + + @MockK + lateinit var window: Window + + @MockK + lateinit var callback: Callback + + @MockK + lateinit var activity: Activity + + @MockK + lateinit var application: Application + + @MockK + internal lateinit var composeView: AndroidComposeView + + @MockK + lateinit var semanticsModifier: SemanticsModifier + + @MockK + lateinit var modifier: Modifier + + @MockK + lateinit var modifierInfo: ModifierInfo + + @MockK + lateinit var semanticsConfiguration: SemanticsConfiguration + + @MockK + lateinit var layoutDelegate: LayoutNodeLayoutDelegate + + @MockK + lateinit var nodeCoordinator: NodeCoordinator + + @Before + fun setup() { + openTelemetryRule = OpenTelemetryRule.create() + MockKAnnotations.init(this, relaxUnitFun = true) + } + + @Test + fun capture_compose_click() { + val installationContext = + InstallationContext( + application, + openTelemetryRule.openTelemetry, + mockk(), + ) + + val callbackCapturingSlot = slot() + every { window.callback } returns callback + every { callback.dispatchTouchEvent(any()) } returns false + + every { activity.window } returns window + every { application.registerActivityLifecycleCallbacks(any()) } returns Unit + + ComposeClickInstrumentation().install(installationContext) + + verify { + application.registerActivityLifecycleCallbacks(capture(callbackCapturingSlot)) + } + + val viewClickActivityCallback = callbackCapturingSlot.captured + val wrapperCapturingSlot = slot() + every { window.callback = any() } returns Unit + + val motionEvent = + MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + every { window.decorView } returns composeView + every { composeView.childCount } returns 0 + + val mockLayoutNode = + createMockLayoutNode( + targetX = motionEvent.x, + targetY = motionEvent.y, + hit = true, + clickable = true, + useDescription = true, + ) + every { composeView.root } returns mockLayoutNode + + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } + + wrapperCapturingSlot.captured.dispatchTouchEvent( + motionEvent, + ) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] as ExtendedLogRecordData + assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + ) + + event = events[1] as ExtendedLogRecordData + assertThat(event) + .hasEventName(VIEW_CLICK_EVENT_NAME) + .hasAttributesSatisfying( + equalTo(APP_WIDGET_ID, mockLayoutNode.semanticsId.toString()), + equalTo(APP_WIDGET_NAME, "clickMe"), + ) + } + + private fun createMockLayoutNode( + targetX: Float = 0f, + targetY: Float = 0f, + hitOffset: IntArray = intArrayOf(10, 20), + id: Int = 100, + hit: Boolean = false, + clickable: Boolean = false, + useDescription: Boolean = false, + ): LayoutNode { + val mockNode = mockkClass(LayoutNode::class) + every { mockNode.isPlaced } returns true + + val bounds = + if (hit) { + Rect( + left = targetX - hitOffset[0], + right = targetX + hitOffset[0], + top = targetY - hitOffset[1], + bottom = targetY + hitOffset[1], + ) + } else { + Rect( + left = targetX + hitOffset[0], + right = targetX + hitOffset[0], + top = targetY + hitOffset[1], + bottom = targetY + hitOffset[1], + ) + } + + every { mockNode.getModifierInfo() } returns listOf(modifierInfo) + if (clickable) { + every { modifierInfo.modifier } returns semanticsModifier + + every { semanticsModifier.semanticsConfiguration } returns semanticsConfiguration + every { semanticsConfiguration.contains(eq(SemanticsActions.OnClick)) } returns true + + if (useDescription) { + every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns null + every { semanticsConfiguration.getOrNull(eq(SemanticsProperties.ContentDescription)) } returns + listOf( + "clickMe", + ) + } else { + every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns + AccessibilityAction<() -> Boolean>("click") { true } + } + + every { mockNode.semanticsId } returns id + } else { + every { modifierInfo.modifier } returns modifier + } + + every { mockNode.zSortedChildren } returns mutableVectorOf() + every { mockNode.layoutDelegate } returns layoutDelegate + every { layoutDelegate.outerCoordinator } returns nodeCoordinator + every { nodeCoordinator.coordinates } returns nodeCoordinator + + mockkStatic("androidx.compose.ui.layout.LayoutCoordinatesKt") + every { nodeCoordinator.boundsInWindow() } returns bounds + every { nodeCoordinator.positionInWindow() } returns Offset(x = bounds.left, y = bounds.top) + + return mockNode + } +} diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetectorTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetectorTest.kt new file mode 100644 index 000000000..70af2af85 --- /dev/null +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetectorTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package io.opentelemetry.instrumentation.compose.click + +import android.os.SystemClock +import android.view.MotionEvent +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.ModifierInfo +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.platform.AndroidComposeView +import androidx.compose.ui.semantics.AccessibilityAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkClass +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ComposeTapTargetDetectorTest { + lateinit var composeTapTargetDetector: ComposeTapTargetDetector + + @MockK + lateinit var composeLayoutNodeUtil: ComposeLayoutNodeUtil + + @MockK + lateinit var composeView: AndroidComposeView + + @MockK + lateinit var semanticsModifier: SemanticsModifier + + @MockK + lateinit var modifier: Modifier + + @MockK + lateinit var semanticsConfiguration: SemanticsConfiguration + + @Before + fun setup() { + MockKAnnotations.init(this) + composeTapTargetDetector = ComposeTapTargetDetector(composeLayoutNodeUtil) + } + + @Test + fun `name from onClick label`() { + val name = composeTapTargetDetector.nodeToName(createMockLayoutNode(clickable = true)) + assertThat(name).isEqualTo("click") + } + + @Test + fun `name from description`() { + val name = + composeTapTargetDetector.nodeToName( + createMockLayoutNode( + clickable = true, + useDescription = true, + ), + ) + assertThat(name).isEqualTo("clickMe") + } + + @Test + fun `name from id on exception`() { + val mockNode = mockkClass(LayoutNode::class) + every { mockNode.semanticsId } returns 41 + every { mockNode.getModifierInfo() } throws RuntimeException("test") + + val name = + composeTapTargetDetector.nodeToName( + mockNode, + ) + assertThat(name).isEqualTo("41") + } + + @Test + fun `name from id on null`() { + val mockNode = mockkClass(LayoutNode::class) + every { mockNode.semanticsId } returns 41 + every { mockNode.getModifierInfo() } returns listOf() + + val name = + composeTapTargetDetector.nodeToName( + mockNode, + ) + assertThat(name).isEqualTo("41") + } + + @Test + fun `return tap target when hit`() { + val motionEvent = + MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + every { composeView.childCount } returns 0 + + val mockLayoutNode = + createMockLayoutNode( + targetX = motionEvent.x, + targetY = motionEvent.y, + hit = true, + clickable = true, + useDescription = true, + ) + every { composeView.root } returns mockLayoutNode + + val actual = + composeTapTargetDetector.findTapTarget(composeView, motionEvent.x, motionEvent.y) + assertThat(actual).isEqualTo(mockLayoutNode) + } + + @Test + fun `return null when no hit`() { + val motionEvent = + MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + every { composeView.childCount } returns 0 + + val mockLayoutNode = + createMockLayoutNode( + targetX = motionEvent.x, + targetY = motionEvent.y, + hit = false, + clickable = true, + useDescription = true, + ) + every { composeView.root } returns mockLayoutNode + + val actual = + composeTapTargetDetector.findTapTarget(composeView, motionEvent.x, motionEvent.y) + assertThat(actual).isNull() + } + + private fun createMockLayoutNode( + targetX: Float = 0f, + targetY: Float = 0f, + hitOffset: IntArray = intArrayOf(10, 20), + id: Int = 100, + hit: Boolean = false, + clickable: Boolean = false, + useDescription: Boolean = false, + ): LayoutNode { + val mockNode = mockkClass(LayoutNode::class) + every { mockNode.isPlaced } returns true + + val bounds = + if (hit) { + Rect( + left = targetX - hitOffset[0], + right = targetX + hitOffset[0], + top = targetY - hitOffset[1], + bottom = targetY + hitOffset[1], + ) + } else { + Rect( + left = targetX + hitOffset[0], + right = targetX + hitOffset[0], + top = targetY + hitOffset[1], + bottom = targetY + hitOffset[1], + ) + } + + val mockModifierInfo = mockkClass(ModifierInfo::class) + every { mockNode.getModifierInfo() } returns listOf(mockModifierInfo) + if (clickable) { + every { mockModifierInfo.modifier } returns semanticsModifier + + every { semanticsModifier.semanticsConfiguration } returns semanticsConfiguration + every { semanticsConfiguration.contains(eq(SemanticsActions.OnClick)) } returns true + + if (useDescription) { + every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns null + every { semanticsConfiguration.getOrNull(eq(SemanticsProperties.ContentDescription)) } returns + listOf( + "clickMe", + ) + } else { + every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns + AccessibilityAction<() -> Boolean>("click") { true } + } + + every { mockNode.semanticsId } returns id + } else { + every { mockModifierInfo.modifier } returns modifier + } + + every { mockNode.zSortedChildren } returns mutableVectorOf() + every { composeLayoutNodeUtil.getLayoutNodeBoundsInWindow(mockNode) } returns bounds + every { composeLayoutNodeUtil.getLayoutNodePositionInWindow(mockNode) } returns + Offset( + x = bounds.left, + y = bounds.top, + ) + + return mockNode + } +}