Skip to content

Commit a4c03a5

Browse files
Added basic double-tap, based off view click by cleverchuk. Addresses #1642 and #1576 (#1681)
* Added basic double-tap, based off view click * Made fixes to correct failures to checks * Ran apiDump * New hardware attributes: pointer, clicks, and button. Single tap and double tap both now rely on `GestureDetector` * Make detekt and apiDump happy * Removed primary button exclusivity from double-tap * Apply suggestions from code review Co-authored-by: Jamie Lynch <fractalwrench@gmail.com> * Tap Event logic moved to a class for deduplication. Hardware attributes now also present on view click. Other reviewed changes * Updated tests to reflect recent instrumentation stabilization changes --------- Co-authored-by: Jamie Lynch <fractalwrench@gmail.com>
1 parent 8fc8e7d commit a4c03a5

4 files changed

Lines changed: 719 additions & 49 deletions

File tree

instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/ViewClickEventGenerator.kt

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55

66
package io.opentelemetry.android.instrumentation.view.click
77

8+
import android.view.GestureDetector
89
import android.view.MotionEvent
910
import android.view.View
1011
import android.view.ViewGroup
1112
import android.view.Window
1213
import io.opentelemetry.android.instrumentation.view.click.internal.APP_SCREEN_CLICK_EVENT_NAME
14+
import io.opentelemetry.android.instrumentation.view.click.internal.HARDWARE_POINTER_BUTTON
15+
import io.opentelemetry.android.instrumentation.view.click.internal.HARDWARE_POINTER_CLICKS
16+
import io.opentelemetry.android.instrumentation.view.click.internal.HARDWARE_POINTER_TYPE
17+
import io.opentelemetry.android.instrumentation.view.click.internal.TapEvent
1318
import io.opentelemetry.android.instrumentation.view.click.internal.VIEW_CLICK_EVENT_NAME
1419
import io.opentelemetry.api.common.Attributes
1520
import io.opentelemetry.api.logs.LogRecordBuilder
@@ -26,28 +31,64 @@ internal class ViewClickEventGenerator(
2631
) {
2732
private var windowRef: WeakReference<Window>? = null
2833

29-
private val viewCoordinates = IntArray(2)
34+
private val gestureListener = object : GestureDetector.SimpleOnGestureListener() {
3035

31-
fun startTracking(window: Window) {
32-
windowRef = WeakReference(window)
33-
val currentCallback = window.callback
34-
window.callback = WindowCallbackWrapper(currentCallback, this)
35-
}
3636

37-
fun generateClick(motionEvent: MotionEvent?) {
38-
windowRef?.get()?.let { window ->
39-
if (motionEvent != null && motionEvent.actionMasked == MotionEvent.ACTION_UP) {
40-
createEvent(APP_SCREEN_CLICK_EVENT_NAME)
37+
override fun onDoubleTap(motionEvent: MotionEvent): Boolean {
38+
windowRef?.get()?.let { window ->
39+
40+
41+
val tapEvent = TapEvent(motionEvent)
42+
43+
createEvent(APP_SCREEN_CLICK_EVENT_NAME, tapEvent, 2)
44+
.setAttribute(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong())
45+
.setAttribute(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong())
46+
.emit()
47+
48+
findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view ->
49+
createEvent(VIEW_CLICK_EVENT_NAME, tapEvent, 2)
50+
.setAllAttributes(createViewAttributes(view))
51+
.emit()
52+
}
53+
54+
}
55+
return false
56+
}
57+
58+
override fun onSingleTapConfirmed(motionEvent: MotionEvent): Boolean {
59+
windowRef?.get()?.let { window ->
60+
61+
62+
val tapEvent = TapEvent(motionEvent)
63+
64+
createEvent(APP_SCREEN_CLICK_EVENT_NAME, tapEvent, 1)
4165
.setAttribute(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong())
4266
.setAttribute(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong())
4367
.emit()
4468

4569
findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view ->
46-
createEvent(VIEW_CLICK_EVENT_NAME)
70+
createEvent(VIEW_CLICK_EVENT_NAME, tapEvent, 1)
4771
.setAllAttributes(createViewAttributes(view))
4872
.emit()
4973
}
5074
}
75+
return false
76+
}
77+
}
78+
79+
val gestureDetector = GestureDetector(null, gestureListener)
80+
81+
private val viewCoordinates = IntArray(2)
82+
83+
fun startTracking(window: Window) {
84+
windowRef = WeakReference(window)
85+
val currentCallback = window.callback
86+
window.callback = WindowCallbackWrapper(currentCallback, this)
87+
}
88+
89+
fun generateClick(motionEvent: MotionEvent?) {
90+
if (motionEvent != null) {
91+
gestureDetector.onTouchEvent(motionEvent)
5192
}
5293
}
5394

@@ -60,10 +101,13 @@ internal class ViewClickEventGenerator(
60101
windowRef = null
61102
}
62103

63-
private fun createEvent(name: String): LogRecordBuilder =
104+
private fun createEvent(name: String, tapEvent: TapEvent, clicks: Int): LogRecordBuilder =
64105
eventLogger
65106
.logRecordBuilder()
66107
.setEventName(name)
108+
.setAttribute(HARDWARE_POINTER_CLICKS, clicks)
109+
.setAttribute(HARDWARE_POINTER_TYPE, tapEvent.toolTypeDescription)
110+
.setAttribute(HARDWARE_POINTER_BUTTON, tapEvent.buttonStateDescription)
67111

68112
private fun createViewAttributes(view: View): Attributes {
69113
val builder = Attributes.builder()

instrumentation/view-click/src/main/kotlin/io/opentelemetry/android/instrumentation/view/click/internal/ViewUtils.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,56 @@
55

66
package io.opentelemetry.android.instrumentation.view.click.internal
77

8+
import android.view.MotionEvent
9+
810
internal const val APP_SCREEN_CLICK_EVENT_NAME = "app.screen.click"
911
internal const val VIEW_CLICK_EVENT_NAME = "app.widget.click"
12+
13+
internal const val HARDWARE_POINTER_TYPE = "hw.pointer.type"
14+
15+
internal const val HARDWARE_POINTER_BUTTON = "hw.pointer.button"
16+
17+
internal const val HARDWARE_POINTER_CLICKS = "hw.pointer.clicks"
18+
19+
internal fun buttonStateToString(buttonStateInt: Int): String? {
20+
return when(buttonStateInt) {
21+
MotionEvent.BUTTON_PRIMARY, MotionEvent.BUTTON_STYLUS_PRIMARY -> "primary"
22+
MotionEvent.BUTTON_SECONDARY, MotionEvent.BUTTON_STYLUS_SECONDARY -> "secondary"
23+
MotionEvent.BUTTON_TERTIARY -> "tertiary"
24+
MotionEvent.BUTTON_BACK -> "back"
25+
MotionEvent.BUTTON_FORWARD -> "forward"
26+
else -> null
27+
}
28+
}
29+
30+
internal fun toolTypeToString(toolTypeInt: Int): String {
31+
return when(toolTypeInt) {
32+
MotionEvent.TOOL_TYPE_MOUSE -> "mouse"
33+
MotionEvent.TOOL_TYPE_FINGER -> "finger"
34+
MotionEvent.TOOL_TYPE_STYLUS -> "stylus"
35+
MotionEvent.TOOL_TYPE_ERASER -> "eraser"
36+
else -> "unknown"
37+
}
38+
}
39+
40+
internal class TapEvent(
41+
private val motionEvent: MotionEvent
42+
) {
43+
44+
val toolTypeDescription: String
45+
val buttonStateDescription: String?
46+
47+
init {
48+
val toolTypeInt = motionEvent.getToolType(0)
49+
val toolTypeHasButtons =
50+
toolTypeInt == MotionEvent.TOOL_TYPE_MOUSE || toolTypeInt == MotionEvent.TOOL_TYPE_STYLUS
51+
val buttonStateInt = motionEvent.buttonState
52+
if (toolTypeHasButtons) {
53+
buttonStateDescription = buttonStateToString(buttonStateInt)
54+
} else {
55+
buttonStateDescription = null
56+
}
57+
toolTypeDescription = toolTypeToString(toolTypeInt)
58+
}
59+
60+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import android.os.SystemClock
2+
import android.view.MotionEvent
3+
import android.view.View
4+
import android.view.ViewConfiguration
5+
import androidx.test.core.view.PointerCoordsBuilder
6+
import io.mockk.every
7+
import io.mockk.mockkClass
8+
import io.mockk.slot
9+
import org.robolectric.shadows.ShadowLooper
10+
import java.util.concurrent.TimeUnit
11+
12+
13+
inline fun <reified T : View> mockView(
14+
id: Int,
15+
motionEvent: MotionEvent,
16+
hitOffset: IntArray = intArrayOf(0, 0),
17+
clickable: Boolean = true,
18+
visibility: Int = View.VISIBLE,
19+
applyOthers: (T) -> Unit = {},
20+
): T {
21+
val mockView = mockkClass(T::class)
22+
every { mockView.visibility } returns visibility
23+
every { mockView.isClickable } returns clickable
24+
25+
every { mockView.id } returns id
26+
val location = IntArray(2)
27+
28+
location[0] = (motionEvent.x + hitOffset[0]).toInt()
29+
location[1] = (motionEvent.y + hitOffset[1]).toInt()
30+
31+
val arrayCapturingSlot = slot<IntArray>()
32+
every { mockView.getLocationInWindow(capture(arrayCapturingSlot)) } answers {
33+
arrayCapturingSlot.captured[0] = location[0]
34+
arrayCapturingSlot.captured[1] = location[1]
35+
}
36+
37+
every { mockView.x } returns location[0].toFloat()
38+
every { mockView.y } returns location[1].toFloat()
39+
40+
every { mockView.width } returns (location[0] + hitOffset[0])
41+
every { mockView.height } returns (location[1] + hitOffset[1])
42+
applyOthers.invoke(mockView)
43+
44+
return mockView
45+
}
46+
47+
private val allowedToolTypes = arrayOf(MotionEvent.TOOL_TYPE_FINGER, MotionEvent.TOOL_TYPE_MOUSE,
48+
MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER, MotionEvent.TOOL_TYPE_UNKNOWN)
49+
50+
private val allowedButtonStates = arrayOf(
51+
MotionEvent.BUTTON_PRIMARY, MotionEvent.BUTTON_STYLUS_PRIMARY,
52+
MotionEvent.BUTTON_SECONDARY, MotionEvent.BUTTON_STYLUS_SECONDARY,
53+
MotionEvent.BUTTON_TERTIARY,
54+
MotionEvent.BUTTON_BACK,
55+
MotionEvent.BUTTON_FORWARD
56+
)
57+
58+
fun getDoubleTapSequence(x: Float, y: Float, toolType: Int = MotionEvent.TOOL_TYPE_FINGER, buttonState: Int = 0,
59+
exceedTimeOut: Boolean = false): Array<MotionEvent> {
60+
61+
require(toolType in allowedToolTypes) { "Invalid tool type" }
62+
63+
if(buttonState != 0) {
64+
require(toolType == MotionEvent.TOOL_TYPE_MOUSE || toolType == MotionEvent.TOOL_TYPE_STYLUS) {
65+
"Invalid tool type for button state"
66+
}
67+
require(buttonState in allowedButtonStates) { "Invalid button state" }
68+
}
69+
70+
val initialTime = SystemClock.uptimeMillis()
71+
72+
val pointerProperties = MotionEvent.PointerProperties()
73+
pointerProperties.id = 0
74+
pointerProperties.toolType = toolType
75+
76+
val pointerCoords = PointerCoordsBuilder.newBuilder().setCoords(x, y).build()
77+
78+
if(exceedTimeOut) {
79+
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout()
80+
81+
return arrayOf(
82+
MotionEvent.obtain(initialTime, initialTime,
83+
MotionEvent.ACTION_DOWN, 1, arrayOf(pointerProperties),
84+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
85+
0, 0, 0, 0),
86+
MotionEvent.obtain(initialTime, initialTime + 300L,
87+
MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties),
88+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
89+
0, 0, 0, 0),
90+
91+
MotionEvent.obtain(
92+
initialTime + 400L + doubleTapTimeout, initialTime + 500L + doubleTapTimeout,
93+
MotionEvent.ACTION_DOWN, 1, arrayOf(pointerProperties),
94+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
95+
0, 0, 0, 0),
96+
97+
MotionEvent.obtain(
98+
initialTime + 600L + doubleTapTimeout, initialTime + 700L + doubleTapTimeout,
99+
MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties),
100+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
101+
0, 0, 0, 0)
102+
)
103+
} else {
104+
105+
return arrayOf(
106+
MotionEvent.obtain(initialTime, initialTime,
107+
MotionEvent.ACTION_DOWN, 1, arrayOf(pointerProperties),
108+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
109+
0, 0, 0, 0),
110+
MotionEvent.obtain(initialTime, initialTime + 300L,
111+
MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties),
112+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
113+
0, 0, 0, 0),
114+
115+
MotionEvent.obtain(
116+
initialTime + 400L, initialTime + 500L,
117+
MotionEvent.ACTION_DOWN, 1, arrayOf(pointerProperties),
118+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
119+
0, 0, 0, 0),
120+
121+
MotionEvent.obtain(
122+
initialTime + 600L, initialTime + 700L,
123+
MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties),
124+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
125+
0, 0, 0, 0)
126+
)
127+
}
128+
}
129+
130+
131+
fun getSingleTapSequence(x: Float, y: Float, toolType: Int = MotionEvent.TOOL_TYPE_FINGER, buttonState: Int = 0)
132+
: Array<MotionEvent> {
133+
require(toolType in allowedToolTypes) {
134+
"Invalid tool type"
135+
}
136+
137+
if(buttonState != 0) {
138+
require(toolType == MotionEvent.TOOL_TYPE_MOUSE || toolType == MotionEvent.TOOL_TYPE_STYLUS) {
139+
"Invalid tool type for button state"
140+
}
141+
require(buttonState in allowedButtonStates) { "Invalid button state" }
142+
}
143+
144+
val initialTime = SystemClock.uptimeMillis()
145+
146+
val pointerProperties = MotionEvent.PointerProperties()
147+
pointerProperties.id = 0
148+
pointerProperties.toolType = toolType
149+
150+
val pointerCoords = PointerCoordsBuilder.newBuilder().setCoords(x, y).build()
151+
return arrayOf(
152+
MotionEvent.obtain(initialTime, initialTime,
153+
MotionEvent.ACTION_DOWN, 1, arrayOf(pointerProperties),
154+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
155+
0, 0, 0, 0),
156+
157+
MotionEvent.obtain(initialTime, initialTime + 100L,
158+
MotionEvent.ACTION_UP, 1, arrayOf(pointerProperties),
159+
arrayOf(pointerCoords), 0, buttonState, 1f, 1f,
160+
0, 0, 0, 0)
161+
)
162+
}
163+
164+
fun fastForwardDoubleTapTimeout() {
165+
ShadowLooper.idleMainLooper(ViewConfiguration.getDoubleTapTimeout().toLong(), TimeUnit.MILLISECONDS)
166+
}

0 commit comments

Comments
 (0)