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 index ccc484198..e190c700a 100644 --- 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 @@ -11,11 +11,10 @@ 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.HARDWARE_POINTER_BUTTON -import io.opentelemetry.android.instrumentation.view.click.internal.HARDWARE_POINTER_CLICKS -import io.opentelemetry.android.instrumentation.view.click.internal.HARDWARE_POINTER_TYPE -import io.opentelemetry.android.instrumentation.view.click.internal.TapEvent +import io.opentelemetry.android.instrumentation.view.click.internal.APP_SCREEN_LONG_PRESS_EVENT_NAME +import io.opentelemetry.android.instrumentation.view.click.internal.Gesture import io.opentelemetry.android.instrumentation.view.click.internal.VIEW_CLICK_EVENT_NAME +import io.opentelemetry.android.instrumentation.view.click.internal.VIEW_LONG_PRESS_EVENT_NAME import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.LogRecordBuilder import io.opentelemetry.api.logs.Logger @@ -38,15 +37,15 @@ internal class ViewClickEventGenerator( windowRef?.get()?.let { window -> - val tapEvent = TapEvent(motionEvent) + val gesture = Gesture.Click(motionEvent, 2) - createEvent(APP_SCREEN_CLICK_EVENT_NAME, tapEvent, 2) + createEvent(APP_SCREEN_CLICK_EVENT_NAME, gesture) .setAttribute(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()) .setAttribute(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()) .emit() findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view -> - createEvent(VIEW_CLICK_EVENT_NAME, tapEvent, 2) + createEvent(VIEW_CLICK_EVENT_NAME, gesture) .setAllAttributes(createViewAttributes(view)) .emit() } @@ -59,21 +58,38 @@ internal class ViewClickEventGenerator( windowRef?.get()?.let { window -> - val tapEvent = TapEvent(motionEvent) + val gesture = Gesture.Click(motionEvent, 1) - createEvent(APP_SCREEN_CLICK_EVENT_NAME, tapEvent, 1) + createEvent(APP_SCREEN_CLICK_EVENT_NAME, gesture) .setAttribute(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()) .setAttribute(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()) .emit() findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view -> - createEvent(VIEW_CLICK_EVENT_NAME, tapEvent, 1) + createEvent(VIEW_CLICK_EVENT_NAME, gesture) .setAllAttributes(createViewAttributes(view)) .emit() } } return false } + + override fun onLongPress(e: MotionEvent) { + windowRef?.get()?.let { window -> + + val gesture = Gesture.LongPress(e) + createEvent(APP_SCREEN_LONG_PRESS_EVENT_NAME, gesture) + .setAttribute(APP_SCREEN_COORDINATE_Y, e.y.toLong()) + .setAttribute(APP_SCREEN_COORDINATE_X, e.x.toLong()) + .emit() + + findTargetForTap(window.decorView, e.x, e.y)?.let { view -> + createEvent(VIEW_LONG_PRESS_EVENT_NAME, gesture) + .setAllAttributes(createViewAttributes(view)) + .emit() + } + } + } } val gestureDetector = GestureDetector(null, gestureListener) @@ -101,13 +117,16 @@ internal class ViewClickEventGenerator( windowRef = null } - private fun createEvent(name: String, tapEvent: TapEvent, clicks: Int): LogRecordBuilder = - eventLogger + private fun createEvent(name: String, gesture: Gesture): LogRecordBuilder { + + val logger = eventLogger .logRecordBuilder() .setEventName(name) - .setAttribute(HARDWARE_POINTER_CLICKS, clicks) - .setAttribute(HARDWARE_POINTER_TYPE, tapEvent.toolTypeDescription) - .setAttribute(HARDWARE_POINTER_BUTTON, tapEvent.buttonStateDescription) + .setAllAttributes(gesture.attributes) + + return logger + } + private fun createViewAttributes(view: View): Attributes { val builder = Attributes.builder() 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 index 9ec8fd17c..41ea636c3 100644 --- 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 @@ -6,9 +6,12 @@ package io.opentelemetry.android.instrumentation.view.click.internal import android.view.MotionEvent +import io.opentelemetry.api.common.Attributes internal const val APP_SCREEN_CLICK_EVENT_NAME = "app.screen.click" internal const val VIEW_CLICK_EVENT_NAME = "app.widget.click" +internal const val APP_SCREEN_LONG_PRESS_EVENT_NAME = "app.screen.longpress" +internal const val VIEW_LONG_PRESS_EVENT_NAME = "app.widget.longpress" internal const val HARDWARE_POINTER_TYPE = "hw.pointer.type" @@ -37,7 +40,7 @@ internal fun toolTypeToString(toolTypeInt: Int): String { } } -internal class TapEvent( +internal class TapEventMetadata( private val motionEvent: MotionEvent ) { @@ -58,3 +61,21 @@ internal class TapEvent( } } + +internal sealed class Gesture(val m: MotionEvent) { + private val t = TapEventMetadata(m) + var attributes = Attributes.empty() + + init { + attributes = attributes.toBuilder().put(HARDWARE_POINTER_TYPE, t.toolTypeDescription).build() + if(t.buttonStateDescription != null) { + attributes = attributes.toBuilder().put(HARDWARE_POINTER_BUTTON, t.buttonStateDescription).build() + } + } + class LongPress(val motionEvent: MotionEvent): Gesture(motionEvent) + class Click(val motionEvent: MotionEvent, clicks: Int): Gesture(motionEvent) { + init { + attributes = attributes.toBuilder().put(HARDWARE_POINTER_CLICKS, clicks.toLong()).build() + } + } +} diff --git a/instrumentation/view-click/src/test/kotlin/TestUtils.kt b/instrumentation/view-click/src/test/kotlin/TestUtils.kt index 723d2e263..8d6d0d25b 100644 --- a/instrumentation/view-click/src/test/kotlin/TestUtils.kt +++ b/instrumentation/view-click/src/test/kotlin/TestUtils.kt @@ -161,6 +161,49 @@ fun getSingleTapSequence(x: Float, y: Float, toolType: Int = MotionEvent.TOOL_TY ) } +fun getLongPressSequence(x: Float, y: Float, toolType: Int = MotionEvent.TOOL_TYPE_FINGER, buttonState: Int = 0, + stayDownLongEnough: Boolean = true) + : Array { + require(toolType in allowedToolTypes) { + "Invalid tool type" + } + + if (buttonState != 0) { + require(toolType == MotionEvent.TOOL_TYPE_MOUSE || toolType == MotionEvent.TOOL_TYPE_STYLUS) { + "Invalid tool type for button state" + } + require(buttonState in allowedButtonStates) { "Invalid button state" } + } + + val initialTime = SystemClock.uptimeMillis() + + val pointerProperties = MotionEvent.PointerProperties() + pointerProperties.id = 0 + pointerProperties.toolType = toolType + + val pointerCoords = PointerCoordsBuilder.newBuilder().setCoords(x, y).build() + val delay = if (stayDownLongEnough) { + ViewConfiguration.getLongPressTimeout() + } else { + 100 + } + return arrayOf( + MotionEvent.obtain(initialTime, initialTime, + MotionEvent.ACTION_DOWN, 1, arrayOf(pointerProperties), + arrayOf(pointerCoords), 0, buttonState, 1f, 1f, + 0, 0, 0, 0), + MotionEvent.obtain(initialTime, initialTime + delay, + MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties), + arrayOf(pointerCoords), 0, buttonState, 1f, 1f, + 0, 0, 0, 0) + ) +} + fun fastForwardDoubleTapTimeout() { ShadowLooper.idleMainLooper(ViewConfiguration.getDoubleTapTimeout().toLong(), TimeUnit.MILLISECONDS) } + +fun fastForwardLongPressTimeout() { + val allowanceTime = 150L + ShadowLooper.idleMainLooper(ViewConfiguration.getLongPressTimeout().toLong() + allowanceTime, TimeUnit.MILLISECONDS) +} diff --git a/instrumentation/view-click/src/test/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewLongPressInstrumentationTest.kt b/instrumentation/view-click/src/test/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewLongPressInstrumentationTest.kt new file mode 100644 index 000000000..973d7750a --- /dev/null +++ b/instrumentation/view-click/src/test/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewLongPressInstrumentationTest.kt @@ -0,0 +1,276 @@ +/* + * 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.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.Window.Callback +import androidx.test.ext.junit.runners.AndroidJUnit4 +import fastForwardLongPressTimeout +import getLongPressSequence +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.slot +import io.mockk.verify +import io.opentelemetry.android.OpenTelemetryRum +import io.opentelemetry.android.instrumentation.view.click.internal.APP_SCREEN_LONG_PRESS_EVENT_NAME +import io.opentelemetry.android.instrumentation.view.click.internal.HARDWARE_POINTER_TYPE +import io.opentelemetry.android.instrumentation.view.click.internal.VIEW_LONG_PRESS_EVENT_NAME +import io.opentelemetry.android.session.SessionProvider +import io.opentelemetry.api.common.AttributeKey.stringKey +import io.opentelemetry.sdk.common.Clock +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 mockView +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@ExtendWith(MockKExtension::class) +class ViewLongPressInstrumentationTest { + 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) + } + + private val toolTypeKey = stringKey(HARDWARE_POINTER_TYPE) + + + @Test + fun capture_view_long_press() { + val openTelemetryRum = mockk { + every { openTelemetry } returns openTelemetryRule.openTelemetry + every { sessionProvider } returns mockk() + every { clock } returns Clock.getDefault() + } + + 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(application, openTelemetryRum) + + verify { + application.registerActivityLifecycleCallbacks(capture(callbackCapturingSlot)) + } + + val viewClickActivityCallback = callbackCapturingSlot.captured + val wrapperCapturingSlot = slot() + every { window.callback = any() } returns Unit + + val longPressSequence = getLongPressSequence(250f, 50f) + val motionEvent = longPressSequence[0] + + val mockView = mockView(10012, motionEvent) + every { window.decorView } returns mockView + + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } + + wrapperCapturingSlot.captured.dispatchTouchEvent( + longPressSequence[0], + ) + fastForwardLongPressTimeout() + wrapperCapturingSlot.captured.dispatchTouchEvent( + longPressSequence[1], + ) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] + assertThat(event) + .hasEventName(APP_SCREEN_LONG_PRESS_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + equalTo(toolTypeKey, "finger") + ) + + event = events[1] + assertThat(event) + .hasEventName(VIEW_LONG_PRESS_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, mockView.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, mockView.y.toLong()), + equalTo(APP_WIDGET_ID, mockView.id.toString()), + equalTo(APP_WIDGET_NAME, "10012"), + equalTo(toolTypeKey, "finger") + ) + } + + @Test + fun capture_view_single_tap_when_too_short_for_long_press() { + + val openTelemetryRum = mockk { + every { openTelemetry } returns openTelemetryRule.openTelemetry + every { sessionProvider } returns mockk() + every { clock } returns Clock.getDefault() + } + + val callbackCapturingSlot = slot() + every { window.callback } returns callback + every { callback.dispatchTouchEvent(any()) } returns false + + every { activity.window } returns window + every { window.context } returns application + every { application.registerActivityLifecycleCallbacks(any()) } returns Unit + + ViewClickInstrumentation().install(application, openTelemetryRum) + + verify { + application.registerActivityLifecycleCallbacks(capture(callbackCapturingSlot)) + } + + val viewClickActivityCallback = callbackCapturingSlot.captured + val wrapperCapturingSlot = slot() + every { window.callback = any() } returns Unit + + + val longPressSequence = getLongPressSequence(250f, 50f, stayDownLongEnough = false) + val initialDownEvent = longPressSequence[0] + + val mockView = mockView(10012, initialDownEvent) + every { window.decorView } returns mockView + + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } + + wrapperCapturingSlot.captured.dispatchTouchEvent(longPressSequence[0]) + fastForwardLongPressTimeout() + wrapperCapturingSlot.captured.dispatchTouchEvent(longPressSequence[1]) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + + var event = events[0] + assertThat(event) + .hasEventName(APP_SCREEN_LONG_PRESS_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, initialDownEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, initialDownEvent.y.toLong()), + equalTo(toolTypeKey, "finger") + ) + + event = events[1] + assertThat(event) + .hasEventName(VIEW_LONG_PRESS_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, mockView.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, mockView.y.toLong()), + equalTo(APP_WIDGET_ID, mockView.id.toString()), + equalTo(APP_WIDGET_NAME, "10012"), + equalTo(toolTypeKey, "finger") + ) + } + + @Test + fun capture_view_long_press_in_viewGroup() { + + val openTelemetryRum = mockk { + every { openTelemetry } returns openTelemetryRule.openTelemetry + every { sessionProvider } returns mockk() + every { clock } returns Clock.getDefault() + } + + 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(application, openTelemetryRum) + verify { + application.registerActivityLifecycleCallbacks(capture(callbackCapturingSlot)) + } + + val viewClickActivityCallback = callbackCapturingSlot.captured + val wrapperCapturingSlot = slot() + every { window.callback = any() } returns Unit + + val longPressSequence = getLongPressSequence(250f, 50f) + val initialDownEvent = longPressSequence[0] + + val mockView = mockView(10012, initialDownEvent) + val mockViewGroup = + mockView(10013, initialDownEvent, 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(longPressSequence[0]) + fastForwardLongPressTimeout() + wrapperCapturingSlot.captured.dispatchTouchEvent(longPressSequence[1]) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(APP_SCREEN_LONG_PRESS_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, initialDownEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, initialDownEvent.y.toLong()), + equalTo(toolTypeKey, "finger") + ) + + event = events[1] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(VIEW_LONG_PRESS_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, mockView.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, mockView.y.toLong()), + equalTo(APP_WIDGET_ID, mockView.id.toString()), + equalTo(APP_WIDGET_NAME, "10012"), + equalTo(toolTypeKey, "finger") + ) + } +}