Skip to content

Commit 109de7a

Browse files
authored
Capture click events for non-compose views (#953)
* add view click capturing via Window.Callback * use abstract class for default callback * refactor * use `RequiresApi` on method and change coordinate type to long * add consumer-rules.pro
1 parent 4096757 commit 109de7a

8 files changed

Lines changed: 608 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
plugins {
2+
id("otel.android-library-conventions")
3+
id("otel.publish-conventions")
4+
}
5+
6+
description = "OpenTelemetry Android View click library instrumentation"
7+
8+
android {
9+
namespace = "io.opentelemetry.android.instrumentation.view.click"
10+
11+
defaultConfig {
12+
consumerProguardFiles("consumer-rules.pro")
13+
}
14+
}
15+
16+
dependencies {
17+
api(project(":services"))
18+
api(libs.opentelemetry.api)
19+
api(platform(libs.opentelemetry.platform.alpha))
20+
api(project(":instrumentation:android-instrumentation"))
21+
22+
implementation(libs.opentelemetry.instrumentation.apiSemconv)
23+
implementation(libs.opentelemetry.api.incubator)
24+
25+
testImplementation(project(":test-common"))
26+
testImplementation(project(":session"))
27+
testImplementation(libs.robolectric)
28+
testImplementation(libs.androidx.test.core)
29+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# used in reflection to check if compose is available at runtime
2+
-keepnames class androidx.compose.ui.platform.ComposeView
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.android.instrumentation.view.click
7+
8+
import android.app.Activity
9+
import io.opentelemetry.android.internal.services.visiblescreen.activities.DefaultingActivityLifecycleCallbacks
10+
11+
class ViewClickActivityCallback(
12+
private val viewClickEventGenerator: ViewClickEventGenerator,
13+
) : DefaultingActivityLifecycleCallbacks {
14+
override fun onActivityResumed(activity: Activity) {
15+
super.onActivityResumed(activity)
16+
viewClickEventGenerator.startTracking(activity.window)
17+
}
18+
19+
override fun onActivityPaused(activity: Activity) {
20+
super.onActivityPaused(activity)
21+
viewClickEventGenerator.stopTracking()
22+
}
23+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.android.instrumentation.view.click
7+
8+
import android.view.MotionEvent
9+
import android.view.View
10+
import android.view.ViewGroup
11+
import android.view.Window
12+
import io.opentelemetry.android.instrumentation.view.click.internal.APP_SCREEN_CLICK_EVENT_NAME
13+
import io.opentelemetry.android.instrumentation.view.click.internal.VIEW_CLICK_EVENT_NAME
14+
import io.opentelemetry.android.instrumentation.view.click.internal.viewIdAttr
15+
import io.opentelemetry.android.instrumentation.view.click.internal.viewNameAttr
16+
import io.opentelemetry.android.instrumentation.view.click.internal.xCoordinateAttr
17+
import io.opentelemetry.android.instrumentation.view.click.internal.yCoordinateAttr
18+
import io.opentelemetry.api.common.Attributes
19+
import io.opentelemetry.api.incubator.logs.ExtendedLogRecordBuilder
20+
import io.opentelemetry.api.incubator.logs.ExtendedLogger
21+
import java.lang.ref.WeakReference
22+
import java.util.LinkedList
23+
24+
class ViewClickEventGenerator(
25+
private val eventLogger: ExtendedLogger,
26+
) {
27+
private var windowRef: WeakReference<Window>? = null
28+
29+
private val viewCoordinates = IntArray(2)
30+
31+
fun startTracking(window: Window) {
32+
windowRef = WeakReference(window)
33+
val currentCallback = window.callback
34+
window.callback = WindowCallbackWrapper(currentCallback, this)
35+
}
36+
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)
41+
.setAttribute(yCoordinateAttr, motionEvent.y.toLong())
42+
.setAttribute(xCoordinateAttr, motionEvent.x.toLong())
43+
.emit()
44+
45+
findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view ->
46+
createEvent(VIEW_CLICK_EVENT_NAME)
47+
.setAllAttributes(createViewAttributes(view))
48+
.emit()
49+
}
50+
}
51+
}
52+
}
53+
54+
fun stopTracking() {
55+
windowRef?.get()?.run {
56+
if (callback is WindowCallbackWrapper) {
57+
callback = (callback as WindowCallbackWrapper).unwrap()
58+
}
59+
}
60+
windowRef = null
61+
}
62+
63+
private fun createEvent(name: String): ExtendedLogRecordBuilder =
64+
eventLogger
65+
.logRecordBuilder()
66+
.setEventName(name)
67+
68+
private fun createViewAttributes(view: View): Attributes {
69+
val builder = Attributes.builder()
70+
builder.put(viewNameAttr, viewToName(view))
71+
builder.put(viewIdAttr, view.id.toLong())
72+
73+
builder.put(xCoordinateAttr, view.x.toLong())
74+
builder.put(yCoordinateAttr, view.y.toLong())
75+
return builder.build()
76+
}
77+
78+
private fun viewToName(view: View): String =
79+
try {
80+
view.resources?.getResourceEntryName(view.id) ?: view.id.toString()
81+
} catch (throwable: Throwable) {
82+
view.id.toString()
83+
}
84+
85+
private fun findTargetForTap(
86+
decorView: View,
87+
x: Float,
88+
y: Float,
89+
): View? {
90+
val queue = LinkedList<View>()
91+
queue.addFirst(decorView)
92+
var target: View? = null
93+
94+
while (queue.isNotEmpty()) {
95+
val view = queue.removeFirst()
96+
if (isJetpackComposeView(view)) {
97+
return null
98+
}
99+
100+
if (isValidClickTarget(view)) {
101+
target = view
102+
}
103+
104+
if (view is ViewGroup) {
105+
handleViewGroup(view, x, y, queue)
106+
}
107+
}
108+
return target
109+
}
110+
111+
private fun isValidClickTarget(view: View): Boolean = view.isClickable && view.isVisible
112+
113+
private fun handleViewGroup(
114+
view: ViewGroup,
115+
x: Float,
116+
y: Float,
117+
stack: LinkedList<View>,
118+
) {
119+
if (!view.isVisible) return
120+
121+
for (i in 0 until view.childCount) {
122+
val child = view.getChildAt(i)
123+
if (hitTest(child, x, y) && !isJetpackComposeView(child)) {
124+
stack.add(child)
125+
}
126+
}
127+
}
128+
129+
private fun hitTest(
130+
view: View,
131+
x: Float,
132+
y: Float,
133+
): Boolean {
134+
view.getLocationInWindow(viewCoordinates)
135+
val vx = viewCoordinates[0]
136+
val vy = viewCoordinates[1]
137+
138+
val w = view.width
139+
val h = view.height
140+
return !(x < vx || x > vx + w || y < vy || y > vy + h)
141+
}
142+
143+
private fun isJetpackComposeView(view: View): Boolean = view::class.java.name.startsWith("androidx.compose.ui.platform.ComposeView")
144+
145+
private val View.isVisible: Boolean
146+
get() = visibility == View.VISIBLE
147+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.android.instrumentation.view.click
7+
8+
import com.google.auto.service.AutoService
9+
import io.opentelemetry.android.instrumentation.AndroidInstrumentation
10+
import io.opentelemetry.android.instrumentation.InstallationContext
11+
import io.opentelemetry.api.incubator.logs.ExtendedLogger
12+
13+
@AutoService(AndroidInstrumentation::class)
14+
class ViewClickInstrumentation : AndroidInstrumentation {
15+
override val name: String = "view.click"
16+
17+
override fun install(ctx: InstallationContext) {
18+
ctx.application.registerActivityLifecycleCallbacks(
19+
ViewClickActivityCallback(
20+
ViewClickEventGenerator(
21+
ctx.openTelemetry
22+
.logsBridge
23+
.loggerBuilder("io.opentelemetry.android.instrumentation.view.click")
24+
.build() as ExtendedLogger,
25+
),
26+
),
27+
)
28+
}
29+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.android.instrumentation.view.click
7+
8+
import android.os.Build.VERSION_CODES
9+
import android.view.ActionMode
10+
import android.view.MotionEvent
11+
import android.view.SearchEvent
12+
import android.view.Window.Callback
13+
import androidx.annotation.RequiresApi
14+
15+
class WindowCallbackWrapper(
16+
private val callback: Callback,
17+
private val viewClickEventGenerator: ViewClickEventGenerator,
18+
) : Callback by callback {
19+
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
20+
viewClickEventGenerator.generateClick(event)
21+
return callback.dispatchTouchEvent(event)
22+
}
23+
24+
@RequiresApi(api = VERSION_CODES.M)
25+
override fun onSearchRequested(searchEvent: SearchEvent?): Boolean = callback.onSearchRequested(searchEvent)
26+
27+
@RequiresApi(api = VERSION_CODES.M)
28+
override fun onWindowStartingActionMode(
29+
callback: ActionMode.Callback?,
30+
type: Int,
31+
): ActionMode? = this.callback.onWindowStartingActionMode(callback, type)
32+
33+
fun unwrap(): Callback = callback
34+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.android.instrumentation.view.click.internal
7+
8+
import io.opentelemetry.api.common.AttributeKey
9+
import io.opentelemetry.api.common.AttributeKey.longKey
10+
import io.opentelemetry.api.common.AttributeKey.stringKey
11+
12+
const val APP_SCREEN_CLICK_EVENT_NAME = "app.screen.click"
13+
const val VIEW_CLICK_EVENT_NAME = "event.app.widget.click"
14+
val viewNameAttr: AttributeKey<String> = stringKey("app.widget.name")
15+
16+
val xCoordinateAttr: AttributeKey<Long> = longKey("app.screen.coordinate.x")
17+
val yCoordinateAttr: AttributeKey<Long> = longKey("app.screen.coordinate.y")
18+
val viewIdAttr: AttributeKey<Long> = longKey("app.widget.id")

0 commit comments

Comments
 (0)