diff --git a/instrumentation/view-click/build.gradle.kts b/instrumentation/view-click/build.gradle.kts new file mode 100644 index 000000000..79127a3cf --- /dev/null +++ b/instrumentation/view-click/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("otel.android-library-conventions") + id("otel.publish-conventions") +} + +description = "OpenTelemetry Android View click library instrumentation" + +android { + namespace = "io.opentelemetry.android.instrumentation.view.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")) + + implementation(libs.opentelemetry.instrumentation.apiSemconv) + implementation(libs.opentelemetry.api.incubator) + + testImplementation(project(":test-common")) + testImplementation(project(":session")) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) +} diff --git a/instrumentation/view-click/consumer-rules.pro b/instrumentation/view-click/consumer-rules.pro new file mode 100644 index 000000000..461035d3f --- /dev/null +++ b/instrumentation/view-click/consumer-rules.pro @@ -0,0 +1,2 @@ +# used in reflection to check if compose is available at runtime +-keepnames class androidx.compose.ui.platform.ComposeView \ No newline at end of file diff --git a/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickActivityCallback.kt b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickActivityCallback.kt new file mode 100644 index 000000000..f1b426afa --- /dev/null +++ b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickActivityCallback.kt @@ -0,0 +1,23 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.view.click + +import android.app.Activity +import io.opentelemetry.android.internal.services.visiblescreen.activities.DefaultingActivityLifecycleCallbacks + +class ViewClickActivityCallback( + private val viewClickEventGenerator: ViewClickEventGenerator, +) : DefaultingActivityLifecycleCallbacks { + override fun onActivityResumed(activity: Activity) { + super.onActivityResumed(activity) + viewClickEventGenerator.startTracking(activity.window) + } + + override fun onActivityPaused(activity: Activity) { + super.onActivityPaused(activity) + viewClickEventGenerator.stopTracking() + } +} diff --git a/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickEventGenerator.kt b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickEventGenerator.kt new file mode 100644 index 000000000..25a97cb07 --- /dev/null +++ b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickEventGenerator.kt @@ -0,0 +1,147 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.view.click + +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.Window +import io.opentelemetry.android.instrumentation.view.click.internal.APP_SCREEN_CLICK_EVENT_NAME +import io.opentelemetry.android.instrumentation.view.click.internal.VIEW_CLICK_EVENT_NAME +import io.opentelemetry.android.instrumentation.view.click.internal.viewIdAttr +import io.opentelemetry.android.instrumentation.view.click.internal.viewNameAttr +import io.opentelemetry.android.instrumentation.view.click.internal.xCoordinateAttr +import io.opentelemetry.android.instrumentation.view.click.internal.yCoordinateAttr +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.incubator.logs.ExtendedLogRecordBuilder +import io.opentelemetry.api.incubator.logs.ExtendedLogger +import java.lang.ref.WeakReference +import java.util.LinkedList + +class ViewClickEventGenerator( + private val eventLogger: ExtendedLogger, +) { + private var windowRef: WeakReference? = null + + private val viewCoordinates = IntArray(2) + + 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(yCoordinateAttr, motionEvent.y.toLong()) + .setAttribute(xCoordinateAttr, motionEvent.x.toLong()) + .emit() + + findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view -> + createEvent(VIEW_CLICK_EVENT_NAME) + .setAllAttributes(createViewAttributes(view)) + .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 createViewAttributes(view: View): Attributes { + val builder = Attributes.builder() + builder.put(viewNameAttr, viewToName(view)) + builder.put(viewIdAttr, view.id.toLong()) + + builder.put(xCoordinateAttr, view.x.toLong()) + builder.put(yCoordinateAttr, view.y.toLong()) + return builder.build() + } + + private fun viewToName(view: View): String = + try { + view.resources?.getResourceEntryName(view.id) ?: view.id.toString() + } catch (throwable: Throwable) { + view.id.toString() + } + + private fun findTargetForTap( + decorView: View, + x: Float, + y: Float, + ): View? { + val queue = LinkedList() + queue.addFirst(decorView) + var target: View? = null + + while (queue.isNotEmpty()) { + val view = queue.removeFirst() + if (isJetpackComposeView(view)) { + return null + } + + if (isValidClickTarget(view)) { + target = view + } + + if (view is ViewGroup) { + handleViewGroup(view, x, y, queue) + } + } + return target + } + + private fun isValidClickTarget(view: View): Boolean = view.isClickable && view.isVisible + + private fun handleViewGroup( + view: ViewGroup, + x: Float, + y: Float, + stack: LinkedList, + ) { + if (!view.isVisible) return + + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + if (hitTest(child, x, y) && !isJetpackComposeView(child)) { + stack.add(child) + } + } + } + + private fun hitTest( + view: View, + x: Float, + y: Float, + ): Boolean { + view.getLocationInWindow(viewCoordinates) + val vx = viewCoordinates[0] + val vy = viewCoordinates[1] + + val w = view.width + val h = view.height + return !(x < vx || x > vx + w || y < vy || y > vy + h) + } + + private fun isJetpackComposeView(view: View): Boolean = view::class.java.name.startsWith("androidx.compose.ui.platform.ComposeView") + + private val View.isVisible: Boolean + get() = visibility == View.VISIBLE +} diff --git a/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickInstrumentation.kt b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickInstrumentation.kt new file mode 100644 index 000000000..cd6023cb5 --- /dev/null +++ b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickInstrumentation.kt @@ -0,0 +1,29 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.view.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 ViewClickInstrumentation : AndroidInstrumentation { + override val name: String = "view.click" + + override fun install(ctx: InstallationContext) { + ctx.application.registerActivityLifecycleCallbacks( + ViewClickActivityCallback( + ViewClickEventGenerator( + ctx.openTelemetry + .logsBridge + .loggerBuilder("io.opentelemetry.android.instrumentation.view.click") + .build() as ExtendedLogger, + ), + ), + ) + } +} diff --git a/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/WindowCallbackWrapper.kt b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/WindowCallbackWrapper.kt new file mode 100644 index 000000000..b69259552 --- /dev/null +++ b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/WindowCallbackWrapper.kt @@ -0,0 +1,34 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.view.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 + +class WindowCallbackWrapper( + private val callback: Callback, + private val viewClickEventGenerator: ViewClickEventGenerator, +) : Callback by callback { + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + viewClickEventGenerator.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/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/internal/ViewUtils.kt b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/internal/ViewUtils.kt new file mode 100644 index 000000000..f31021930 --- /dev/null +++ b/instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/internal/ViewUtils.kt @@ -0,0 +1,18 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.view.click.internal + +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.AttributeKey.longKey +import io.opentelemetry.api.common.AttributeKey.stringKey + +const val APP_SCREEN_CLICK_EVENT_NAME = "app.screen.click" +const val VIEW_CLICK_EVENT_NAME = "event.app.widget.click" +val viewNameAttr: AttributeKey = stringKey("app.widget.name") + +val xCoordinateAttr: AttributeKey = longKey("app.screen.coordinate.x") +val yCoordinateAttr: AttributeKey = longKey("app.screen.coordinate.y") +val viewIdAttr: AttributeKey = longKey("app.widget.id") diff --git a/instrumentation/view-click/src/test/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickInstrumentationTest.kt b/instrumentation/view-click/src/test/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickInstrumentationTest.kt new file mode 100644 index 000000000..831aae0a5 --- /dev/null +++ b/instrumentation/view-click/src/test/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickInstrumentationTest.kt @@ -0,0 +1,326 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.view.click + +import android.app.Activity +import android.app.Application +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.Window.Callback +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.mockkClass +import io.mockk.slot +import io.mockk.verify +import io.opentelemetry.android.instrumentation.InstallationContext +import io.opentelemetry.android.instrumentation.view.click.internal.APP_SCREEN_CLICK_EVENT_NAME +import io.opentelemetry.android.instrumentation.view.click.internal.VIEW_CLICK_EVENT_NAME +import io.opentelemetry.android.instrumentation.view.click.internal.viewIdAttr +import io.opentelemetry.android.instrumentation.view.click.internal.viewNameAttr +import io.opentelemetry.android.instrumentation.view.click.internal.xCoordinateAttr +import io.opentelemetry.android.instrumentation.view.click.internal.yCoordinateAttr +import io.opentelemetry.android.session.SessionManager +import io.opentelemetry.android.test.common.hasEventName +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 org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@ExtendWith(MockKExtension::class) +class ViewClickInstrumentationTest { + 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 + + @Before + fun setUp() { + openTelemetryRule = OpenTelemetryRule.create() + MockKAnnotations.init(this, relaxUnitFun = true) + } + + @Test + fun capture_view_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 + + ViewClickInstrumentation().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) + val mockView = mockView(10012, motionEvent) + every { window.decorView } returns mockView + + 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(xCoordinateAttr, motionEvent.x.toLong()), + equalTo(yCoordinateAttr, motionEvent.y.toLong()), + ) + + event = events[1] as ExtendedLogRecordData + assertThat(event) + .hasEventName(VIEW_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(xCoordinateAttr, mockView.x.toLong()), + equalTo(yCoordinateAttr, mockView.y.toLong()), + equalTo(viewIdAttr, mockView.id), + equalTo(viewNameAttr, "10012"), + ) + } + + @Test + fun capture_view_click_in_viewGroup() { + 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 + + ViewClickInstrumentation().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) + val mockView = mockView(10012, motionEvent) + val mockViewGroup = + mockView(10013, motionEvent, clickable = false) { + every { it.childCount } returns 1 + every { it.getChildAt(any()) } returns mockView + } + + every { window.decorView } returns mockViewGroup + 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(xCoordinateAttr, motionEvent.x.toLong()), + equalTo(yCoordinateAttr, motionEvent.y.toLong()), + ) + + event = events[1] as ExtendedLogRecordData + assertThat(event) + .hasEventName(VIEW_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(xCoordinateAttr, mockView.x.toLong()), + equalTo(yCoordinateAttr, mockView.y.toLong()), + equalTo(viewIdAttr, mockView.id), + equalTo(viewNameAttr, "10012"), + ) + } + + @Test + fun not_captured_view_click_in_viewGroup() { + 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 + + ViewClickInstrumentation().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) + val mockView = mockView(10012, motionEvent, hitOffset = intArrayOf(50, 30)) + val mockViewGroup = + mockView(10013, motionEvent, clickable = false) { + every { it.childCount } returns 1 + every { it.getChildAt(any()) } returns mockView + } + + every { window.decorView } returns mockViewGroup + + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } + + wrapperCapturingSlot.captured.dispatchTouchEvent( + motionEvent, + ) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(1) + + val event = events[0] as ExtendedLogRecordData + assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(xCoordinateAttr, motionEvent.x.toLong()), + equalTo(yCoordinateAttr, motionEvent.y.toLong()), + ) + } + + @Test + fun not_captured_view_click_for_down_event() { + 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 + + ViewClickInstrumentation().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_DOWN, 250f, 50f, 0) + + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } + + wrapperCapturingSlot.captured.dispatchTouchEvent( + motionEvent, + ) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(0) + } + + private inline fun mockView( + id: Int, + motionEvent: MotionEvent, + hitOffset: IntArray = intArrayOf(0, 0), + clickable: Boolean = true, + visibility: Int = View.VISIBLE, + applyOthers: (T) -> Unit = {}, + ): T { + val mockView = mockkClass(T::class) + every { mockView.visibility } returns visibility + every { mockView.isClickable } returns clickable + + every { mockView.id } returns id + val location = IntArray(2) + + location[0] = (motionEvent.x + hitOffset[0]).toInt() + location[1] = (motionEvent.y + hitOffset[1]).toInt() + + val arrayCapturingSlot = slot() + every { mockView.getLocationInWindow(capture(arrayCapturingSlot)) } answers { + arrayCapturingSlot.captured[0] = location[0] + arrayCapturingSlot.captured[1] = location[1] + } + + every { mockView.x } returns location[0].toFloat() + every { mockView.y } returns location[1].toFloat() + + every { mockView.width } returns (location[0] + hitOffset[0]) + every { mockView.height } returns (location[1] + hitOffset[1]) + applyOthers.invoke(mockView) + + return mockView + } +}