How we built user interaction tracking for Jetpack Compose
We recently shared our tips for getting started with Jetpack Compose, Android’s recommended modern toolkit for building native UI. One of those tips is using an error and performance monitoring tool like Sentry to reduce the learning curve and ensure that your app is bug-free. In this blog, we detail how we implemented the user interaction tracking feature for Jetpack Compose, which is available as part of our Android SDK. We hope that sharing our process will shed light on the inner workings of Jetpack Compose and inspire you to start building.
Our goals
Our Android SDK gives developers deep context, like device details, threading information, and screenshots, that makes it easier to investigate an issue. It also provides breadcrumbs of user interactions (clicks, scrolls, or swipes) to fully understand what led up to a crash. And, like all of our other SDKs, our Android SDK is designed to provide this valuable information out-of-the-box without cluttering your code with Sentry SDK calls.
We had the following goals in mind when building our user interaction tracking feature for Jetpack Compose:
Detect any clicks, swipes, or scrolls, globally
Know which UI element a user interacted with
Determine an identifier for the UI element and generate the corresponding breadcrumb
Require minimal setup
Detecting clicks, scrolls, and swipes
In Jetpack Compose UI, a click behavior is usually added via Modifier.clickable, where you provide a lambda expression as an argument. Scrolling and swiping work similarly. That’s a lot of API surface to cover and spread throughout the user’s code. So how could an SDK track all those calls without asking the developer to add any custom code to every invocation? The answer is some nifty combination of existing system callbacks:
On Sentry SDK init, register a
ActivityLifecycleCallbacks
to get hold of the current visible ActivityRetrieve the Window via
Activity.getWindow()
Set a Window.Callback using
window.setCallback()
Let’s dive a bit deeper into the Window.Callback interface. It defines several methods, but the interesting one for us is dispatchTouchEvent. It allows you to intercept every motion event being dispatched to an Activity. This is quite powerful and the basis for many features. For example, the good old Dialog uses this callback to detect clicks outside the content to trigger dialog dismissals.
What’s important to note here is that you can only set a single Window.Callback, thus it’s required to remember any previously set callback (e.g. by the system or other app code out of your control) and delegate all calls to it. This ensures any existing logic will still be executed, avoiding breaking any behaviour.
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
But this is only half of the job done, as we also want to know which widget the user has interacted with. For traditional Android XML layouts, this is rather easy:
Iterate the View Hierarchy, and find a matching View given the touch coordinates
Retrieve the numeric View ID via view.getId()
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 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() {
// ...
}
We also had little control over what the API returned, e.g., the widget descriptions were localized, making it unsuitable for our use case.
Diving into Compose internals
So it was time to examine how Compose works under the hood closely.
Unlike the traditional Android View system, Jetpack Compose builds the View Hierarchy for you. Your @Composable code “emits” all required information to build up its internal hierarchy of nodes. For Android, the tree consists of two different node types: Either LayoutNode (e.g. a Box) or VNode (used for Vector drawables).
The before-mentioned AndroidComposeView implements the androidx.compose.ui.node.Owner interface, which itself provides a root of type LayoutNode.
Unfortunately, some of these APIs are marked as internal and thus can’t be used from an outside module, as it will produce a Kotlin compiler error. We didn’t want to resort to using reflection to workaround this, so we devised another little trick: If you’re accessing the APIs via Java, you’ll get away with a compiler warning. 🙂 Granted, this is far from ideal, but it gives us some compile-time safety and lets us quickly discover breaking changes in combination with a newer version of Jetpack Compose. On top of that, reflection would not have worked for obfuscated builds, as any Class.forName() calls during runtime wouldn’t work with renamed Compose runtime classes.
After settling on the Java workaround, we quickly encountered another issue when adding Java sources to our existing sentry-compose Kotlin multiplatform module. The build fails if you try to mix Java into an Kotlin Multiplatform Mobile (KMM) enabled Android library. This is a known issue, and as a temporary workaround, we created a separate JVM module called sentry-compose-helper which contains all relevant Java code.
Similar to a View, a LayoutNode also provides some APIs to retrieve its location and bounds on the screen. LayoutNode.getCoordinates() provides coordinates that can be fed into LayoutCoordinates.positionInWindow(), which then returns an Offset.
// 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
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
Retrieving a suitable identifier for a LayoutNode wasn’t straightforward either. Our first approach was to access the sourceInformation. When the Compose Compiler plugin processes your @Composable functions, it adds sourceInformation to your method body. This can then later get picked up by Compose tooling to e.g. link the Layout Inspector with your source code.
To illustrate this a bit better, let’s define the simplest possible @Composable function
@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));
}
}
Let’s focus on the ComposerKt.sourceInformation() call: The second argument is a String, containing information about the function name and the source file. Unfortunately, sourceInformation isn’t necessarily available in obfuscated release builds, thus, we also can’t take advantage of that.
After some more research, we stumbled upon the built-in Modifier.testTag("<identifier">) method, which is commonly used for writing UI tests. Turns out this is part of the accessibility semantics we already looked into earlier!
At this point, it was little to no surprise to see that those semantics are being modeled as Modifiers under the hood (Modifiers are like a secret ingredient, making Jetpack Compose so powerful!). Since Modifiers are directly attached to a LayoutNode, we can simply iterate over them and look for a suitable one.
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
Having finished the last piece of the puzzle, it was time to wrap it up, cover some edge cases and ship the final product. Jetpack Compose user interactions are now available, starting with the 6.10.0 version of our Android SDK.
Currently, the feature is still opt-in, so it needs to be enabled via AndroidManifest.xml:
<!-- AndroidManifest.xml -->
<application>
<meta-data android:name="io.sentry.traces.user-interaction.enable" android:value="true" />
</application>
But after enabling it, it just works. Granted, it still requires you to provide a Modifier.testTag(...), but that should already exist if you’re writing UI tests. 😉 Check out our docs to get started.
I hope you enjoyed this write-up about some Jetpack Compose internals. Let us know what you think in our Discord or GithHub community. Stay tuned for more tech articles in the upcoming weeks!