Skip to content

Commit 46e0307

Browse files
authored
Add support for Sentry Kotlin Compiler Plugin (#2695)
1 parent 5041902 commit 46e0307

18 files changed

Lines changed: 497 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
### Features
6+
- Add support for Sentry Kotlin Compiler Plugin ([#2695](https://github.com/getsentry/sentry-java/pull/2695))
7+
- In conjunction with our sentry-kotlin-compiler-plugin we improved Jetpack Compose support for
8+
- [View Hierarchy](https://docs.sentry.io/platforms/android/enriching-events/viewhierarchy/) support for Jetpack Compose screens
9+
- Automatic breadcrumbs for [user interactions](https://docs.sentry.io/platforms/android/performance/instrumentation/automatic-instrumentation/#user-interaction-instrumentation)
10+
511
### Fixes
612

713
- Base64 encode internal Apollo3 Headers ([#2707](https://github.com/getsentry/sentry-java/pull/2707))

sentry-android-core/api/sentry-android-core.api

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,9 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr
317317
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;)V
318318
public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent;
319319
public static fun snapshotViewHierarchy (Landroid/app/Activity;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy;
320-
public static fun snapshotViewHierarchy (Landroid/app/Activity;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy;
320+
public static fun snapshotViewHierarchy (Landroid/app/Activity;Ljava/util/List;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy;
321321
public static fun snapshotViewHierarchy (Landroid/view/View;)Lio/sentry/protocol/ViewHierarchy;
322+
public static fun snapshotViewHierarchy (Landroid/view/View;Ljava/util/List;)Lio/sentry/protocol/ViewHierarchy;
322323
public static fun snapshotViewHierarchyAsData (Landroid/app/Activity;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ISerializer;Lio/sentry/ILogger;)[B
323324
}
324325

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
import io.sentry.cache.PersistingOptionsObserver;
2323
import io.sentry.cache.PersistingScopeObserver;
2424
import io.sentry.compose.gestures.ComposeGestureTargetLocator;
25+
import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter;
2526
import io.sentry.internal.gestures.GestureTargetLocator;
27+
import io.sentry.internal.viewhierarchy.ViewHierarchyExporter;
2628
import io.sentry.transport.NoOpEnvelopeCache;
2729
import io.sentry.util.Objects;
2830
import java.io.BufferedInputStream;
@@ -44,9 +46,12 @@
4446
@SuppressWarnings("Convert2MethodRef") // older AGP versions do not support method references
4547
final class AndroidOptionsInitializer {
4648

47-
static final String SENTRY_COMPOSE_INTEGRATION_CLASS_NAME =
49+
static final String SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME =
4850
"io.sentry.compose.gestures.ComposeGestureTargetLocator";
4951

52+
static final String SENTRY_COMPOSE_VIEW_HIERARCHY_INTEGRATION_CLASS_NAME =
53+
"io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter";
54+
5055
static final String COMPOSE_CLASS_NAME = "androidx.compose.ui.node.Owner";
5156

5257
/** private ctor */
@@ -151,22 +156,34 @@ static void initializeIntegrationsAndProcessors(
151156

152157
final boolean isAndroidXScrollViewAvailable =
153158
loadClass.isClassAvailable("androidx.core.view.ScrollingView", options);
159+
final boolean isComposeUpstreamAvailable =
160+
loadClass.isClassAvailable(COMPOSE_CLASS_NAME, options);
154161

155162
if (options.getGestureTargetLocators().isEmpty()) {
156163
final List<GestureTargetLocator> gestureTargetLocators = new ArrayList<>(2);
157164
gestureTargetLocators.add(new AndroidViewGestureTargetLocator(isAndroidXScrollViewAvailable));
158165

159-
final boolean isComposeUpstreamAvailable =
160-
loadClass.isClassAvailable(COMPOSE_CLASS_NAME, options);
161166
final boolean isComposeAvailable =
162167
(isComposeUpstreamAvailable
163-
&& loadClass.isClassAvailable(SENTRY_COMPOSE_INTEGRATION_CLASS_NAME, options));
168+
&& loadClass.isClassAvailable(
169+
SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME, options));
164170

165171
if (isComposeAvailable) {
166-
gestureTargetLocators.add(new ComposeGestureTargetLocator());
172+
gestureTargetLocators.add(new ComposeGestureTargetLocator(options.getLogger()));
167173
}
168174
options.setGestureTargetLocators(gestureTargetLocators);
169175
}
176+
177+
if (options.getViewHierarchyExporters().isEmpty()
178+
&& isComposeUpstreamAvailable
179+
&& loadClass.isClassAvailable(
180+
SENTRY_COMPOSE_VIEW_HIERARCHY_INTEGRATION_CLASS_NAME, options)) {
181+
182+
final List<ViewHierarchyExporter> viewHierarchyExporters = new ArrayList<>(1);
183+
viewHierarchyExporters.add(new ComposeViewHierarchyExporter(options.getLogger()));
184+
options.setViewHierarchyExporters(viewHierarchyExporters);
185+
}
186+
170187
options.setMainThreadChecker(AndroidMainThreadChecker.getInstance());
171188
if (options.getCollectors().isEmpty()) {
172189
options.addCollector(new AndroidMemoryCollector());

sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import io.sentry.SentryLevel;
1515
import io.sentry.android.core.internal.gestures.ViewUtils;
1616
import io.sentry.android.core.internal.util.AndroidMainThreadChecker;
17+
import io.sentry.internal.viewhierarchy.ViewHierarchyExporter;
1718
import io.sentry.protocol.ViewHierarchy;
1819
import io.sentry.protocol.ViewHierarchyNode;
1920
import io.sentry.util.HintUtils;
@@ -60,7 +61,11 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options)
6061

6162
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
6263
final @Nullable ViewHierarchy viewHierarchy =
63-
snapshotViewHierarchy(activity, options.getMainThreadChecker(), options.getLogger());
64+
snapshotViewHierarchy(
65+
activity,
66+
options.getViewHierarchyExporters(),
67+
options.getMainThreadChecker(),
68+
options.getLogger());
6469

6570
if (viewHierarchy != null) {
6671
hint.setViewHierarchy(Attachment.fromViewHierarchy(viewHierarchy));
@@ -74,8 +79,10 @@ public static byte[] snapshotViewHierarchyAsData(
7479
@NotNull IMainThreadChecker mainThreadChecker,
7580
@NotNull ISerializer serializer,
7681
@NotNull ILogger logger) {
82+
7783
@Nullable
78-
ViewHierarchy viewHierarchy = snapshotViewHierarchy(activity, mainThreadChecker, logger);
84+
ViewHierarchy viewHierarchy =
85+
snapshotViewHierarchy(activity, new ArrayList<>(0), mainThreadChecker, logger);
7986

8087
if (viewHierarchy == null) {
8188
logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy.");
@@ -98,15 +105,18 @@ public static byte[] snapshotViewHierarchyAsData(
98105

99106
@Nullable
100107
public static ViewHierarchy snapshotViewHierarchy(
101-
@Nullable Activity activity, @NotNull ILogger logger) {
102-
return snapshotViewHierarchy(activity, AndroidMainThreadChecker.getInstance(), logger);
108+
final @Nullable Activity activity, final @NotNull ILogger logger) {
109+
return snapshotViewHierarchy(
110+
activity, new ArrayList<>(0), AndroidMainThreadChecker.getInstance(), logger);
103111
}
104112

105113
@Nullable
106114
public static ViewHierarchy snapshotViewHierarchy(
107-
@Nullable Activity activity,
108-
@NotNull IMainThreadChecker mainThreadChecker,
109-
@NotNull ILogger logger) {
115+
final @Nullable Activity activity,
116+
final @NotNull List<ViewHierarchyExporter> exporters,
117+
final @NotNull IMainThreadChecker mainThreadChecker,
118+
final @NotNull ILogger logger) {
119+
110120
if (activity == null) {
111121
logger.log(SentryLevel.INFO, "Missing activity for view hierarchy snapshot.");
112122
return null;
@@ -126,14 +136,14 @@ public static ViewHierarchy snapshotViewHierarchy(
126136

127137
try {
128138
if (mainThreadChecker.isMainThread()) {
129-
return snapshotViewHierarchy(decorView);
139+
return snapshotViewHierarchy(decorView, exporters);
130140
} else {
131141
final CountDownLatch latch = new CountDownLatch(1);
132142
final AtomicReference<ViewHierarchy> viewHierarchy = new AtomicReference<>(null);
133143
activity.runOnUiThread(
134144
() -> {
135145
try {
136-
viewHierarchy.set(snapshotViewHierarchy(decorView));
146+
viewHierarchy.set(snapshotViewHierarchy(decorView, exporters));
137147
latch.countDown();
138148
} catch (Throwable t) {
139149
logger.log(SentryLevel.ERROR, "Failed to process view hierarchy.", t);
@@ -150,23 +160,39 @@ public static ViewHierarchy snapshotViewHierarchy(
150160
}
151161

152162
@NotNull
153-
public static ViewHierarchy snapshotViewHierarchy(@NotNull final View view) {
163+
public static ViewHierarchy snapshotViewHierarchy(final @NotNull View view) {
164+
return snapshotViewHierarchy(view, new ArrayList<>(0));
165+
}
166+
167+
@NotNull
168+
public static ViewHierarchy snapshotViewHierarchy(
169+
final @NotNull View view, final @NotNull List<ViewHierarchyExporter> exporters) {
154170
final List<ViewHierarchyNode> windows = new ArrayList<>(1);
155171
final ViewHierarchy viewHierarchy = new ViewHierarchy("android_view_system", windows);
156172

157173
final @NotNull ViewHierarchyNode node = viewToNode(view);
158174
windows.add(node);
159-
addChildren(view, node);
175+
addChildren(view, node, exporters);
160176

161177
return viewHierarchy;
162178
}
163179

164180
private static void addChildren(
165-
@NotNull final View view, @NotNull final ViewHierarchyNode parentNode) {
181+
final @NotNull View view,
182+
final @NotNull ViewHierarchyNode parentNode,
183+
final @NotNull List<ViewHierarchyExporter> exporters) {
166184
if (!(view instanceof ViewGroup)) {
167185
return;
168186
}
169187

188+
// In case any external exporter recognizes it's own widget (e.g. AndroidComposeView)
189+
// we can immediately return
190+
for (ViewHierarchyExporter exporter : exporters) {
191+
if (exporter.export(parentNode, view)) {
192+
return;
193+
}
194+
}
195+
170196
final @NotNull ViewGroup viewGroup = ((ViewGroup) view);
171197
final int childCount = viewGroup.getChildCount();
172198
if (childCount == 0) {
@@ -179,7 +205,7 @@ private static void addChildren(
179205
if (child != null) {
180206
final @NotNull ViewHierarchyNode childNode = viewToNode(child);
181207
childNodes.add(childNode);
182-
addChildren(child, childNode);
208+
addChildren(child, childNode, exporters);
183209
}
184210
}
185211
parentNode.setChildren(childNodes);

sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ class AndroidOptionsInitializerTest {
559559
fixture.initSutWithClassLoader(
560560
classesToLoad = listOf(
561561
AndroidOptionsInitializer.COMPOSE_CLASS_NAME,
562-
AndroidOptionsInitializer.SENTRY_COMPOSE_INTEGRATION_CLASS_NAME
562+
AndroidOptionsInitializer.SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME
563563
)
564564
)
565565

sentry-compose-helper/api/sentry-compose-helper.api

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
public class io/sentry/compose/SentryComposeHelper {
2+
public fun <init> (Lio/sentry/ILogger;)V
3+
public fun getLayoutNodeBoundsInWindow (Landroidx/compose/ui/node/LayoutNode;)Landroidx/compose/ui/geometry/Rect;
4+
}
5+
16
public final class io/sentry/compose/gestures/ComposeGestureTargetLocator : io/sentry/internal/gestures/GestureTargetLocator {
2-
public fun <init> ()V
7+
public fun <init> (Lio/sentry/ILogger;)V
38
public fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement;
49
}
510

@@ -10,3 +15,8 @@ public final class io/sentry/compose/helper/BuildConfig {
1015
public static final field VERSION_NAME Ljava/lang/String;
1116
}
1217

18+
public final class io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter : io/sentry/internal/viewhierarchy/ViewHierarchyExporter {
19+
public fun <init> (Lio/sentry/ILogger;)V
20+
public fun export (Lio/sentry/protocol/ViewHierarchyNode;Ljava/lang/Object;)Z
21+
}
22+

sentry-compose-helper/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ kotlin {
2222
compileOnly(compose.ui)
2323
}
2424
}
25+
val jvmTest by getting {
26+
dependencies {
27+
implementation(compose.runtime)
28+
implementation(compose.ui)
29+
30+
implementation(Config.TestLibs.kotlinTestJunit)
31+
implementation(Config.TestLibs.mockitoKotlin)
32+
implementation(Config.TestLibs.mockitoInline)
33+
}
34+
}
2535
}
2636
}
2737

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package io.sentry.compose;
2+
3+
import androidx.compose.ui.geometry.Rect;
4+
import androidx.compose.ui.layout.LayoutCoordinatesKt;
5+
import androidx.compose.ui.node.LayoutNode;
6+
import androidx.compose.ui.node.LayoutNodeLayoutDelegate;
7+
import io.sentry.ILogger;
8+
import io.sentry.SentryLevel;
9+
import java.lang.reflect.Field;
10+
import org.jetbrains.annotations.NotNull;
11+
import org.jetbrains.annotations.Nullable;
12+
13+
public class SentryComposeHelper {
14+
15+
private final @NotNull ILogger logger;
16+
private Field layoutDelegateField = null;
17+
18+
public SentryComposeHelper(final @NotNull ILogger logger) {
19+
this.logger = logger;
20+
try {
21+
final Class<?> clazz = Class.forName("androidx.compose.ui.node.LayoutNode");
22+
layoutDelegateField = clazz.getDeclaredField("layoutDelegate");
23+
layoutDelegateField.setAccessible(true);
24+
} catch (Exception e) {
25+
logger.log(SentryLevel.WARNING, "Could not find LayoutNode.layoutDelegate field");
26+
}
27+
}
28+
29+
public @Nullable Rect getLayoutNodeBoundsInWindow(@NotNull final LayoutNode node) {
30+
if (layoutDelegateField != null) {
31+
try {
32+
final LayoutNodeLayoutDelegate delegate =
33+
(LayoutNodeLayoutDelegate) layoutDelegateField.get(node);
34+
return LayoutCoordinatesKt.boundsInWindow(delegate.getOuterCoordinator().getCoordinates());
35+
} catch (Exception e) {
36+
logger.log(SentryLevel.WARNING, "Could not fetch position for LayoutNode", e);
37+
}
38+
}
39+
return null;
40+
}
41+
}

sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package io.sentry.compose.gestures;
22

3-
import androidx.compose.ui.layout.LayoutCoordinatesKt;
3+
import androidx.compose.ui.geometry.Rect;
44
import androidx.compose.ui.layout.ModifierInfo;
55
import androidx.compose.ui.node.LayoutNode;
66
import androidx.compose.ui.node.Owner;
77
import androidx.compose.ui.semantics.SemanticsConfiguration;
88
import androidx.compose.ui.semantics.SemanticsModifier;
99
import androidx.compose.ui.semantics.SemanticsPropertyKey;
10+
import io.sentry.ILogger;
1011
import io.sentry.SentryIntegrationPackageStorage;
12+
import io.sentry.compose.SentryComposeHelper;
1113
import io.sentry.compose.helper.BuildConfig;
1214
import io.sentry.internal.gestures.GestureTargetLocator;
1315
import io.sentry.internal.gestures.UiElement;
@@ -21,7 +23,11 @@
2123
@SuppressWarnings("KotlinInternalInJava")
2224
public final class ComposeGestureTargetLocator implements GestureTargetLocator {
2325

24-
public ComposeGestureTargetLocator() {
26+
private final @NotNull ILogger logger;
27+
private volatile @Nullable SentryComposeHelper composeHelper;
28+
29+
public ComposeGestureTargetLocator(final @NotNull ILogger logger) {
30+
this.logger = logger;
2531
SentryIntegrationPackageStorage.getInstance().addIntegration("ComposeUserInteraction");
2632
SentryIntegrationPackageStorage.getInstance()
2733
.addPackage("maven:io.sentry:sentry-compose", BuildConfig.VERSION_NAME);
@@ -30,6 +36,16 @@ public ComposeGestureTargetLocator() {
3036
@Override
3137
public @Nullable UiElement locate(
3238
@NotNull Object root, float x, float y, UiElement.Type targetType) {
39+
40+
// lazy init composeHelper as it's using some reflection under the hood
41+
if (composeHelper == null) {
42+
synchronized (this) {
43+
if (composeHelper == null) {
44+
composeHelper = new SentryComposeHelper(logger);
45+
}
46+
}
47+
}
48+
3349
@Nullable String targetTag = null;
3450

3551
if (!(root instanceof Owner)) {
@@ -45,7 +61,7 @@ public ComposeGestureTargetLocator() {
4561
continue;
4662
}
4763

48-
if (node.isPlaced() && layoutNodeBoundsContain(node, x, y)) {
64+
if (node.isPlaced() && layoutNodeBoundsContain(composeHelper, node, x, y)) {
4965
boolean isClickable = false;
5066
boolean isScrollable = false;
5167
@Nullable String testTag = null;
@@ -63,7 +79,7 @@ public ComposeGestureTargetLocator() {
6379
isScrollable = true;
6480
} else if ("OnClick".equals(key)) {
6581
isClickable = true;
66-
} else if ("TestTag".equals(key)) {
82+
} else if ("SentryTag".equals(key) || "TestTag".equals(key)) {
6783
if (entry.getValue() instanceof String) {
6884
testTag = (String) entry.getValue();
6985
}
@@ -92,16 +108,19 @@ public ComposeGestureTargetLocator() {
92108
}
93109

94110
private static boolean layoutNodeBoundsContain(
95-
@NotNull LayoutNode node, final float x, final float y) {
96-
final int nodeHeight = node.getHeight();
97-
final int nodeWidth = node.getWidth();
98-
99-
// Offset is a Kotlin value class, packing x/y into a long
100-
// TODO find a way to use the existing APIs
101-
final long nodePosition = LayoutCoordinatesKt.positionInWindow(node.getCoordinates());
102-
final int nodeX = (int) Float.intBitsToFloat((int) (nodePosition >> 32));
103-
final int nodeY = (int) Float.intBitsToFloat((int) (nodePosition));
111+
@NotNull SentryComposeHelper composeHelper,
112+
@NotNull LayoutNode node,
113+
final float x,
114+
final float y) {
104115

105-
return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight);
116+
final @Nullable Rect bounds = composeHelper.getLayoutNodeBoundsInWindow(node);
117+
if (bounds == null) {
118+
return false;
119+
} else {
120+
return x >= bounds.getLeft()
121+
&& x <= bounds.getRight()
122+
&& y >= bounds.getTop()
123+
&& y <= bounds.getBottom();
124+
}
106125
}
107126
}

0 commit comments

Comments
 (0)