diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f37fd007d..746fce280d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Features +- Add support for Sentry Kotlin Compiler Plugin ([#2695](https://github.com/getsentry/sentry-java/pull/2695)) + - In conjunction with our sentry-kotlin-compiler-plugin we improved Jetpack Compose support for + - [View Hierarchy](https://docs.sentry.io/platforms/android/enriching-events/viewhierarchy/) support for Jetpack Compose screens + - Automatic breadcrumbs for [user interactions](https://docs.sentry.io/platforms/android/performance/instrumentation/automatic-instrumentation/#user-interaction-instrumentation) + ### Fixes - Base64 encode internal Apollo3 Headers ([#2707](https://github.com/getsentry/sentry-java/pull/2707)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index d28fcfab8b3..27c71257c02 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -317,8 +317,9 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public static fun snapshotViewHierarchy (Landroid/app/Activity;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; - public static fun snapshotViewHierarchy (Landroid/app/Activity;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; + public static fun snapshotViewHierarchy (Landroid/app/Activity;Ljava/util/List;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; public static fun snapshotViewHierarchy (Landroid/view/View;)Lio/sentry/protocol/ViewHierarchy; + public static fun snapshotViewHierarchy (Landroid/view/View;Ljava/util/List;)Lio/sentry/protocol/ViewHierarchy; public static fun snapshotViewHierarchyAsData (Landroid/app/Activity;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ISerializer;Lio/sentry/ILogger;)[B } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 67101e36556..3c816aee2a0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -22,7 +22,9 @@ import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; import io.sentry.compose.gestures.ComposeGestureTargetLocator; +import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter; import io.sentry.internal.gestures.GestureTargetLocator; +import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.Objects; import java.io.BufferedInputStream; @@ -44,9 +46,12 @@ @SuppressWarnings("Convert2MethodRef") // older AGP versions do not support method references final class AndroidOptionsInitializer { - static final String SENTRY_COMPOSE_INTEGRATION_CLASS_NAME = + static final String SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME = "io.sentry.compose.gestures.ComposeGestureTargetLocator"; + static final String SENTRY_COMPOSE_VIEW_HIERARCHY_INTEGRATION_CLASS_NAME = + "io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter"; + static final String COMPOSE_CLASS_NAME = "androidx.compose.ui.node.Owner"; /** private ctor */ @@ -151,22 +156,34 @@ static void initializeIntegrationsAndProcessors( final boolean isAndroidXScrollViewAvailable = loadClass.isClassAvailable("androidx.core.view.ScrollingView", options); + final boolean isComposeUpstreamAvailable = + loadClass.isClassAvailable(COMPOSE_CLASS_NAME, options); if (options.getGestureTargetLocators().isEmpty()) { final List gestureTargetLocators = new ArrayList<>(2); gestureTargetLocators.add(new AndroidViewGestureTargetLocator(isAndroidXScrollViewAvailable)); - final boolean isComposeUpstreamAvailable = - loadClass.isClassAvailable(COMPOSE_CLASS_NAME, options); final boolean isComposeAvailable = (isComposeUpstreamAvailable - && loadClass.isClassAvailable(SENTRY_COMPOSE_INTEGRATION_CLASS_NAME, options)); + && loadClass.isClassAvailable( + SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME, options)); if (isComposeAvailable) { - gestureTargetLocators.add(new ComposeGestureTargetLocator()); + gestureTargetLocators.add(new ComposeGestureTargetLocator(options.getLogger())); } options.setGestureTargetLocators(gestureTargetLocators); } + + if (options.getViewHierarchyExporters().isEmpty() + && isComposeUpstreamAvailable + && loadClass.isClassAvailable( + SENTRY_COMPOSE_VIEW_HIERARCHY_INTEGRATION_CLASS_NAME, options)) { + + final List viewHierarchyExporters = new ArrayList<>(1); + viewHierarchyExporters.add(new ComposeViewHierarchyExporter(options.getLogger())); + options.setViewHierarchyExporters(viewHierarchyExporters); + } + options.setMainThreadChecker(AndroidMainThreadChecker.getInstance()); if (options.getCollectors().isEmpty()) { options.addCollector(new AndroidMemoryCollector()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index 6c051cb7961..3cd1874abb5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -14,6 +14,7 @@ import io.sentry.SentryLevel; import io.sentry.android.core.internal.gestures.ViewUtils; import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; import io.sentry.protocol.ViewHierarchy; import io.sentry.protocol.ViewHierarchyNode; import io.sentry.util.HintUtils; @@ -60,7 +61,11 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); final @Nullable ViewHierarchy viewHierarchy = - snapshotViewHierarchy(activity, options.getMainThreadChecker(), options.getLogger()); + snapshotViewHierarchy( + activity, + options.getViewHierarchyExporters(), + options.getMainThreadChecker(), + options.getLogger()); if (viewHierarchy != null) { hint.setViewHierarchy(Attachment.fromViewHierarchy(viewHierarchy)); @@ -74,8 +79,10 @@ public static byte[] snapshotViewHierarchyAsData( @NotNull IMainThreadChecker mainThreadChecker, @NotNull ISerializer serializer, @NotNull ILogger logger) { + @Nullable - ViewHierarchy viewHierarchy = snapshotViewHierarchy(activity, mainThreadChecker, logger); + ViewHierarchy viewHierarchy = + snapshotViewHierarchy(activity, new ArrayList<>(0), mainThreadChecker, logger); if (viewHierarchy == null) { logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy."); @@ -98,15 +105,18 @@ public static byte[] snapshotViewHierarchyAsData( @Nullable public static ViewHierarchy snapshotViewHierarchy( - @Nullable Activity activity, @NotNull ILogger logger) { - return snapshotViewHierarchy(activity, AndroidMainThreadChecker.getInstance(), logger); + final @Nullable Activity activity, final @NotNull ILogger logger) { + return snapshotViewHierarchy( + activity, new ArrayList<>(0), AndroidMainThreadChecker.getInstance(), logger); } @Nullable public static ViewHierarchy snapshotViewHierarchy( - @Nullable Activity activity, - @NotNull IMainThreadChecker mainThreadChecker, - @NotNull ILogger logger) { + final @Nullable Activity activity, + final @NotNull List exporters, + final @NotNull IMainThreadChecker mainThreadChecker, + final @NotNull ILogger logger) { + if (activity == null) { logger.log(SentryLevel.INFO, "Missing activity for view hierarchy snapshot."); return null; @@ -126,14 +136,14 @@ public static ViewHierarchy snapshotViewHierarchy( try { if (mainThreadChecker.isMainThread()) { - return snapshotViewHierarchy(decorView); + return snapshotViewHierarchy(decorView, exporters); } else { final CountDownLatch latch = new CountDownLatch(1); final AtomicReference viewHierarchy = new AtomicReference<>(null); activity.runOnUiThread( () -> { try { - viewHierarchy.set(snapshotViewHierarchy(decorView)); + viewHierarchy.set(snapshotViewHierarchy(decorView, exporters)); latch.countDown(); } catch (Throwable t) { logger.log(SentryLevel.ERROR, "Failed to process view hierarchy.", t); @@ -150,23 +160,39 @@ public static ViewHierarchy snapshotViewHierarchy( } @NotNull - public static ViewHierarchy snapshotViewHierarchy(@NotNull final View view) { + public static ViewHierarchy snapshotViewHierarchy(final @NotNull View view) { + return snapshotViewHierarchy(view, new ArrayList<>(0)); + } + + @NotNull + public static ViewHierarchy snapshotViewHierarchy( + final @NotNull View view, final @NotNull List exporters) { final List windows = new ArrayList<>(1); final ViewHierarchy viewHierarchy = new ViewHierarchy("android_view_system", windows); final @NotNull ViewHierarchyNode node = viewToNode(view); windows.add(node); - addChildren(view, node); + addChildren(view, node, exporters); return viewHierarchy; } private static void addChildren( - @NotNull final View view, @NotNull final ViewHierarchyNode parentNode) { + final @NotNull View view, + final @NotNull ViewHierarchyNode parentNode, + final @NotNull List exporters) { if (!(view instanceof ViewGroup)) { return; } + // In case any external exporter recognizes it's own widget (e.g. AndroidComposeView) + // we can immediately return + for (ViewHierarchyExporter exporter : exporters) { + if (exporter.export(parentNode, view)) { + return; + } + } + final @NotNull ViewGroup viewGroup = ((ViewGroup) view); final int childCount = viewGroup.getChildCount(); if (childCount == 0) { @@ -179,7 +205,7 @@ private static void addChildren( if (child != null) { final @NotNull ViewHierarchyNode childNode = viewToNode(child); childNodes.add(childNode); - addChildren(child, childNode); + addChildren(child, childNode, exporters); } } parentNode.setChildren(childNodes); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 18bd55bba0d..a8e00f299a7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -559,7 +559,7 @@ class AndroidOptionsInitializerTest { fixture.initSutWithClassLoader( classesToLoad = listOf( AndroidOptionsInitializer.COMPOSE_CLASS_NAME, - AndroidOptionsInitializer.SENTRY_COMPOSE_INTEGRATION_CLASS_NAME + AndroidOptionsInitializer.SENTRY_COMPOSE_GESTURE_INTEGRATION_CLASS_NAME ) ) diff --git a/sentry-compose-helper/api/sentry-compose-helper.api b/sentry-compose-helper/api/sentry-compose-helper.api index 3903641972a..058e4312760 100644 --- a/sentry-compose-helper/api/sentry-compose-helper.api +++ b/sentry-compose-helper/api/sentry-compose-helper.api @@ -1,5 +1,10 @@ +public class io/sentry/compose/SentryComposeHelper { + public fun (Lio/sentry/ILogger;)V + public fun getLayoutNodeBoundsInWindow (Landroidx/compose/ui/node/LayoutNode;)Landroidx/compose/ui/geometry/Rect; +} + public final class io/sentry/compose/gestures/ComposeGestureTargetLocator : io/sentry/internal/gestures/GestureTargetLocator { - public fun ()V + public fun (Lio/sentry/ILogger;)V 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 { public static final field VERSION_NAME Ljava/lang/String; } +public final class io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter : io/sentry/internal/viewhierarchy/ViewHierarchyExporter { + public fun (Lio/sentry/ILogger;)V + public fun export (Lio/sentry/protocol/ViewHierarchyNode;Ljava/lang/Object;)Z +} + diff --git a/sentry-compose-helper/build.gradle.kts b/sentry-compose-helper/build.gradle.kts index b28ac4cbccf..4bdb1b1f990 100644 --- a/sentry-compose-helper/build.gradle.kts +++ b/sentry-compose-helper/build.gradle.kts @@ -22,6 +22,16 @@ kotlin { compileOnly(compose.ui) } } + val jvmTest by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.ui) + + implementation(Config.TestLibs.kotlinTestJunit) + implementation(Config.TestLibs.mockitoKotlin) + implementation(Config.TestLibs.mockitoInline) + } + } } } diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/SentryComposeHelper.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/SentryComposeHelper.java new file mode 100644 index 00000000000..f90e961c897 --- /dev/null +++ b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/SentryComposeHelper.java @@ -0,0 +1,41 @@ +package io.sentry.compose; + +import androidx.compose.ui.geometry.Rect; +import androidx.compose.ui.layout.LayoutCoordinatesKt; +import androidx.compose.ui.node.LayoutNode; +import androidx.compose.ui.node.LayoutNodeLayoutDelegate; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import java.lang.reflect.Field; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SentryComposeHelper { + + private final @NotNull ILogger logger; + private Field layoutDelegateField = null; + + public SentryComposeHelper(final @NotNull ILogger logger) { + this.logger = logger; + try { + final Class clazz = Class.forName("androidx.compose.ui.node.LayoutNode"); + layoutDelegateField = clazz.getDeclaredField("layoutDelegate"); + layoutDelegateField.setAccessible(true); + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Could not find LayoutNode.layoutDelegate field"); + } + } + + public @Nullable Rect getLayoutNodeBoundsInWindow(@NotNull final LayoutNode node) { + if (layoutDelegateField != null) { + try { + final LayoutNodeLayoutDelegate delegate = + (LayoutNodeLayoutDelegate) layoutDelegateField.get(node); + return LayoutCoordinatesKt.boundsInWindow(delegate.getOuterCoordinator().getCoordinates()); + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Could not fetch position for LayoutNode", e); + } + } + return null; + } +} diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java index db45a48e001..7bee6b79f6e 100644 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java +++ b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -1,13 +1,15 @@ package io.sentry.compose.gestures; -import androidx.compose.ui.layout.LayoutCoordinatesKt; +import androidx.compose.ui.geometry.Rect; import androidx.compose.ui.layout.ModifierInfo; import androidx.compose.ui.node.LayoutNode; import androidx.compose.ui.node.Owner; import androidx.compose.ui.semantics.SemanticsConfiguration; import androidx.compose.ui.semantics.SemanticsModifier; import androidx.compose.ui.semantics.SemanticsPropertyKey; +import io.sentry.ILogger; import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.compose.SentryComposeHelper; import io.sentry.compose.helper.BuildConfig; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; @@ -21,7 +23,10 @@ @SuppressWarnings("KotlinInternalInJava") public final class ComposeGestureTargetLocator implements GestureTargetLocator { - public ComposeGestureTargetLocator() { + private final @NotNull SentryComposeHelper composeHelper; + + public ComposeGestureTargetLocator(final @NotNull ILogger logger) { + this.composeHelper = new SentryComposeHelper(logger); SentryIntegrationPackageStorage.getInstance().addIntegration("ComposeUserInteraction"); SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-compose", BuildConfig.VERSION_NAME); @@ -45,7 +50,7 @@ public ComposeGestureTargetLocator() { continue; } - if (node.isPlaced() && layoutNodeBoundsContain(node, x, y)) { + if (node.isPlaced() && layoutNodeBoundsContain(composeHelper, node, x, y)) { boolean isClickable = false; boolean isScrollable = false; @Nullable String testTag = null; @@ -63,7 +68,7 @@ public ComposeGestureTargetLocator() { isScrollable = true; } else if ("OnClick".equals(key)) { isClickable = true; - } else if ("TestTag".equals(key)) { + } else if ("SentryTag".equals(key) || "TestTag".equals(key)) { if (entry.getValue() instanceof String) { testTag = (String) entry.getValue(); } @@ -92,16 +97,19 @@ public ComposeGestureTargetLocator() { } private static boolean layoutNodeBoundsContain( - @NotNull LayoutNode node, final float x, final float y) { - final int nodeHeight = node.getHeight(); - final int nodeWidth = node.getWidth(); - - // Offset is a Kotlin value class, packing x/y into a long - // TODO find a way to use the existing APIs - final long nodePosition = LayoutCoordinatesKt.positionInWindow(node.getCoordinates()); - final int nodeX = (int) Float.intBitsToFloat((int) (nodePosition >> 32)); - final int nodeY = (int) Float.intBitsToFloat((int) (nodePosition)); + @NotNull SentryComposeHelper composeHelper, + @NotNull LayoutNode node, + final float x, + final float y) { - return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight); + final @Nullable Rect bounds = composeHelper.getLayoutNodeBoundsInWindow(node); + if (bounds == null) { + return false; + } else { + return x >= bounds.getLeft() + && x <= bounds.getRight() + && y >= bounds.getTop() + && y <= bounds.getBottom(); + } } } diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java new file mode 100644 index 00000000000..e9cf3a7764c --- /dev/null +++ b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java @@ -0,0 +1,124 @@ +package io.sentry.compose.viewhierarchy; + +import androidx.compose.runtime.collection.MutableVector; +import androidx.compose.ui.geometry.Rect; +import androidx.compose.ui.layout.ModifierInfo; +import androidx.compose.ui.node.LayoutNode; +import androidx.compose.ui.node.Owner; +import androidx.compose.ui.semantics.SemanticsConfiguration; +import androidx.compose.ui.semantics.SemanticsModifier; +import androidx.compose.ui.semantics.SemanticsPropertyKey; +import io.sentry.ILogger; +import io.sentry.compose.SentryComposeHelper; +import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.protocol.ViewHierarchyNode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("KotlinInternalInJava") +public final class ComposeViewHierarchyExporter implements ViewHierarchyExporter { + + @NotNull private final ILogger logger; + + public ComposeViewHierarchyExporter(@NotNull final ILogger logger) { + this.logger = logger; + } + + @Override + public boolean export(@NotNull final ViewHierarchyNode parent, @NotNull final Object element) { + + if (!(element instanceof Owner)) { + return false; + } + + final SentryComposeHelper composeHelper = new SentryComposeHelper(logger); + + final @NotNull LayoutNode rootNode = ((Owner) element).getRoot(); + addChild(composeHelper, parent, null, rootNode); + return true; + } + + private static void addChild( + @NotNull final SentryComposeHelper composeHelper, + @NotNull final ViewHierarchyNode parent, + @Nullable final LayoutNode parentNode, + @NotNull final LayoutNode node) { + if (node.isPlaced()) { + final ViewHierarchyNode vhNode = new ViewHierarchyNode(); + setTag(node, vhNode); + setBounds(composeHelper, node, parentNode, vhNode); + + if (vhNode.getTag() != null) { + vhNode.setType(vhNode.getTag()); + } else { + vhNode.setType("@Composable"); + } + + if (parent.getChildren() == null) { + parent.setChildren(new ArrayList<>()); + } + parent.getChildren().add(vhNode); + + final MutableVector children = node.getZSortedChildren(); + final int childrenCount = children.getSize(); + for (int i = 0; i < childrenCount; i++) { + final LayoutNode child = children.get(i); + addChild(composeHelper, vhNode, node, child); + } + } + } + + private static void setTag( + final @NotNull LayoutNode node, final @NotNull ViewHierarchyNode vhNode) { + final List modifiers = node.getModifierInfo(); + for (ModifierInfo modifierInfo : modifiers) { + if (modifierInfo.getModifier() instanceof SemanticsModifier) { + final SemanticsModifier semanticsModifierCore = + (SemanticsModifier) modifierInfo.getModifier(); + final SemanticsConfiguration semanticsConfiguration = + semanticsModifierCore.getSemanticsConfiguration(); + for (Map.Entry, ?> entry : semanticsConfiguration) { + final @Nullable String key = entry.getKey().getName(); + if ("SentryTag".equals(key) || "TestTag".equals(key)) { + if (entry.getValue() instanceof String) { + vhNode.setTag((String) entry.getValue()); + } + } + } + } + } + } + + private static void setBounds( + final @NotNull SentryComposeHelper composeHelper, + final @NotNull LayoutNode node, + final @Nullable LayoutNode parentNode, + final @NotNull ViewHierarchyNode vhNode) { + + final int nodeHeight = node.getHeight(); + final int nodeWidth = node.getWidth(); + + vhNode.setHeight((double) nodeHeight); + vhNode.setWidth((double) nodeWidth); + + final Rect bounds = composeHelper.getLayoutNodeBoundsInWindow(node); + if (bounds != null) { + double x = bounds.getLeft(); + double y = bounds.getTop(); + // layout coordinates for view hierarchy are relative to the parent node + if (parentNode != null) { + final @Nullable Rect parentBounds = composeHelper.getLayoutNodeBoundsInWindow(parentNode); + if (parentBounds != null) { + x -= parentBounds.getLeft(); + y -= parentBounds.getTop(); + } + } + + vhNode.setX(x); + vhNode.setY(y); + } + } +} diff --git a/sentry-compose-helper/src/jvmTest/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.java b/sentry-compose-helper/src/jvmTest/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.java new file mode 100644 index 00000000000..a7289124811 --- /dev/null +++ b/sentry-compose-helper/src/jvmTest/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.java @@ -0,0 +1,106 @@ +package io.sentry.compose.viewhierarchy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import androidx.compose.runtime.collection.MutableVector; +import androidx.compose.ui.layout.LayoutCoordinates; +import androidx.compose.ui.layout.ModifierInfo; +import androidx.compose.ui.node.LayoutNode; +import androidx.compose.ui.node.Owner; +import androidx.compose.ui.semantics.SemanticsConfiguration; +import androidx.compose.ui.semantics.SemanticsModifier; +import androidx.compose.ui.semantics.SemanticsPropertyKey; +import io.sentry.NoOpLogger; +import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.protocol.ViewHierarchyNode; +import java.util.ArrayList; +import java.util.List; +import kotlin.jvm.functions.Function2; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.Test; +import org.mockito.Mockito; + +public class ComposeViewHierarchyExporterTest { + + @Test + public void testComposeViewHierarchyExport() { + final ViewHierarchyNode rootVhNode = new ViewHierarchyNode(); + + final LayoutNode childA = mockLayoutNode(true, "childA", 10, 20); + final LayoutNode childB = mockLayoutNode(true, null, 10, 20); + final LayoutNode childC = mockLayoutNode(false, null, 10, 20); + final LayoutNode parent = mockLayoutNode(true, "root", 30, 40, childA, childB, childC); + + final Owner node = Mockito.mock(Owner.class); + Mockito.when(node.getRoot()).thenReturn(parent); + + final ViewHierarchyExporter exporter = + new ComposeViewHierarchyExporter(NoOpLogger.getInstance()); + exporter.export(rootVhNode, node); + + assertEquals(1, rootVhNode.getChildren().size()); + final ViewHierarchyNode parentVhNode = rootVhNode.getChildren().get(0); + + assertEquals("root", parentVhNode.getTag()); + assertEquals(30.0, parentVhNode.getWidth().doubleValue(), 0.001); + assertEquals(40.0, parentVhNode.getHeight().doubleValue(), 0.001); + + // ensure not placed elements (childC) are not part of the view hierarchy + assertEquals(2, parentVhNode.getChildren().size()); + + final ViewHierarchyNode childAVhNode = parentVhNode.getChildren().get(0); + assertEquals("childA", childAVhNode.getTag()); + assertEquals(10.0, childAVhNode.getWidth().doubleValue(), 0.001); + assertEquals(20.0, childAVhNode.getHeight().doubleValue(), 0.001); + assertNull(childAVhNode.getChildren()); + + final ViewHierarchyNode childBVhNode = parentVhNode.getChildren().get(1); + assertNull(childBVhNode.getTag()); + } + + private static LayoutNode mockLayoutNode( + final boolean isPlaced, + final @Nullable String tag, + final int width, + final int height, + LayoutNode... children) { + final LayoutNode nodeA = Mockito.mock(LayoutNode.class); + Mockito.when(nodeA.isPlaced()).thenReturn(isPlaced); + Mockito.when((nodeA.getWidth())).thenReturn(width); + Mockito.when((nodeA.getHeight())).thenReturn(height); + + final ModifierInfo modifierInfo = Mockito.mock(ModifierInfo.class); + Mockito.when(modifierInfo.getModifier()) + .thenReturn( + new SemanticsModifier() { + @NotNull + @Override + public SemanticsConfiguration getSemanticsConfiguration() { + final SemanticsConfiguration config = new SemanticsConfiguration(); + config.set( + new SemanticsPropertyKey<>( + "SentryTag", + new Function2() { + @Override + public String invoke(String s, String s2) { + return s; + } + }), + tag); + return config; + } + }); + final List modifierInfoList = new ArrayList<>(); + modifierInfoList.add(modifierInfo); + Mockito.when((nodeA.getModifierInfo())).thenReturn(modifierInfoList); + + Mockito.when((nodeA.getZSortedChildren())) + .thenReturn(new MutableVector<>(children, children.length)); + + final LayoutCoordinates coordinates = Mockito.mock(LayoutCoordinates.class); + Mockito.when(nodeA.getCoordinates()).thenReturn(coordinates); + return nodeA; + } +} diff --git a/sentry-compose/api/android/sentry-compose.api b/sentry-compose/api/android/sentry-compose.api index 21fb2160ff9..f8da7ecfb06 100644 --- a/sentry-compose/api/android/sentry-compose.api +++ b/sentry-compose/api/android/sentry-compose.api @@ -10,6 +10,13 @@ public final class io/sentry/compose/SentryComposeTracingKt { public static final fun SentryTraced (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } +public final class io/sentry/compose/SentryModifier { + public static final field $stable I + public static final field INSTANCE Lio/sentry/compose/SentryModifier; + public static final field TAG Ljava/lang/String; + public static final fun sentryTag (Landroidx/compose/ui/Modifier;Ljava/lang/String;)Landroidx/compose/ui/Modifier; +} + public final class io/sentry/compose/SentryNavigationIntegrationKt { public static final fun withSentryObservableEffect (Landroidx/navigation/NavHostController;ZZLandroidx/compose/runtime/Composer;II)Landroidx/navigation/NavHostController; } diff --git a/sentry-compose/proguard-rules.pro b/sentry-compose/proguard-rules.pro index a711d8c0068..31025505f67 100644 --- a/sentry-compose/proguard-rules.pro +++ b/sentry-compose/proguard-rules.pro @@ -2,7 +2,12 @@ # The Android SDK checks at runtime if these classes are available via Class.forName -keep class io.sentry.compose.gestures.ComposeGestureTargetLocator { (...); } +-keep class io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter { (...); } + -keepnames interface androidx.compose.ui.node.Owner +-keepclassmembers class androidx.compose.ui.node.LayoutNode { + private androidx.compose.ui.node.LayoutNodeLayoutDelegate layoutDelegate; +} # R8 will warn about missing classes if people don't have androidx.compose-navigation on their # classpath, but this is fine, these classes are used in an internal class which is only used when diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt index e86b7943f44..84e5925ac4e 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt @@ -9,10 +9,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.platform.testTag import io.sentry.ISpan import io.sentry.Sentry import io.sentry.SpanOptions +import io.sentry.compose.SentryModifier.sentryTag private const val OP_PARENT_COMPOSITION = "ui.compose.composition" private const val OP_COMPOSE = "ui.compose" @@ -74,7 +74,7 @@ public fun SentryTraced( val compositionSpan = parentCompositionSpan.item?.startChild(OP_COMPOSE, tag) val firstRendered = remember { ImmutableHolder(false) } - val baseModifier = if (enableUserInteractionTracing) modifier.testTag(tag) else modifier + val baseModifier = if (enableUserInteractionTracing) Modifier.sentryTag(tag) else modifier Box( modifier = baseModifier diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt new file mode 100644 index 00000000000..f1f43c9c8bb --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -0,0 +1,29 @@ +package io.sentry.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.semantics + +public object SentryModifier { + + public const val TAG: String = "SentryTag" + + // Based on TestTag + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt;l=166;drc=76bc6975d1b520c545b6f8786ff5c9f0bc22bd1f + private val SentryTag = SemanticsPropertyKey( + name = TAG, + mergePolicy = { parentValue, _ -> + // Never merge SentryTags, to avoid leaking internal test tags to parents. + parentValue + } + ) + + @JvmStatic + public fun Modifier.sentryTag(tag: String): Modifier { + return semantics( + properties = { + this[SentryTag] = tag + } + ) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c15750ead00..7d8f94ec5c8 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1707,6 +1707,7 @@ public class io/sentry/SentryOptions { public fun getTransactionProfiler ()Lio/sentry/ITransactionProfiler; public fun getTransportFactory ()Lio/sentry/ITransportFactory; public fun getTransportGate ()Lio/sentry/transport/ITransportGate; + public final fun getViewHierarchyExporters ()Ljava/util/List; public fun isAttachServerName ()Z public fun isAttachStacktrace ()Z public fun isAttachThreads ()Z @@ -1804,6 +1805,7 @@ public class io/sentry/SentryOptions { public fun setTransactionProfiler (Lio/sentry/ITransactionProfiler;)V public fun setTransportFactory (Lio/sentry/ITransportFactory;)V public fun setTransportGate (Lio/sentry/transport/ITransportGate;)V + public fun setViewHierarchyExporters (Ljava/util/List;)V } public abstract interface class io/sentry/SentryOptions$BeforeBreadcrumbCallback { @@ -2697,6 +2699,10 @@ public final class io/sentry/internal/modules/ResourcesModulesLoader : io/sentry public fun (Lio/sentry/ILogger;)V } +public abstract interface class io/sentry/internal/viewhierarchy/ViewHierarchyExporter { + public abstract fun export (Lio/sentry/protocol/ViewHierarchyNode;Ljava/lang/Object;)Z +} + public final class io/sentry/profilemeasurements/ProfileMeasurement : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field ID_CPU_USAGE Ljava/lang/String; public static final field ID_FROZEN_FRAME_RENDERS Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 27bb8274475..03a9b25227a 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -8,6 +8,7 @@ import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.modules.IModulesLoader; import io.sentry.internal.modules.NoOpModulesLoader; +import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryTransaction; import io.sentry.transport.ITransport; @@ -394,9 +395,15 @@ public class SentryOptions { /** Which framework is responsible for instrumenting. */ private @NotNull Instrumenter instrumenter = Instrumenter.SENTRY; - /** Contains a list of GestureTargetLocator instances used for user interaction tracking * */ + /** Contains a list of GestureTargetLocator instances used for user interaction tracking */ private final @NotNull List gestureTargetLocators = new ArrayList<>(); + /** + * Contains a list of ViewHierarchyExporter instances used for extracting non Android system View + * Hierarchy elements + */ + private final @NotNull List viewHierarchyExporters = new ArrayList<>(); + private @NotNull IMainThreadChecker mainThreadChecker = NoOpMainThreadChecker.getInstance(); // TODO this should default to false on the next major @@ -1969,6 +1976,27 @@ public void setGestureTargetLocators(@NotNull final List l gestureTargetLocators.addAll(locators); } + /** + * Returns a list of all {@link ViewHierarchyExporter} instances used to export view hierarchy + * information. + * + * @return a list of {@link ViewHierarchyExporter} + */ + @NotNull + public final List getViewHierarchyExporters() { + return viewHierarchyExporters; + } + + /** + * Sets the list of {@link ViewHierarchyExporter} being used to export the view hierarchy. + * + * @param exporters a list of {@link ViewHierarchyExporter} + */ + public void setViewHierarchyExporters(@NotNull final List exporters) { + viewHierarchyExporters.clear(); + viewHierarchyExporters.addAll(exporters); + } + public @NotNull IMainThreadChecker getMainThreadChecker() { return mainThreadChecker; } diff --git a/sentry/src/main/java/io/sentry/internal/viewhierarchy/ViewHierarchyExporter.java b/sentry/src/main/java/io/sentry/internal/viewhierarchy/ViewHierarchyExporter.java new file mode 100644 index 00000000000..e4ebdf4273b --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/viewhierarchy/ViewHierarchyExporter.java @@ -0,0 +1,16 @@ +package io.sentry.internal.viewhierarchy; + +import io.sentry.protocol.ViewHierarchyNode; +import org.jetbrains.annotations.NotNull; + +public interface ViewHierarchyExporter { + /** + * Exports the view hierarchy + * + * @param parent the parent view hierarchy node to which element should be attached to + * @param element The UI widget + * @return true if element was processed and the corresponding view hierarchy was attached to + * parent, false otherwise + */ + boolean export(@NotNull final ViewHierarchyNode parent, @NotNull final Object element); +}