-
Notifications
You must be signed in to change notification settings - Fork 100
Capture click events for non-compose views #953
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e95b488
4e7b790
5503e0d
7ece584
0d87827
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| plugins { | ||
| id("otel.android-library-conventions") | ||
| id("otel.publish-conventions") | ||
| } | ||
|
|
||
| description = "OpenTelemetry Android View click library instrumentation" | ||
|
|
||
| android { | ||
| namespace = "io.opentelemetry.android.instrumentation.view.click" | ||
|
|
||
| defaultConfig { | ||
| consumerProguardFiles("consumer-rules.pro") | ||
| } | ||
| } | ||
|
|
||
| dependencies { | ||
| api(project(":services")) | ||
| api(libs.opentelemetry.api) | ||
| api(platform(libs.opentelemetry.platform.alpha)) | ||
| api(project(":instrumentation:android-instrumentation")) | ||
|
|
||
| implementation(libs.opentelemetry.instrumentation.apiSemconv) | ||
| implementation(libs.opentelemetry.api.incubator) | ||
|
|
||
| testImplementation(project(":test-common")) | ||
| testImplementation(project(":session")) | ||
| testImplementation(libs.robolectric) | ||
| testImplementation(libs.androidx.test.core) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # used in reflection to check if compose is available at runtime | ||
| -keepnames class androidx.compose.ui.platform.ComposeView |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.android.instrumentation.view.click | ||
|
|
||
| import android.app.Activity | ||
| import io.opentelemetry.android.internal.services.visiblescreen.activities.DefaultingActivityLifecycleCallbacks | ||
|
|
||
| class ViewClickActivityCallback( | ||
| private val viewClickEventGenerator: ViewClickEventGenerator, | ||
| ) : DefaultingActivityLifecycleCallbacks { | ||
| override fun onActivityResumed(activity: Activity) { | ||
| super.onActivityResumed(activity) | ||
| viewClickEventGenerator.startTracking(activity.window) | ||
| } | ||
|
|
||
| override fun onActivityPaused(activity: Activity) { | ||
| super.onActivityPaused(activity) | ||
| viewClickEventGenerator.stopTracking() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.android.instrumentation.view.click | ||
|
|
||
| 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.VIEW_CLICK_EVENT_NAME | ||
| import io.opentelemetry.android.instrumentation.view.click.internal.viewIdAttr | ||
| import io.opentelemetry.android.instrumentation.view.click.internal.viewNameAttr | ||
| import io.opentelemetry.android.instrumentation.view.click.internal.xCoordinateAttr | ||
| import io.opentelemetry.android.instrumentation.view.click.internal.yCoordinateAttr | ||
| import io.opentelemetry.api.common.Attributes | ||
| import io.opentelemetry.api.incubator.logs.ExtendedLogRecordBuilder | ||
| import io.opentelemetry.api.incubator.logs.ExtendedLogger | ||
| import java.lang.ref.WeakReference | ||
| import java.util.LinkedList | ||
|
|
||
| class ViewClickEventGenerator( | ||
| private val eventLogger: ExtendedLogger, | ||
| ) { | ||
| private var windowRef: WeakReference<Window>? = null | ||
|
|
||
| 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?) { | ||
| windowRef?.get()?.let { window -> | ||
| if (motionEvent != null && motionEvent.actionMasked == MotionEvent.ACTION_UP) { | ||
| createEvent(APP_SCREEN_CLICK_EVENT_NAME) | ||
| .setAttribute(yCoordinateAttr, motionEvent.y.toLong()) | ||
| .setAttribute(xCoordinateAttr, motionEvent.x.toLong()) | ||
| .emit() | ||
|
|
||
| findTargetForTap(window.decorView, motionEvent.x, motionEvent.y)?.let { view -> | ||
| createEvent(VIEW_CLICK_EVENT_NAME) | ||
| .setAllAttributes(createViewAttributes(view)) | ||
| .emit() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fun stopTracking() { | ||
| windowRef?.get()?.run { | ||
| if (callback is WindowCallbackWrapper) { | ||
| callback = (callback as WindowCallbackWrapper).unwrap() | ||
| } | ||
| } | ||
| windowRef = null | ||
| } | ||
|
|
||
| private fun createEvent(name: String): ExtendedLogRecordBuilder = | ||
| eventLogger | ||
| .logRecordBuilder() | ||
| .setEventName(name) | ||
|
|
||
| private fun createViewAttributes(view: View): Attributes { | ||
| val builder = Attributes.builder() | ||
| builder.put(viewNameAttr, viewToName(view)) | ||
| builder.put(viewIdAttr, view.id.toLong()) | ||
|
|
||
| builder.put(xCoordinateAttr, view.x.toLong()) | ||
| builder.put(yCoordinateAttr, view.y.toLong()) | ||
| return builder.build() | ||
| } | ||
|
|
||
| private fun viewToName(view: View): String = | ||
| try { | ||
| view.resources?.getResourceEntryName(view.id) ?: view.id.toString() | ||
| } catch (throwable: Throwable) { | ||
| view.id.toString() | ||
| } | ||
|
|
||
| private fun findTargetForTap( | ||
| decorView: View, | ||
| x: Float, | ||
| y: Float, | ||
| ): View? { | ||
| val queue = LinkedList<View>() | ||
| queue.addFirst(decorView) | ||
| var target: View? = null | ||
|
|
||
| while (queue.isNotEmpty()) { | ||
| val view = queue.removeFirst() | ||
| if (isJetpackComposeView(view)) { | ||
| return null | ||
| } | ||
|
|
||
| if (isValidClickTarget(view)) { | ||
| target = view | ||
| } | ||
|
|
||
| if (view is ViewGroup) { | ||
| handleViewGroup(view, x, y, queue) | ||
| } | ||
| } | ||
| return target | ||
| } | ||
|
|
||
| private fun isValidClickTarget(view: View): Boolean = view.isClickable && view.isVisible | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this can be a bit more clever. not sure if needed though.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no need for that. |
||
|
|
||
| private fun handleViewGroup( | ||
| view: ViewGroup, | ||
| x: Float, | ||
| y: Float, | ||
| stack: LinkedList<View>, | ||
| ) { | ||
| if (!view.isVisible) return | ||
|
|
||
| for (i in 0 until view.childCount) { | ||
| val child = view.getChildAt(i) | ||
| if (hitTest(child, x, y) && !isJetpackComposeView(child)) { | ||
|
marandaneto marked this conversation as resolved.
|
||
| stack.add(child) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun hitTest( | ||
| view: View, | ||
| x: Float, | ||
| y: Float, | ||
| ): Boolean { | ||
| view.getLocationInWindow(viewCoordinates) | ||
|
marandaneto marked this conversation as resolved.
marandaneto marked this conversation as resolved.
|
||
| val vx = viewCoordinates[0] | ||
| val vy = viewCoordinates[1] | ||
|
|
||
| val w = view.width | ||
| val h = view.height | ||
| return !(x < vx || x > vx + w || y < vy || y > vy + h) | ||
| } | ||
|
|
||
| private fun isJetpackComposeView(view: View): Boolean = view::class.java.name.startsWith("androidx.compose.ui.platform.ComposeView") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if minify is enabled, this might fail? is
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yea, the test will fail and result in traversing compose view tree. however, i don't see how an exception will result here.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's good, let's just add this to the consumer-rules exception then so we can validate
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure, I understand what you're asking. However, it sounds like you're saying I should create a consumer rules with this as the content?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes otherwise
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if there's another class that implements
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bidetofevil it looks like that doesn't exist in the Android compose framework yet and we can address them when that changes? |
||
|
|
||
| private val View.isVisible: Boolean | ||
| get() = visibility == View.VISIBLE | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.android.instrumentation.view.click | ||
|
|
||
| import com.google.auto.service.AutoService | ||
| import io.opentelemetry.android.instrumentation.AndroidInstrumentation | ||
| import io.opentelemetry.android.instrumentation.InstallationContext | ||
| import io.opentelemetry.api.incubator.logs.ExtendedLogger | ||
|
|
||
| @AutoService(AndroidInstrumentation::class) | ||
| class ViewClickInstrumentation : AndroidInstrumentation { | ||
| override val name: String = "view.click" | ||
|
|
||
| override fun install(ctx: InstallationContext) { | ||
| ctx.application.registerActivityLifecycleCallbacks( | ||
| ViewClickActivityCallback( | ||
| ViewClickEventGenerator( | ||
| ctx.openTelemetry | ||
| .logsBridge | ||
| .loggerBuilder("io.opentelemetry.android.instrumentation.view.click") | ||
| .build() as ExtendedLogger, | ||
| ), | ||
| ), | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.android.instrumentation.view.click | ||
|
|
||
| import android.os.Build.VERSION_CODES | ||
| import android.view.ActionMode | ||
| import android.view.MotionEvent | ||
| import android.view.SearchEvent | ||
| import android.view.Window.Callback | ||
| import androidx.annotation.RequiresApi | ||
|
|
||
| class WindowCallbackWrapper( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You may just be able to annotate the class rather than the methods so it'll detect the creation
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I remember trying that and it not appeasing the sniffer 🤷♂️ |
||
| private val callback: Callback, | ||
| private val viewClickEventGenerator: ViewClickEventGenerator, | ||
| ) : Callback by callback { | ||
| override fun dispatchTouchEvent(event: MotionEvent?): Boolean { | ||
| viewClickEventGenerator.generateClick(event) | ||
| return callback.dispatchTouchEvent(event) | ||
| } | ||
|
|
||
| @RequiresApi(api = VERSION_CODES.M) | ||
| override fun onSearchRequested(searchEvent: SearchEvent?): Boolean = callback.onSearchRequested(searchEvent) | ||
|
|
||
| @RequiresApi(api = VERSION_CODES.M) | ||
| override fun onWindowStartingActionMode( | ||
| callback: ActionMode.Callback?, | ||
| type: Int, | ||
| ): ActionMode? = this.callback.onWindowStartingActionMode(callback, type) | ||
|
|
||
| fun unwrap(): Callback = callback | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.android.instrumentation.view.click.internal | ||
|
|
||
| import io.opentelemetry.api.common.AttributeKey | ||
| import io.opentelemetry.api.common.AttributeKey.longKey | ||
| import io.opentelemetry.api.common.AttributeKey.stringKey | ||
|
|
||
| const val APP_SCREEN_CLICK_EVENT_NAME = "app.screen.click" | ||
| const val VIEW_CLICK_EVENT_NAME = "event.app.widget.click" | ||
| val viewNameAttr: AttributeKey<String> = stringKey("app.widget.name") | ||
|
|
||
| val xCoordinateAttr: AttributeKey<Long> = longKey("app.screen.coordinate.x") | ||
| val yCoordinateAttr: AttributeKey<Long> = longKey("app.screen.coordinate.y") | ||
| val viewIdAttr: AttributeKey<Long> = longKey("app.widget.id") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The underlying coordinates are Longs - why not go with a
Pair<Long, Long>?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
view.getLocationInWindowAPI demands it.