Blog Infos
Author
Published
Topics
Published
Jetpack Compose and Monitoring

The final outcome: Automatically turning clicks into breadcrumbs

Our Requirements for Declarative Programming Support
  1. Detect any clicks, swipes, or scrolls, globally
  2. Know which UI element a user interacted with
  3. Determine an identifier for the UI element and generate the corresponding breadcrumb
  4. Require minimal setup
Detecting clicks, scrolls, and swipes
  1. On Sentry SDK init, register a ActivityLifecycleCallbacks to get hold of the current visible Activity
  2. Retrieve the Window via Activity.getWindow()
  3. Set a Window.Callback using window.setCallback()
val previousCallback = window.getCallback() ?: EmptyCallback()
val newCallback = SentryWindowCallback(previousCallback)
window.setCallback(newCallback)

class SentryWindowCallback(val delegate: Window.Callback) : Window.Callback {
    override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
        // our logic ...

        return delegate.dispatchTouchEvent(event)
    }
}
Locating and identifying widgets
  1. Iterate the View Hierarchy, and find a matching View given the touch coordinates
  2. Retrieve the numeric View ID via view.getId()
  3. Translate the ID back to its resource name to get a readable identifier
fun coordinatesWithinBounds(view: View, x: Float, y: Float): Boolean {
    view.getLocationOnScreen(coordinates)
    val vx = coordinates[0]
    val vy = coordinates[1]
    
    val w = view.width
    val h = view.height
    
    return !(x < vx || x > vx + w || y < vy || y > vy + h);
}

fun isViewTappable(view: View) {
    return view.isClickable() && view.getVisibility() == View.VISIBLE
}

val x = motionEvent.getX()
val y = motionEvent.getY()

if (coordinatesWithinBounds(view, x, y) && isViewTappable(view)) {
    val viewId = view.getId()
    return view.getContext()
      .getResources()?
      .getResourceEntryName(viewId); // e.g. button_login
)

As Jetpack Compose UI is not using the Android System widgets, we can’t apply the same mechanism here. If you take a look at the Android layout hierarchy, all you get is one large AndroidComposeView which takes care of rendering your @Composables and acts as a bridge between the system and Jetpack Compose runtime.

Left: Traditional Android Layout, Right: Jetpack Compose UI

 

Our first approach was to use some Accessibility Services APIs to retrieve a description of an UI element at a specific location on the screen. The official documentation about semantics provided a good starting point, and we quickly found ourselves digging into AndroidComposeViewAccessibilityDelegateCompat.android.kt to understand better how it works under the hood.

// From <https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt>

/**
 * Hit test the layout tree for semantics wrappers.
 * The return value is a virtual view id, or InvalidId if an embedded Android View was hit.
 */
@OptIn(ExperimentalComposeUiApi::class)
@VisibleForTesting
internal fun hitTestSemanticsAt(x: Float, y: Float): Int

But after an early prototype, we quickly abandoned the idea as the potential performance overhead of having accessibility enabled didn’t justify the value generated. Since Compose UI elements are not part of the traditional Android View system, the Compose runtime needs to sync the “semantic tree” to the Android system accessibility service if the accessibility features are enabled. For example, any changes to the layout bounds are synced every 100ms.

// From <https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt;l=2033;drc=63b4fed978b3da23879817a502899d9154d97e51>

/**
 * This suspend function loops for the entire lifetime of the Compose instance: it consumes
 * recent layout changes and sends events to the accessibility framework in batches separated
 * by a 100ms delay.
 */
suspend fun boundsUpdatesEventLoop() {
    // ...
}
Diving into Compose internals
// From: <https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt;l=122;drc=2a88b3e1da6387b7914f95001988f90a2a3857f1>
/**
 * The position of this layout relative to the window.
 */
fun LayoutCoordinates.positionInWindow(): Offset

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

You probably used Offset before, but did you know it’s actually a Long in a fancy costume? 🤡 x and y are just packed into the first and last 32 bits. This Kotlin feature is called Inline Classes, and it’s a powerful trick to improve runtime performance while still providing the convenience and type safety of classes.

@Immutable
@kotlin.jvm.JvmInline
value class Offset internal constructor(internal val packedValue: Long) {
  @Stable
  val x: Float
    get() // ...
  
  @Stable
  val y: Float
    get() // ...
}

Since we’re accessing the Compose API in Java, we had to manually extract x and y components from the Offset.

private static boolean layoutNodeBoundsContain(@NotNull LayoutNode node, final float x, final float y) {
    final int nodeHeight = node.getHeight();
    final int nodeWidth = node.getWidth();

    // positionInWindow() returns an Offset in Kotlin
    // if accessed in Java, you'll get a long!
    final long nodePosition = LayoutCoordinatesKt.positionInWindow(node.getCoordinates());

    final int nodeX = (int) Float.intBitsToFloat((int) (nodePosition >> 32));
    final int nodeY = (int) Float.intBitsToFloat((int) (nodePosition));

    return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight);
}
Identifying Composables
@Composable
fun EmptyComposable() {

}

Now let’s compile this code and check how the Compose Compiler plugin enriches the function body:

import androidx.compose.runtime.Composer;
import androidx.compose.runtime.ComposerKt;
import androidx.compose.runtime.ScopeUpdateScope;
import kotlin.Metadata;

public final class EmptyComposableKt {
    public static final void EmptyComposable(Composer $composer, int $changed) {
        Composer $composer2 = $composer.startRestartGroup(103603534);
        ComposerKt.sourceInformation($composer2, "C(EmptyComposable):EmptyComposable.kt#llk8wg");
        if ($changed != 0 || !$composer2.getSkipping()) {
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventStart(103603534, $changed, -1, "com.example.EmptyComposable (EmptyComposable.kt:5)");
            }
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventEnd();
            }
        } else {
            $composer2.skipToGroupEnd();
        }
        ScopeUpdateScope endRestartGroup = $composer2.endRestartGroup();
        if (endRestartGroup == null) {
            return;
        }
        endRestartGroup.updateScope(new EmptyComposableKt$EmptyComposable$1($changed));
    }
}
fun retrieveTestTag(node: LayoutNode) : String? {
    for (modifier in node.modifiers) {
        if (modifier is SemanticsModifier) {
            val testTag: String? = modifier
                .semanticsConfiguration
                .getOrNull(SemanticsProperties.TestTag)
            
            if (testTag != null) {
                return testTag
            }
        }
    }
    return null
}
Wrapping it up
<!-- AndroidManifest.xml -->
<application>
  <meta-data android:name="io.sentry.traces.user-interaction.enable" android:value="true" />
</application>
Next Steps
Jetpack Compose + Sentry Resources

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu