Skip to content

Commit 775fa63

Browse files
authored
Merge 808125e into b03d10f
2 parents b03d10f + 808125e commit 775fa63

14 files changed

Lines changed: 342 additions & 15 deletions

File tree

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
import io.sentry.android.fragment.FragmentLifecycleIntegration;
2121
import io.sentry.android.timber.SentryTimberIntegration;
2222
import io.sentry.compose.gestures.ComposeGestureTargetLocator;
23+
import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter;
2324
import io.sentry.internal.gestures.GestureTargetLocator;
25+
import io.sentry.internal.viewhierarchy.ViewHierarchyExporter;
2426
import io.sentry.transport.NoOpEnvelopeCache;
2527
import io.sentry.util.Objects;
2628
import java.io.BufferedInputStream;
@@ -42,9 +44,12 @@
4244
@SuppressWarnings("Convert2MethodRef") // older AGP versions do not support method references
4345
final class AndroidOptionsInitializer {
4446

45-
static final String SENTRY_COMPOSE_INTEGRATION_CLASS_NAME =
47+
static final String SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME =
4648
"io.sentry.compose.gestures.ComposeGestureTargetLocator";
4749

50+
static final String SENTRY_COMPOSE_VIEW_HIERARCHY_INTEGRATION_CLASS_NAME =
51+
"io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter";
52+
4853
static final String COMPOSE_CLASS_NAME = "androidx.compose.ui.node.Owner";
4954

5055
/** private ctor */
@@ -148,22 +153,34 @@ static void initializeIntegrationsAndProcessors(
148153

149154
final boolean isAndroidXScrollViewAvailable =
150155
loadClass.isClassAvailable("androidx.core.view.ScrollingView", options);
156+
final boolean isComposeUpstreamAvailable =
157+
loadClass.isClassAvailable(COMPOSE_CLASS_NAME, options);
151158

152159
if (options.getGestureTargetLocators().isEmpty()) {
153160
final List<GestureTargetLocator> gestureTargetLocators = new ArrayList<>(2);
154161
gestureTargetLocators.add(new AndroidViewGestureTargetLocator(isAndroidXScrollViewAvailable));
155162

156-
final boolean isComposeUpstreamAvailable =
157-
loadClass.isClassAvailable(COMPOSE_CLASS_NAME, options);
158163
final boolean isComposeAvailable =
159164
(isComposeUpstreamAvailable
160-
&& loadClass.isClassAvailable(SENTRY_COMPOSE_INTEGRATION_CLASS_NAME, options));
165+
&& loadClass.isClassAvailable(
166+
SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME, options));
161167

162168
if (isComposeAvailable) {
163169
gestureTargetLocators.add(new ComposeGestureTargetLocator());
164170
}
165171
options.setGestureTargetLocators(gestureTargetLocators);
166172
}
173+
174+
if (options.getViewHierarchyExporters().isEmpty()
175+
&& isComposeUpstreamAvailable
176+
&& loadClass.isClassAvailable(
177+
SENTRY_COMPOSE_VIEW_HIERARCHY_INTEGRATION_CLASS_NAME, options)) {
178+
179+
final List<ViewHierarchyExporter> viewHierarchyExporters = new ArrayList<>(1);
180+
viewHierarchyExporters.add(new ComposeViewHierarchyExporter());
181+
options.setViewHierarchyExporters(viewHierarchyExporters);
182+
}
183+
167184
options.setMainThreadChecker(AndroidMainThreadChecker.getInstance());
168185
if (options.getCollectors().isEmpty()) {
169186
options.addCollector(new AndroidMemoryCollector());

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
@@ -505,7 +505,7 @@ class AndroidOptionsInitializerTest {
505505
fixture.initSutWithClassLoader(
506506
classesToLoad = listOf(
507507
AndroidOptionsInitializer.COMPOSE_CLASS_NAME,
508-
AndroidOptionsInitializer.SENTRY_COMPOSE_INTEGRATION_CLASS_NAME
508+
AndroidOptionsInitializer.SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME
509509
)
510510
)
511511

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
public class io/sentry/compose/SentryComposeUtil {
2+
public fun <init> ()V
3+
public static final fun getLayoutNodeXY (Landroidx/compose/ui/node/LayoutNode;)[I
4+
}
5+
16
public final class io/sentry/compose/gestures/ComposeGestureTargetLocator : io/sentry/internal/gestures/GestureTargetLocator {
27
public fun <init> ()V
38
public fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement;
@@ -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> ()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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.sentry.compose;
2+
3+
import androidx.compose.ui.layout.LayoutCoordinatesKt;
4+
import androidx.compose.ui.node.LayoutNode;
5+
import org.jetbrains.annotations.NotNull;
6+
7+
public class SentryComposeUtil {
8+
public static final int[] getLayoutNodeXY(@NotNull final LayoutNode node) {
9+
// Offset is a Kotlin value class, packing x/y into a long
10+
// TODO find a way to use the existing APIs
11+
final long nodePosition = LayoutCoordinatesKt.positionInWindow(node.getCoordinates());
12+
final int nodeX = (int) Float.intBitsToFloat((int) (nodePosition >> 32));
13+
final int nodeY = (int) Float.intBitsToFloat((int) (nodePosition));
14+
return new int[] {nodeX, nodeY};
15+
}
16+
}

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

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

3-
import androidx.compose.ui.layout.LayoutCoordinatesKt;
43
import androidx.compose.ui.layout.ModifierInfo;
54
import androidx.compose.ui.node.LayoutNode;
65
import androidx.compose.ui.node.Owner;
76
import androidx.compose.ui.semantics.SemanticsConfiguration;
87
import androidx.compose.ui.semantics.SemanticsModifier;
98
import androidx.compose.ui.semantics.SemanticsPropertyKey;
109
import io.sentry.SentryIntegrationPackageStorage;
10+
import io.sentry.compose.SentryComposeUtil;
1111
import io.sentry.compose.helper.BuildConfig;
1212
import io.sentry.internal.gestures.GestureTargetLocator;
1313
import io.sentry.internal.gestures.UiElement;
@@ -63,7 +63,7 @@ public ComposeGestureTargetLocator() {
6363
isScrollable = true;
6464
} else if ("OnClick".equals(key)) {
6565
isClickable = true;
66-
} else if ("TestTag".equals(key)) {
66+
} else if ("SentryTag".equals(key) || "TestTag".equals(key)) {
6767
if (entry.getValue() instanceof String) {
6868
testTag = (String) entry.getValue();
6969
}
@@ -96,11 +96,9 @@ private static boolean layoutNodeBoundsContain(
9696
final int nodeHeight = node.getHeight();
9797
final int nodeWidth = node.getWidth();
9898

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));
99+
final int[] xy = SentryComposeUtil.getLayoutNodeXY(node);
100+
final int nodeX = xy[0];
101+
final int nodeY = xy[1];
104102

105103
return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight);
106104
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package io.sentry.compose.viewhierarchy;
2+
3+
import androidx.compose.runtime.collection.MutableVector;
4+
import androidx.compose.ui.layout.ModifierInfo;
5+
import androidx.compose.ui.node.LayoutNode;
6+
import androidx.compose.ui.node.Owner;
7+
import androidx.compose.ui.semantics.SemanticsConfiguration;
8+
import androidx.compose.ui.semantics.SemanticsModifier;
9+
import androidx.compose.ui.semantics.SemanticsPropertyKey;
10+
import io.sentry.compose.SentryComposeUtil;
11+
import io.sentry.internal.viewhierarchy.ViewHierarchyExporter;
12+
import io.sentry.protocol.ViewHierarchyNode;
13+
import java.util.ArrayList;
14+
import java.util.List;
15+
import java.util.Map;
16+
import org.jetbrains.annotations.NotNull;
17+
import org.jetbrains.annotations.Nullable;
18+
19+
@SuppressWarnings("KotlinInternalInJava")
20+
public final class ComposeViewHierarchyExporter implements ViewHierarchyExporter {
21+
22+
@Override
23+
public boolean export(@NotNull final ViewHierarchyNode parent, @NotNull final Object element) {
24+
25+
if (!(element instanceof Owner)) {
26+
return false;
27+
}
28+
29+
final @NotNull LayoutNode rootNode = ((Owner) element).getRoot();
30+
addChild(parent, rootNode);
31+
return true;
32+
}
33+
34+
private static void addChild(
35+
@NotNull final ViewHierarchyNode parent, @NotNull final LayoutNode node) {
36+
if (node.isPlaced()) {
37+
final ViewHierarchyNode vhNode = new ViewHierarchyNode();
38+
setBounds(node, vhNode);
39+
setTag(node, vhNode);
40+
if (parent.getChildren() == null) {
41+
parent.setChildren(new ArrayList<>());
42+
}
43+
parent.getChildren().add(vhNode);
44+
45+
final MutableVector<LayoutNode> children = node.getZSortedChildren();
46+
final int childrenCount = children.getSize();
47+
for (int i = 0; i < childrenCount; i++) {
48+
final LayoutNode child = children.get(i);
49+
addChild(vhNode, child);
50+
}
51+
}
52+
}
53+
54+
private static void setTag(
55+
@NotNull final LayoutNode node, @NotNull final ViewHierarchyNode vhNode) {
56+
final List<ModifierInfo> modifiers = node.getModifierInfo();
57+
for (ModifierInfo modifierInfo : modifiers) {
58+
if (modifierInfo.getModifier() instanceof SemanticsModifier) {
59+
final SemanticsModifier semanticsModifierCore =
60+
(SemanticsModifier) modifierInfo.getModifier();
61+
final SemanticsConfiguration semanticsConfiguration =
62+
semanticsModifierCore.getSemanticsConfiguration();
63+
for (Map.Entry<? extends SemanticsPropertyKey<?>, ?> entry : semanticsConfiguration) {
64+
final @Nullable String key = entry.getKey().getName();
65+
if ("SentryTag".equals(key) || "TestTag".equals(key)) {
66+
if (entry.getValue() instanceof String) {
67+
vhNode.setTag((String) entry.getValue());
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}
74+
75+
private static void setBounds(@NotNull LayoutNode node, @NotNull ViewHierarchyNode vhNode) {
76+
final int nodeHeight = node.getHeight();
77+
final int nodeWidth = node.getWidth();
78+
79+
final int[] xy = SentryComposeUtil.getLayoutNodeXY(node);
80+
81+
vhNode.setHeight(Double.valueOf(nodeHeight));
82+
vhNode.setWidth(Double.valueOf(nodeWidth));
83+
vhNode.setX(Double.valueOf(xy[0]));
84+
vhNode.setY(Double.valueOf(xy[1]));
85+
}
86+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package io.sentry.compose.viewhierarchy;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertNull;
5+
6+
import androidx.compose.runtime.collection.MutableVector;
7+
import androidx.compose.ui.layout.LayoutCoordinates;
8+
import androidx.compose.ui.layout.ModifierInfo;
9+
import androidx.compose.ui.node.LayoutNode;
10+
import androidx.compose.ui.node.Owner;
11+
import androidx.compose.ui.semantics.SemanticsConfiguration;
12+
import androidx.compose.ui.semantics.SemanticsModifier;
13+
import androidx.compose.ui.semantics.SemanticsPropertyKey;
14+
import io.sentry.internal.viewhierarchy.ViewHierarchyExporter;
15+
import io.sentry.protocol.ViewHierarchyNode;
16+
import java.util.ArrayList;
17+
import java.util.List;
18+
import kotlin.jvm.functions.Function2;
19+
import org.jetbrains.annotations.NotNull;
20+
import org.jetbrains.annotations.Nullable;
21+
import org.junit.Test;
22+
import org.mockito.Mockito;
23+
24+
public class ComposeViewHierarchyExporterTest {
25+
26+
@Test
27+
public void testComposeViewHierarchyExport() {
28+
final ViewHierarchyNode rootVhNode = new ViewHierarchyNode();
29+
30+
final LayoutNode childA = mockLayoutNode(true, "childA", 10, 20);
31+
final LayoutNode childB = mockLayoutNode(true, null, 10, 20);
32+
final LayoutNode childC = mockLayoutNode(false, null, 10, 20);
33+
final LayoutNode parent = mockLayoutNode(true, "root", 30, 40, childA, childB, childC);
34+
35+
final Owner node = Mockito.mock(Owner.class);
36+
Mockito.when(node.getRoot()).thenReturn(parent);
37+
38+
final ViewHierarchyExporter exporter = new ComposeViewHierarchyExporter();
39+
exporter.export(rootVhNode, node);
40+
41+
assertEquals(1, rootVhNode.getChildren().size());
42+
final ViewHierarchyNode parentVhNode = rootVhNode.getChildren().get(0);
43+
44+
assertEquals("root", parentVhNode.getTag());
45+
assertEquals(30.0, parentVhNode.getWidth().doubleValue(), 0.001);
46+
assertEquals(40.0, parentVhNode.getHeight().doubleValue(), 0.001);
47+
48+
// ensure not placed elements (childC) are not part of the view hierarchy
49+
assertEquals(2, parentVhNode.getChildren().size());
50+
51+
final ViewHierarchyNode childAVhNode = parentVhNode.getChildren().get(0);
52+
assertEquals("childA", childAVhNode.getTag());
53+
assertEquals(10.0, childAVhNode.getWidth().doubleValue(), 0.001);
54+
assertEquals(20.0, childAVhNode.getHeight().doubleValue(), 0.001);
55+
assertNull(childAVhNode.getChildren());
56+
57+
final ViewHierarchyNode childBVhNode = parentVhNode.getChildren().get(1);
58+
assertNull(childBVhNode.getTag());
59+
}
60+
61+
private static LayoutNode mockLayoutNode(
62+
final boolean isPlaced,
63+
final @Nullable String tag,
64+
final int width,
65+
final int height,
66+
LayoutNode... children) {
67+
final LayoutNode nodeA = Mockito.mock(LayoutNode.class);
68+
Mockito.when(nodeA.isPlaced()).thenReturn(isPlaced);
69+
Mockito.when((nodeA.getWidth())).thenReturn(width);
70+
Mockito.when((nodeA.getHeight())).thenReturn(height);
71+
72+
final ModifierInfo modifierInfo = Mockito.mock(ModifierInfo.class);
73+
Mockito.when(modifierInfo.getModifier())
74+
.thenReturn(
75+
new SemanticsModifier() {
76+
@NotNull
77+
@Override
78+
public SemanticsConfiguration getSemanticsConfiguration() {
79+
final SemanticsConfiguration config = new SemanticsConfiguration();
80+
config.set(
81+
new SemanticsPropertyKey<>(
82+
"SentryTag",
83+
new Function2<String, String, String>() {
84+
@Override
85+
public String invoke(String s, String s2) {
86+
return s;
87+
}
88+
}),
89+
tag);
90+
return config;
91+
}
92+
});
93+
final List<ModifierInfo> modifierInfoList = new ArrayList<>();
94+
modifierInfoList.add(modifierInfo);
95+
Mockito.when((nodeA.getModifierInfo())).thenReturn(modifierInfoList);
96+
97+
Mockito.when((nodeA.getZSortedChildren()))
98+
.thenReturn(new MutableVector<>(children, children.length));
99+
100+
final LayoutCoordinates coordinates = Mockito.mock(LayoutCoordinates.class);
101+
Mockito.when(nodeA.getCoordinates()).thenReturn(coordinates);
102+
return nodeA;
103+
}
104+
}

sentry-compose/api/android/sentry-compose.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ public final class io/sentry/compose/SentryComposeTracingKt {
1010
public static final fun SentryTraced (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
1111
}
1212

13+
public final class io/sentry/compose/SentryModifier {
14+
public static final field $stable I
15+
public static final field INSTANCE Lio/sentry/compose/SentryModifier;
16+
public static final field TAG Ljava/lang/String;
17+
public static final fun sentryTag (Landroidx/compose/ui/Modifier;Ljava/lang/String;)Landroidx/compose/ui/Modifier;
18+
}
19+
1320
public final class io/sentry/compose/SentryNavigationIntegrationKt {
1421
public static final fun withSentryObservableEffect (Landroidx/navigation/NavHostController;ZZLandroidx/compose/runtime/Composer;II)Landroidx/navigation/NavHostController;
1522
}

sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import androidx.compose.runtime.remember
99
import androidx.compose.ui.ExperimentalComposeUiApi
1010
import androidx.compose.ui.Modifier
1111
import androidx.compose.ui.draw.drawWithContent
12-
import androidx.compose.ui.platform.testTag
1312
import io.sentry.ISpan
1413
import io.sentry.Sentry
1514
import io.sentry.SpanOptions
15+
import io.sentry.compose.SentryModifier.sentryTag
1616

1717
private const val OP_PARENT_COMPOSITION = "ui.compose.composition"
1818
private const val OP_COMPOSE = "ui.compose"
@@ -74,7 +74,7 @@ public fun SentryTraced(
7474
val compositionSpan = parentCompositionSpan.item?.startChild(OP_COMPOSE, tag)
7575
val firstRendered = remember { ImmutableHolder(false) }
7676

77-
val baseModifier = if (enableUserInteractionTracing) modifier.testTag(tag) else modifier
77+
val baseModifier = if (enableUserInteractionTracing) Modifier.sentryTag(tag) else modifier
7878

7979
Box(
8080
modifier = baseModifier

0 commit comments

Comments
 (0)