Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@

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.VIEW_CLICK_EVENT_NAME
import io.opentelemetry.android.instrumentation.view.click.internal.buttonStateToString
import io.opentelemetry.android.instrumentation.view.click.internal.toolTypeToString
import io.opentelemetry.api.common.Attributes
import io.opentelemetry.api.logs.LogRecordBuilder
import io.opentelemetry.api.logs.Logger
Expand All @@ -26,28 +32,90 @@ internal class ViewClickEventGenerator(
) {
private var windowRef: WeakReference<Window>? = 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 toolTypeInt = motionEvent.getToolType(0)
val toolType = toolTypeToString(toolTypeInt)

val appEvent = createEvent(APP_SCREEN_CLICK_EVENT_NAME)
.setAttribute(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong())
.setAttribute(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong())
.emit()
.setAttribute(HARDWARE_POINTER_CLICKS, 2)
.setAttribute(HARDWARE_POINTER_TYPE, toolType)

val buttonStateInt = motionEvent.buttonState
val toolTypeHasButtons = toolTypeInt == MotionEvent.TOOL_TYPE_MOUSE || toolTypeInt == MotionEvent.TOOL_TYPE_STYLUS
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider driving this and other information via an enum in ViewUtils as that will reduce the amount of logic in this class. Here's a non-exhaustive example of what I mean:

enum class TapEvent(
    val motionEvent: Int,
    val toolType: Int,
) {


    val toolTypeDescription: String // derive from MotionEvent.TOOL_TYPE_*
    val hasButtons: Boolean = toolTypeInt == MotionEvent.TOOL_TYPE_MOUSE || toolTypeInt == MotionEvent.TOOL_TYPE_STYLUS
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I've applied the other suggestions. I'll look into this and give my feedback by Wednesday or Thursday

val isButtonPrimary = buttonStateInt == MotionEvent.BUTTON_PRIMARY || buttonStateInt == MotionEvent.BUTTON_STYLUS_PRIMARY
if(toolTypeHasButtons && !isButtonPrimary) {
return false
}
val buttonState = buttonStateToString(buttonStateInt)

if(buttonState != null) {
Comment thread
DavidGrath marked this conversation as resolved.
Outdated
appEvent.setAttribute(HARDWARE_POINTER_BUTTON, buttonState)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we need to restrict it to primary given we already have an attribute to indicate which button.

Suggested change
val buttonStateInt = motionEvent.buttonState
val toolTypeHasButtons = toolTypeInt == MotionEvent.TOOL_TYPE_MOUSE || toolTypeInt == MotionEvent.TOOL_TYPE_STYLUS
val isButtonPrimary = buttonStateInt == MotionEvent.BUTTON_PRIMARY || buttonStateInt == MotionEvent.BUTTON_STYLUS_PRIMARY
if(toolTypeHasButtons && !isButtonPrimary) {
return false
}
val buttonState = buttonStateToString(buttonStateInt)
if(buttonState != null) {
appEvent.setAttribute(HARDWARE_POINTER_BUTTON, buttonState)
}
val toolTypeHasButtons = toolTypeInt == MotionEvent.TOOL_TYPE_MOUSE || toolTypeInt == MotionEvent.TOOL_TYPE_STYLUS
val buttonState = null
if(toolTypeHasButtons) {
val buttonStateInt = motionEvent.buttonState
buttonState = buttonStateToString(buttonStateInt)
}
if(buttonState != null) {
appEvent.setAttribute(HARDWARE_POINTER_BUTTON, buttonState)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I'll need to delete the corresponding test I wrote for this as well

Copy link
Copy Markdown
Contributor

@thompson-tomo thompson-tomo Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, note the change should be replicated to single click.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


appEvent.emit()

findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view ->
createEvent(VIEW_CLICK_EVENT_NAME)
.setAllAttributes(createViewAttributes(view))
.emit()
}

}
return false
}

override fun onSingleTapConfirmed(motionEvent: MotionEvent): Boolean {
windowRef?.get()?.let { window ->

val toolTypeInt = motionEvent.getToolType(0)
val toolType = toolTypeToString(toolTypeInt)

val appEvent = createEvent(APP_SCREEN_CLICK_EVENT_NAME)
.setAttribute(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong())
.setAttribute(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong())
.setAttribute(HARDWARE_POINTER_CLICKS, 1)
.setAttribute(HARDWARE_POINTER_TYPE, toolType)


val buttonStateInt = motionEvent.buttonState
val buttonState = buttonStateToString(buttonStateInt)
if(buttonState != null) {
Comment thread
DavidGrath marked this conversation as resolved.
Outdated
appEvent.setAttribute(HARDWARE_POINTER_BUTTON, buttonState)
}

appEvent.emit()

findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view ->
createEvent(VIEW_CLICK_EVENT_NAME)
.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) {
Comment thread
DavidGrath marked this conversation as resolved.
Outdated
gestureDetector.onTouchEvent(motionEvent)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,34 @@

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"
}
}
166 changes: 166 additions & 0 deletions instrumentation/view-click/src/test/kotlin/TestUtils.kt
Original file line number Diff line number Diff line change
@@ -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 <reified T : View> 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<IntArray>()
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<MotionEvent> {

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<MotionEvent> {
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)
}
Loading
Loading