Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
29 changes: 29 additions & 0 deletions instrumentation/view-click/build.gradle.kts
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)
}
2 changes: 2 additions & 0 deletions instrumentation/view-click/consumer-rules.pro
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)
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.

The underlying coordinates are Longs - why not go with a Pair<Long, Long>?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

view.getLocationInWindow API demands it.


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
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)) {
Comment thread
marandaneto marked this conversation as resolved.
stack.add(child)
}
}
}

private fun hitTest(
view: View,
x: Float,
y: Float,
): Boolean {
view.getLocationInWindow(viewCoordinates)
Comment thread
marandaneto marked this conversation as resolved.
Comment thread
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")
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.

if minify is enabled, this might fail? is androidx.compose.ui.platform.ComposeView minified or an exception (Added to proguard etc?)

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

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.

that's good, let's just add this to the consumer-rules exception then so we can validate isJetpackComposeView even if minify is enabled?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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?

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.

yes otherwise androidx.compose.ui.platform.ComposeView is gonna be minified and this condition will always return false

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.

What if there's another class that implements AbstractComposeView directly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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(
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.

You may just be able to annotate the class rather than the methods so it'll detect the creation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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")
Loading