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 a70af45b4..ccc484198 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 @@ -5,11 +5,16 @@ package io.opentelemetry.android.instrumentation.view.click +import android.view.GestureDetector 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.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.VIEW_CLICK_EVENT_NAME import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.LogRecordBuilder @@ -26,28 +31,64 @@ internal class ViewClickEventGenerator( ) { private var windowRef: WeakReference? = null - private val viewCoordinates = IntArray(2) + private val gestureListener = object : GestureDetector.SimpleOnGestureListener() { - 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) + override fun onDoubleTap(motionEvent: MotionEvent): Boolean { + windowRef?.get()?.let { window -> + + + val tapEvent = TapEvent(motionEvent) + + createEvent(APP_SCREEN_CLICK_EVENT_NAME, tapEvent, 2) + .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) + .setAllAttributes(createViewAttributes(view)) + .emit() + } + + } + return false + } + + override fun onSingleTapConfirmed(motionEvent: MotionEvent): Boolean { + windowRef?.get()?.let { window -> + + + val tapEvent = TapEvent(motionEvent) + + createEvent(APP_SCREEN_CLICK_EVENT_NAME, tapEvent, 1) .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) + createEvent(VIEW_CLICK_EVENT_NAME, tapEvent, 1) .setAllAttributes(createViewAttributes(view)) .emit() } } + return false + } + } + + val gestureDetector = GestureDetector(null, gestureListener) + + 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?) { + if (motionEvent != null) { + gestureDetector.onTouchEvent(motionEvent) } } @@ -60,10 +101,13 @@ internal class ViewClickEventGenerator( windowRef = null } - private fun createEvent(name: String): LogRecordBuilder = + private fun createEvent(name: String, tapEvent: TapEvent, clicks: Int): LogRecordBuilder = eventLogger .logRecordBuilder() .setEventName(name) + .setAttribute(HARDWARE_POINTER_CLICKS, clicks) + .setAttribute(HARDWARE_POINTER_TYPE, tapEvent.toolTypeDescription) + .setAttribute(HARDWARE_POINTER_BUTTON, tapEvent.buttonStateDescription) 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 ba6fb12b0..9ec8fd17c 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 @@ -5,5 +5,56 @@ package io.opentelemetry.android.instrumentation.view.click.internal +import android.view.MotionEvent + internal const val APP_SCREEN_CLICK_EVENT_NAME = "app.screen.click" internal const val VIEW_CLICK_EVENT_NAME = "app.widget.click" + +internal const val HARDWARE_POINTER_TYPE = "hw.pointer.type" + +internal const val HARDWARE_POINTER_BUTTON = "hw.pointer.button" + +internal const val HARDWARE_POINTER_CLICKS = "hw.pointer.clicks" + +internal fun buttonStateToString(buttonStateInt: Int): String? { + return when(buttonStateInt) { + MotionEvent.BUTTON_PRIMARY, MotionEvent.BUTTON_STYLUS_PRIMARY -> "primary" + MotionEvent.BUTTON_SECONDARY, MotionEvent.BUTTON_STYLUS_SECONDARY -> "secondary" + MotionEvent.BUTTON_TERTIARY -> "tertiary" + MotionEvent.BUTTON_BACK -> "back" + MotionEvent.BUTTON_FORWARD -> "forward" + else -> null + } +} + +internal fun toolTypeToString(toolTypeInt: Int): String { + return when(toolTypeInt) { + MotionEvent.TOOL_TYPE_MOUSE -> "mouse" + MotionEvent.TOOL_TYPE_FINGER -> "finger" + MotionEvent.TOOL_TYPE_STYLUS -> "stylus" + MotionEvent.TOOL_TYPE_ERASER -> "eraser" + else -> "unknown" + } +} + +internal class TapEvent( + private val motionEvent: MotionEvent +) { + + val toolTypeDescription: String + val buttonStateDescription: String? + + init { + val toolTypeInt = motionEvent.getToolType(0) + val toolTypeHasButtons = + toolTypeInt == MotionEvent.TOOL_TYPE_MOUSE || toolTypeInt == MotionEvent.TOOL_TYPE_STYLUS + val buttonStateInt = motionEvent.buttonState + if (toolTypeHasButtons) { + buttonStateDescription = buttonStateToString(buttonStateInt) + } else { + buttonStateDescription = null + } + toolTypeDescription = toolTypeToString(toolTypeInt) + } + +} diff --git a/instrumentation/view-click/src/test/kotlin/TestUtils.kt b/instrumentation/view-click/src/test/kotlin/TestUtils.kt new file mode 100644 index 000000000..723d2e263 --- /dev/null +++ b/instrumentation/view-click/src/test/kotlin/TestUtils.kt @@ -0,0 +1,166 @@ +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import androidx.test.core.view.PointerCoordsBuilder +import io.mockk.every +import io.mockk.mockkClass +import io.mockk.slot +import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.TimeUnit + + +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 +} + +private val allowedToolTypes = arrayOf(MotionEvent.TOOL_TYPE_FINGER, MotionEvent.TOOL_TYPE_MOUSE, + MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER, MotionEvent.TOOL_TYPE_UNKNOWN) + +private val allowedButtonStates = arrayOf( + MotionEvent.BUTTON_PRIMARY, MotionEvent.BUTTON_STYLUS_PRIMARY, + MotionEvent.BUTTON_SECONDARY, MotionEvent.BUTTON_STYLUS_SECONDARY, + MotionEvent.BUTTON_TERTIARY, + MotionEvent.BUTTON_BACK, + MotionEvent.BUTTON_FORWARD +) + +fun getDoubleTapSequence(x: Float, y: Float, toolType: Int = MotionEvent.TOOL_TYPE_FINGER, buttonState: Int = 0, + exceedTimeOut: Boolean = false): 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() + + if(exceedTimeOut) { + val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout() + + 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 + 300L, + MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties), + arrayOf(pointerCoords), 0, buttonState, 1f, 1f, + 0, 0, 0, 0), + + MotionEvent.obtain( + initialTime + 400L + doubleTapTimeout, initialTime + 500L + doubleTapTimeout, + MotionEvent.ACTION_DOWN, 1, arrayOf(pointerProperties), + arrayOf(pointerCoords), 0, buttonState, 1f, 1f, + 0, 0, 0, 0), + + MotionEvent.obtain( + initialTime + 600L + doubleTapTimeout, initialTime + 700L + doubleTapTimeout, + MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties), + arrayOf(pointerCoords), 0, buttonState, 1f, 1f, + 0, 0, 0, 0) + ) + } else { + + 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 + 300L, + MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties), + arrayOf(pointerCoords), 0, buttonState, 1f, 1f, + 0, 0, 0, 0), + + MotionEvent.obtain( + initialTime + 400L, initialTime + 500L, + MotionEvent.ACTION_DOWN, 1, arrayOf(pointerProperties), + arrayOf(pointerCoords), 0, buttonState, 1f, 1f, + 0, 0, 0, 0), + + MotionEvent.obtain( + initialTime + 600L, initialTime + 700L, + MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties), + arrayOf(pointerCoords), 0, buttonState, 1f, 1f, + 0, 0, 0, 0) + ) + } +} + + +fun getSingleTapSequence(x: Float, y: Float, toolType: Int = MotionEvent.TOOL_TYPE_FINGER, buttonState: Int = 0) + : 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() + 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 + 100L, + 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) +} 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 index 67f14b22d..c89404843 100644 --- 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 @@ -14,19 +14,27 @@ import android.view.ViewGroup import android.view.Window import android.view.Window.Callback import androidx.test.ext.junit.runners.AndroidJUnit4 +import fastForwardDoubleTapTimeout +import getDoubleTapSequence +import getSingleTapSequence 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.OpenTelemetryRum 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.VIEW_CLICK_EVENT_NAME import io.opentelemetry.android.session.SessionProvider +import io.opentelemetry.api.common.AttributeKey.longKey +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 @@ -34,6 +42,7 @@ import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_CO 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 @@ -62,6 +71,10 @@ class ViewClickInstrumentationTest { MockKAnnotations.init(this, relaxUnitFun = true) } + private val clicksKey = longKey(HARDWARE_POINTER_CLICKS) + private val toolTypeKey = stringKey(HARDWARE_POINTER_TYPE) + private val buttonKey = stringKey(HARDWARE_POINTER_BUTTON) + @Test fun capture_view_click() { val openTelemetryRum = mockk { @@ -87,8 +100,9 @@ class ViewClickInstrumentationTest { val wrapperCapturingSlot = slot() every { window.callback = any() } returns Unit - val motionEvent = - MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + val singleTapSequence = getSingleTapSequence(250f, 50f) + val motionEvent = singleTapSequence[0] + val mockView = mockView(10012, motionEvent) every { window.decorView } returns mockView @@ -98,8 +112,12 @@ class ViewClickInstrumentationTest { } wrapperCapturingSlot.captured.dispatchTouchEvent( - motionEvent, + singleTapSequence[0], + ) + wrapperCapturingSlot.captured.dispatchTouchEvent( + singleTapSequence[1], ) + fastForwardDoubleTapTimeout() val events = openTelemetryRule.logRecords assertThat(events).hasSize(2) @@ -110,6 +128,8 @@ class ViewClickInstrumentationTest { .hasAttributesSatisfyingExactly( equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + equalTo(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "finger") ) event = events[1] @@ -120,6 +140,8 @@ class ViewClickInstrumentationTest { equalTo(APP_SCREEN_COORDINATE_Y, mockView.y.toLong()), equalTo(APP_WIDGET_ID, mockView.id.toString()), equalTo(APP_WIDGET_NAME, "10012"), + equalTo(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "finger") ) } @@ -147,8 +169,8 @@ class ViewClickInstrumentationTest { val wrapperCapturingSlot = slot() every { window.callback = any() } returns Unit - val motionEvent = - MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + val singleTapSequence = getSingleTapSequence(250f, 50f) + val motionEvent = singleTapSequence[0] val mockView = mockView(10012, motionEvent) val mockViewGroup = mockView(10013, motionEvent, clickable = false) { @@ -163,9 +185,10 @@ class ViewClickInstrumentationTest { window.callback = capture(wrapperCapturingSlot) } - wrapperCapturingSlot.captured.dispatchTouchEvent( - motionEvent, - ) + wrapperCapturingSlot.captured.dispatchTouchEvent(singleTapSequence[0]) + + wrapperCapturingSlot.captured.dispatchTouchEvent(singleTapSequence[1]) + fastForwardDoubleTapTimeout() val events = openTelemetryRule.logRecords assertThat(events).hasSize(2) @@ -176,6 +199,8 @@ class ViewClickInstrumentationTest { .hasAttributesSatisfyingExactly( equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + equalTo(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "finger") ) event = events[1] @@ -186,6 +211,8 @@ class ViewClickInstrumentationTest { equalTo(APP_SCREEN_COORDINATE_Y, mockView.y.toLong()), equalTo(APP_WIDGET_ID, mockView.id.toString()), equalTo(APP_WIDGET_NAME, "10012"), + equalTo(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "finger") ) } @@ -213,8 +240,8 @@ class ViewClickInstrumentationTest { val wrapperCapturingSlot = slot() every { window.callback = any() } returns Unit - val motionEvent = - MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + val singleTapSequence = getSingleTapSequence(250f, 50f) + val motionEvent = singleTapSequence[0] val mockView = mockView(10012, motionEvent, hitOffset = intArrayOf(50, 30)) val mockViewGroup = mockView(10013, motionEvent, clickable = false) { @@ -230,8 +257,12 @@ class ViewClickInstrumentationTest { } wrapperCapturingSlot.captured.dispatchTouchEvent( - motionEvent, + singleTapSequence[0], ) + wrapperCapturingSlot.captured.dispatchTouchEvent( + singleTapSequence[1], + ) + fastForwardDoubleTapTimeout() val events = openTelemetryRule.logRecords assertThat(events).hasSize(1) @@ -242,6 +273,8 @@ class ViewClickInstrumentationTest { .hasAttributesSatisfyingExactly( equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + equalTo(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "finger") ) } @@ -285,37 +318,413 @@ class ViewClickInstrumentationTest { 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) + @Test + fun capture_view_double_tap() { + + 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 - location[0] = (motionEvent.x + hitOffset[0]).toInt() - location[1] = (motionEvent.y + hitOffset[1]).toInt() + ViewClickInstrumentation().install(application, openTelemetryRum) - val arrayCapturingSlot = slot() - every { mockView.getLocationInWindow(capture(arrayCapturingSlot)) } answers { - arrayCapturingSlot.captured[0] = location[0] - arrayCapturingSlot.captured[1] = location[1] + verify { + application.registerActivityLifecycleCallbacks(capture(callbackCapturingSlot)) } - every { mockView.x } returns location[0].toFloat() - every { mockView.y } returns location[1].toFloat() + val viewClickActivityCallback = callbackCapturingSlot.captured + val wrapperCapturingSlot = slot() + every { window.callback = any() } returns Unit + + val doubleTapSequence = getDoubleTapSequence(250f, 50f) + val initialDownEvent = doubleTapSequence[0] + + val mockView = mockView(10012, initialDownEvent) + every { window.decorView } returns mockView - every { mockView.width } returns (location[0] + hitOffset[0]) - every { mockView.height } returns (location[1] + hitOffset[1]) - applyOthers.invoke(mockView) + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } - return mockView + for(motionEvent in doubleTapSequence) { + wrapperCapturingSlot.captured.dispatchTouchEvent(motionEvent) + } + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, initialDownEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, initialDownEvent.y.toLong()), + equalTo(clicksKey, 2.toLong()), + equalTo(toolTypeKey, "finger") + ) + + event = events[1] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(VIEW_CLICK_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(clicksKey, 2.toLong()), + equalTo(toolTypeKey, "finger") + ) + } + + @Test + fun capture_view_double_tap_button_state() { + + 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 doubleTapSequence = getDoubleTapSequence(250f, 50f, MotionEvent.TOOL_TYPE_MOUSE, MotionEvent.BUTTON_PRIMARY) + val initialDownEvent = doubleTapSequence[0] + + val mockView = mockView(10012, initialDownEvent) + every { window.decorView } returns mockView + + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } + + for(motionEvent in doubleTapSequence) { + wrapperCapturingSlot.captured.dispatchTouchEvent(motionEvent) + } + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, initialDownEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, initialDownEvent.y.toLong()), + equalTo(clicksKey, 2.toLong()), + equalTo(toolTypeKey, "mouse"), + equalTo(buttonKey, "primary") + ) + + event = events[1] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(VIEW_CLICK_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(clicksKey, 2.toLong()), + equalTo(toolTypeKey, "mouse"), + equalTo(buttonKey, "primary") + ) + } + + @Test + fun capture_view_single_tap_button_state() { + + 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 singleTapSequence = getSingleTapSequence(250f, 50f, MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.BUTTON_STYLUS_SECONDARY) + val motionEvent = singleTapSequence[0] + + val mockView = mockView(10012, motionEvent) + every { window.decorView } returns mockView + + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } + + wrapperCapturingSlot.captured.dispatchTouchEvent(singleTapSequence[0]) + wrapperCapturingSlot.captured.dispatchTouchEvent(singleTapSequence[1]) + fastForwardDoubleTapTimeout() + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] + 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()), + equalTo(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "stylus"), + equalTo(buttonKey, "secondary") + ) + + event = events[1] + assertThat(event) + .hasEventName(VIEW_CLICK_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(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "stylus"), + equalTo(buttonKey, "secondary") + ) + } + + @Test + fun capture_view_single_tap_when_double_tap_timeout_exceeded() { + + 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 doubleTapSequence = getDoubleTapSequence(250f, 50f, exceedTimeOut = true) + val initialDownEvent = doubleTapSequence[0] + + val mockView = mockView(10012, initialDownEvent) + every { window.decorView } returns mockView + + viewClickActivityCallback.onActivityResumed(activity) + verify { + window.callback = capture(wrapperCapturingSlot) + } + + wrapperCapturingSlot.captured.dispatchTouchEvent(doubleTapSequence[0]) + wrapperCapturingSlot.captured.dispatchTouchEvent(doubleTapSequence[1]) + fastForwardDoubleTapTimeout() + wrapperCapturingSlot.captured.dispatchTouchEvent(doubleTapSequence[2]) + wrapperCapturingSlot.captured.dispatchTouchEvent(doubleTapSequence[3]) + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + + var event = events[0] + assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, initialDownEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, initialDownEvent.y.toLong()), + equalTo(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "finger") + ) + + event = events[1] + assertThat(event) + .hasEventName(VIEW_CLICK_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(clicksKey, 1.toLong()), + equalTo(toolTypeKey, "finger") + ) + } + + @Test + fun not_captured_view_double_tap_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 doubleTapSequence = getDoubleTapSequence(250f, 50f) + val initialDownEvent = doubleTapSequence[0] + + val mockView = mockView(10012, initialDownEvent, hitOffset = intArrayOf(50, 30)) + 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) + } + + for(motionEvent in doubleTapSequence) { + wrapperCapturingSlot.captured.dispatchTouchEvent(motionEvent) + } + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(1) + + val event = events[0] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, initialDownEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, initialDownEvent.y.toLong()), + equalTo(clicksKey, 2.toLong()), + equalTo(toolTypeKey, "finger") + ) + } + + @Test + fun capture_view_double_tap_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 doubleTapSequence = getDoubleTapSequence(250f, 50f) + val initialDownEvent = doubleTapSequence[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) + } + + for (motionEvent in doubleTapSequence) { + wrapperCapturingSlot.captured.dispatchTouchEvent(motionEvent) + } + + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, initialDownEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, initialDownEvent.y.toLong()), + equalTo(clicksKey, 2.toLong()), + equalTo(toolTypeKey, "finger") + ) + + event = events[1] + OpenTelemetryAssertions.assertThat(event) + .hasEventName(VIEW_CLICK_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(clicksKey, 2.toLong()), + equalTo(toolTypeKey, "finger") + ) } }