diff --git a/CHANGELOG.md b/CHANGELOG.md index f004b7d8eba..4d136fd9c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - The `MANIFEST.MF` of `sentry-opentelemetry-agent` now has `Implementation-Version` set to the raw version ([#4291](https://github.com/getsentry/sentry-java/pull/4291)) - An example value would be `8.6.0` - The value of the `Sentry-Version-Name` attribute looks like `sentry-8.5.0-otel-2.10.0` +- Fix tags missing for compose view hierarchies ([#4275](https://github.com/getsentry/sentry-java/pull/4275)) ### Internal diff --git a/build.gradle.kts b/build.gradle.kts index 38ff7b04393..08890a3de69 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -155,7 +155,7 @@ subprojects { } } - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support" && this.name != "sentry-compose-helper") { + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support") { apply() apply() diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 7203a0327f0..326b9cd18df 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -81,7 +81,6 @@ dependencies { compileOnly(projects.sentryAndroidTimber) compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) - compileOnly(projects.sentryComposeHelper) // lifecycle processor, session tracking implementation(Config.Libs.lifecycleProcess) @@ -109,7 +108,7 @@ dependencies { testImplementation(projects.sentryAndroidFragment) testImplementation(projects.sentryAndroidTimber) testImplementation(projects.sentryAndroidReplay) - testImplementation(projects.sentryComposeHelper) + testImplementation(projects.sentryCompose) testImplementation(projects.sentryAndroidNdk) testRuntimeOnly(Config.Libs.composeUi) testRuntimeOnly(Config.Libs.timber) diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index c817467a063..22164de9776 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -97,7 +97,6 @@ dependencies { if (applySentryIntegrations) { implementation(projects.sentryAndroid) implementation(projects.sentryCompose) - implementation(projects.sentryComposeHelper) } else { implementation(projects.sentryAndroidCore) } diff --git a/sentry-bom/build.gradle.kts b/sentry-bom/build.gradle.kts index 8af147e82db..48ec2bda58c 100644 --- a/sentry-bom/build.gradle.kts +++ b/sentry-bom/build.gradle.kts @@ -9,8 +9,7 @@ dependencies { .filter { !it.name.startsWith("sentry-samples") && it.name != project.name && - !it.name.contains("test", ignoreCase = true) && - it.name != "sentry-compose-helper" + !it.name.contains("test", ignoreCase = true) } .forEach { project -> evaluationDependsOn(project.path) diff --git a/sentry-compose-helper/README.md b/sentry-compose-helper/README.md deleted file mode 100644 index f5b900ddffb..00000000000 --- a/sentry-compose-helper/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Sentry Compose Helper Library - -This utility library is used to access internal Jetpack Compose APIs using Java. - -Due to [this open issue](https://youtrack.jetbrains.com/issue/KT-30878) you can not have -java sources in a KMP-enabled project which has the android-lib plugin applied. -Thus we place all relevant java code in this library for compilation, -and embed it as part of `sentry-compose`. - -Once the above issue is resolved, the code of this module can be safely moved to `sentry-compose`. diff --git a/sentry-compose-helper/api/sentry-compose-helper.api b/sentry-compose-helper/api/sentry-compose-helper.api deleted file mode 100644 index 058e4312760..00000000000 --- a/sentry-compose-helper/api/sentry-compose-helper.api +++ /dev/null @@ -1,22 +0,0 @@ -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 (Lio/sentry/ILogger;)V - public fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement; -} - -public final class io/sentry/compose/helper/BuildConfig { - public static final field $stable I - public static final field INSTANCE Lio/sentry/compose/helper/BuildConfig; - public static final field SENTRY_COMPOSE_HELPER_SDK_NAME Ljava/lang/String; - 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 deleted file mode 100644 index be5637280ec..00000000000 --- a/sentry-compose-helper/build.gradle.kts +++ /dev/null @@ -1,67 +0,0 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -plugins { - kotlin("multiplatform") - id("org.jetbrains.compose") - `java-library` - id(Config.QualityPlugins.gradleVersions) - id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion -} - -kotlin { - jvm { - withJava() - } - - sourceSets { - val jvmMain by getting { - dependencies { - implementation(projects.sentry) - - compileOnly(compose.runtime) - compileOnly(compose.ui) - - compileOnly(Config.Libs.androidxAnnotation) - } - } - val jvmTest by getting { - dependencies { - implementation(compose.runtime) - implementation(compose.ui) - - compileOnly(Config.Libs.androidxAnnotation) - implementation(Config.TestLibs.kotlinTestJunit) - implementation(Config.TestLibs.mockitoKotlin) - implementation(Config.TestLibs.mockitoInline) - } - } - } -} - -configure { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() -} - -val embeddedJar by configurations.creating { - isCanBeConsumed = true - isCanBeResolved = false -} - -artifacts { - add("embeddedJar", project.layout.buildDirectory.file("libs/sentry-compose-helper-jvm-$version.jar").get().asFile) -} - -buildConfig { - sourceSets.getByName("jvmMain") { - useKotlinOutput() - className("BuildConfig") - packageName("io.sentry.compose.helper") - buildConfigField("String", "SENTRY_COMPOSE_HELPER_SDK_NAME", "\"${Config.Sentry.SENTRY_COMPOSE_HELPER_SDK_NAME}\"") - buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") - } -} 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 deleted file mode 100644 index f90e961c897..00000000000 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/SentryComposeHelper.java +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 3c2286298f1..00000000000 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java +++ /dev/null @@ -1,162 +0,0 @@ -package io.sentry.compose.gestures; - -import androidx.compose.ui.Modifier; -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.ISentryLifecycleToken; -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; -import io.sentry.util.AutoClosableReentrantLock; -import java.lang.reflect.Field; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@SuppressWarnings("KotlinInternalInJava") -public final class ComposeGestureTargetLocator implements GestureTargetLocator { - - private static final String ORIGIN = "jetpack_compose"; - - private final @NotNull ILogger logger; - private volatile @Nullable SentryComposeHelper composeHelper; - private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); - - static { - SentryIntegrationPackageStorage.getInstance() - .addPackage("maven:io.sentry:sentry-compose", BuildConfig.VERSION_NAME); - } - - public ComposeGestureTargetLocator(final @NotNull ILogger logger) { - this.logger = logger; - SentryIntegrationPackageStorage.getInstance().addIntegration("ComposeUserInteraction"); - } - - @Override - public @Nullable UiElement locate( - @Nullable Object root, float x, float y, UiElement.Type targetType) { - - // lazy init composeHelper as it's using some reflection under the hood - if (composeHelper == null) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - if (composeHelper == null) { - composeHelper = new SentryComposeHelper(logger); - } - } - } - - if (!(root instanceof Owner)) { - return null; - } - - final @NotNull Queue queue = new LinkedList<>(); - queue.add(((Owner) root).getRoot()); - - // the final tag to return - @Nullable String targetTag = null; - - // the last known tag when iterating the node tree - @Nullable String lastKnownTag = null; - while (!queue.isEmpty()) { - final @Nullable LayoutNode node = queue.poll(); - if (node == null) { - continue; - } - - if (node.isPlaced() && layoutNodeBoundsContain(composeHelper, node, x, y)) { - boolean isClickable = false; - boolean isScrollable = false; - - 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 ("ScrollBy".equals(key)) { - isScrollable = true; - } else if ("OnClick".equals(key)) { - isClickable = true; - } else if ("SentryTag".equals(key) || "TestTag".equals(key)) { - if (entry.getValue() instanceof String) { - lastKnownTag = (String) entry.getValue(); - } - } - } - } else { - final @NotNull Modifier modifier = modifierInfo.getModifier(); - // Newer Jetpack Compose 1.5 uses Node modifiers for clicks/scrolls - final @Nullable String type = modifier.getClass().getCanonicalName(); - if ("androidx.compose.foundation.ClickableElement".equals(type) - || "androidx.compose.foundation.CombinedClickableElement".equals(type)) { - isClickable = true; - } else if ("androidx.compose.foundation.ScrollingLayoutElement".equals(type)) { - isScrollable = true; - } else if ("androidx.compose.ui.platform.TestTagElement".equals(type)) { - // Newer Jetpack Compose uses TestTagElement as node elements - // See - // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TestTag.kt;l=34;drc=dcaa116fbfda77e64a319e1668056ce3b032469f - try { - final Field tagField = modifier.getClass().getDeclaredField("tag"); - tagField.setAccessible(true); - final @Nullable Object value = tagField.get(modifier); - if (value instanceof String) { - lastKnownTag = (String) value; - } - } catch (Throwable e) { - // ignored - } - } - } - } - - if (isClickable && targetType == UiElement.Type.CLICKABLE) { - targetTag = lastKnownTag; - } - if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { - targetTag = lastKnownTag; - // skip any children for scrollable targets - break; - } - } - queue.addAll(node.getZSortedChildren().asMutableList()); - } - - if (targetTag == null) { - return null; - } else { - return new UiElement(null, null, null, targetTag, ORIGIN); - } - } - - private static boolean layoutNodeBoundsContain( - @NotNull SentryComposeHelper composeHelper, - @NotNull LayoutNode node, - final float x, - final float y) { - - 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 deleted file mode 100644 index 6568b495c35..00000000000 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java +++ /dev/null @@ -1,135 +0,0 @@ -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.ISentryLifecycleToken; -import io.sentry.compose.SentryComposeHelper; -import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; -import io.sentry.protocol.ViewHierarchyNode; -import io.sentry.util.AutoClosableReentrantLock; -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; - @Nullable private volatile SentryComposeHelper composeHelper; - private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); - - 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; - } - - // lazy init composeHelper as it's using some reflection under the hood - if (composeHelper == null) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - if (composeHelper == null) { - 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 deleted file mode 100644 index a7289124811..00000000000 --- a/sentry-compose-helper/src/jvmTest/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.java +++ /dev/null @@ -1,106 +0,0 @@ -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 728e248dd31..59d4828ec7f 100644 --- a/sentry-compose/api/android/sentry-compose.api +++ b/sentry-compose/api/android/sentry-compose.api @@ -6,6 +6,10 @@ public final class io/sentry/compose/BuildConfig { public fun ()V } +public final class io/sentry/compose/SentryComposeHelperKt { + public static final fun boundsInWindow (Landroidx/compose/ui/layout/LayoutCoordinates;Landroidx/compose/ui/layout/LayoutCoordinates;)Landroidx/compose/ui/geometry/Rect; +} + 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 } @@ -22,3 +26,19 @@ public final class io/sentry/compose/SentryNavigationIntegrationKt { public static final fun withSentryObservableEffect (Landroidx/navigation/NavHostController;ZZLandroidx/compose/runtime/Composer;II)Landroidx/navigation/NavHostController; } +public final class io/sentry/compose/gestures/ComposeGestureTargetLocator : io/sentry/internal/gestures/GestureTargetLocator { + public static final field $stable I + public static final field Companion Lio/sentry/compose/gestures/ComposeGestureTargetLocator$Companion; + public fun (Lio/sentry/ILogger;)V + public fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement; +} + +public final class io/sentry/compose/gestures/ComposeGestureTargetLocator$Companion { +} + +public final class io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter : io/sentry/internal/viewhierarchy/ViewHierarchyExporter { + public static final field $stable I + public fun (Lio/sentry/ILogger;)V + public fun export (Lio/sentry/protocol/ViewHierarchyNode;Ljava/lang/Object;)Z +} + diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index df8c4dae202..cd80f2a8009 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -1,4 +1,4 @@ -import com.android.build.gradle.internal.tasks.LibraryAarJarsTask + import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.dokka.gradle.DokkaTask @@ -42,8 +42,6 @@ kotlin { dependencies { compileOnly(compose.runtime) compileOnly(compose.ui) - - compileOnly(projects.sentryComposeHelper) } } val androidMain by getting { @@ -136,23 +134,3 @@ tasks.withType().configureEach { } } } - -/** - * Due to https://youtrack.jetbrains.com/issue/KT-30878 - * you can not have java sources in a KMP-enabled project which has the android-lib plugin applied. - * Thus we compile relevant java code in sentry-compose-helper first and embed it in here. - */ -val embedComposeHelperConfig by configurations.creating { - isCanBeConsumed = false - isCanBeResolved = true -} - -dependencies { - embedComposeHelperConfig( - project(":" + projects.sentryComposeHelper.name, "embeddedJar") - ) -} - -tasks.withType { - mainScopeClassFiles.setFrom(embedComposeHelperConfig) -} diff --git a/sentry-compose/proguard-rules.pro b/sentry-compose/proguard-rules.pro index 372d2db1db0..666251ddda0 100644 --- a/sentry-compose/proguard-rules.pro +++ b/sentry-compose/proguard-rules.pro @@ -13,6 +13,7 @@ -keepnames class androidx.compose.foundation.CombinedClickableElement -keepnames class androidx.compose.foundation.ScrollingLayoutElement -keepnames class androidx.compose.ui.platform.TestTagElement { *; } +-keepnames class io.sentry.compose.SentryModifier.SentryTagModifierNodeElement { *; } # 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/SentryComposeHelper.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt new file mode 100644 index 00000000000..c22ef056752 --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt @@ -0,0 +1,163 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.semantics.SemanticsModifier +import io.sentry.ILogger +import io.sentry.SentryLevel +import java.lang.reflect.Field + +internal class SentryComposeHelper(logger: ILogger) { + + private val testTagElementField: Field? = + loadField(logger, "androidx.compose.ui.platform.TestTagElement", "tag") + + private val sentryTagElementField: Field? = + loadField(logger, "io.sentry.compose.SentryModifier.SentryTagModifierNodeElement", "tag") + + fun extractTag(modifier: Modifier): String? { + val type = modifier.javaClass.canonicalName + // Newer Jetpack Compose uses TestTagElement as node elements + // See + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TestTag.kt;l=34;drc=dcaa116fbfda77e64a319e1668056ce3b032469f + try { + if ("androidx.compose.ui.platform.TestTagElement" == type && + testTagElementField != null + ) { + val value = testTagElementField.get(modifier) + return value as String? + } else if ("io.sentry.compose.SentryModifier.SentryTagModifierNodeElement" == type && + sentryTagElementField != null + ) { + val value = sentryTagElementField.get(modifier) + return value as String? + } + } catch (e: Throwable) { + // ignored + } + + // Older versions use SemanticsModifier + if (modifier is SemanticsModifier) { + val semanticsConfiguration = + modifier.semanticsConfiguration + for ((item, value) in semanticsConfiguration) { + val key = item.name + if ("SentryTag" == key || "TestTag" == key) { + if (value is String) { + return value + } + } + } + } + return null + } + + companion object { + private fun loadField( + logger: ILogger, + className: String, + fieldName: String + ): Field? { + try { + val clazz = Class.forName(className) + val field = clazz.getDeclaredField(fieldName) + field.isAccessible = true + return field + } catch (e: Exception) { + logger.log(SentryLevel.WARNING, "Could not load $className.$fieldName field") + } + return null + } + } +} + +/** + * Copied from sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt + * + * A faster copy of https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187 + * + * Since we traverse the tree from the root, we don't need to find it again from the leaf node and + * just pass it as an argument. + * + * @return boundaries of this layout relative to the window's origin. + */ +public fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates?): Rect { + val root = rootCoordinates ?: findRootCoordinates() + + val rootWidth = root.size.width.toFloat() + val rootHeight = root.size.height.toFloat() + + val bounds = root.localBoundingBoxOf(this) + val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) + val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) + val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) + val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) + + if (boundsLeft == boundsRight || boundsTop == boundsBottom) { + return Rect.Zero + } + + val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) + val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) + val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) + val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) + + val topLeftX = topLeft.x + val topRightX = topRight.x + val bottomLeftX = bottomLeft.x + val bottomRightX = bottomRight.x + + val left = fastMinOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + val right = fastMaxOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + + val topLeftY = topLeft.y + val topRightY = topRight.y + val bottomLeftY = bottomLeft.y + val bottomRightY = bottomRight.y + + val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + + return Rect(left, top, right, bottom) +} + +/** + * Returns the smaller of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.minOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float { + return minOf(a, minOf(b, minOf(c, d))) +} + +/** + * Returns the largest of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.maxOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private fun fastMaxOf(a: Float, b: Float, c: Float, d: Float): Float { + return maxOf(a, maxOf(b, maxOf(c, d))) +} + +/** + * Returns this float value clamped in the inclusive range defined by [minimumValue] and + * [maximumValue]. Unlike [Float.coerceIn], the range is not validated: the caller must ensure that + * [minimumValue] is less than [maximumValue]. + */ +private fun Float.fastCoerceIn(minimumValue: Float, maximumValue: Float) = + this.fastCoerceAtLeast(minimumValue).fastCoerceAtMost(maximumValue) + +/** Ensures that this value is not less than the specified [minimumValue]. */ +private fun Float.fastCoerceAtLeast(minimumValue: Float): Float { + return if (this < minimumValue) minimumValue else this +} + +/** Ensures that this value is not greater than the specified [maximumValue]. */ +private fun Float.fastCoerceAtMost(maximumValue: Float): Float { + return if (this > maximumValue) maximumValue else this +} diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt new file mode 100644 index 00000000000..d630bf6ed3b --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -0,0 +1,145 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.compose.gestures + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsModifier +import io.sentry.ILogger +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.compose.BuildConfig +import io.sentry.compose.SentryComposeHelper +import io.sentry.compose.boundsInWindow +import io.sentry.internal.gestures.GestureTargetLocator +import io.sentry.internal.gestures.UiElement +import io.sentry.util.AutoClosableReentrantLock +import java.util.LinkedList +import java.util.Queue + +@OptIn(InternalComposeUiApi::class) +public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureTargetLocator { + @Volatile + private var composeHelper: SentryComposeHelper? = null + private val lock = AutoClosableReentrantLock() + + init { + SentryIntegrationPackageStorage.getInstance().addPackage("maven:io.sentry:sentry-compose", BuildConfig.VERSION_NAME) + } + + override fun locate( + root: Any?, + x: Float, + y: Float, + targetType: UiElement.Type + ): UiElement? { + if (root !is Owner) { + return null + } + + // lazy init composeHelper as it's using some reflection under the hood + if (composeHelper == null) { + lock.acquire().use { + if (composeHelper == null) { + composeHelper = SentryComposeHelper(logger) + } + } + } + + val rootLayoutNode = root.root + + val queue: Queue = LinkedList() + queue.add(rootLayoutNode) + + // the final tag to return + var targetTag: String? = null + + // the last known tag when iterating the node tree + var lastKnownTag: String? = null + while (!queue.isEmpty()) { + val node = queue.poll() ?: continue + if (node.isPlaced && layoutNodeBoundsContain( + rootLayoutNode, + node, + x, + y + ) + ) { + var isClickable = false + var isScrollable = false + + val modifiers = node.getModifierInfo() + for (modifierInfo in modifiers) { + val tag = composeHelper!!.extractTag(modifierInfo.modifier) + if (tag != null) { + lastKnownTag = tag + } + + if (modifierInfo.modifier is SemanticsModifier) { + val semanticsModifierCore = + modifierInfo.modifier as SemanticsModifier + val semanticsConfiguration = + semanticsModifierCore.semanticsConfiguration + + for (item in semanticsConfiguration) { + val key: String = item.key.name + if ("ScrollBy" == key) { + isScrollable = true + } else if ("OnClick" == key) { + isClickable = true + } + } + } else { + val modifier = modifierInfo.modifier + // Newer Jetpack Compose 1.5 uses Node modifiers for clicks/scrolls + val type = modifier.javaClass.canonicalName + if ("androidx.compose.foundation.ClickableElement" == type || + "androidx.compose.foundation.CombinedClickableElement" == type + ) { + isClickable = true + } else if ("androidx.compose.foundation.ScrollingLayoutElement" == type) { + isScrollable = true + } + } + } + + if (isClickable && targetType == UiElement.Type.CLICKABLE) { + targetTag = lastKnownTag + } + if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { + targetTag = lastKnownTag + // skip any children for scrollable targets + break + } + } + queue.addAll(node.zSortedChildren.asMutableList()) + } + + return if (targetTag == null) { + null + } else { + UiElement( + null, + null, + null, + targetTag, + ORIGIN + ) + } + } + + private fun layoutNodeBoundsContain( + root: LayoutNode, + node: LayoutNode, + x: Float, + y: Float + ): Boolean { + val bounds = node.coordinates.boundsInWindow(root.coordinates) + return bounds.contains(Offset(x, y)) + } + + public companion object { + private const val ORIGIN = "jetpack_compose" + } +} diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.kt new file mode 100644 index 00000000000..ab6814fbfb3 --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.kt @@ -0,0 +1,92 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.compose.viewhierarchy + +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import io.sentry.ILogger +import io.sentry.compose.SentryComposeHelper +import io.sentry.internal.viewhierarchy.ViewHierarchyExporter +import io.sentry.protocol.ViewHierarchyNode +import io.sentry.util.AutoClosableReentrantLock + +public class ComposeViewHierarchyExporter public constructor(private val logger: ILogger) : + ViewHierarchyExporter { + @Volatile + private var composeHelper: SentryComposeHelper? = null + private val lock = AutoClosableReentrantLock() + + override fun export(parent: ViewHierarchyNode, element: Any): Boolean { + if (element !is Owner) { + return false + } + + // lazy init composeHelper as it's using some reflection under the hood + if (composeHelper == null) { + lock.acquire().use { + if (composeHelper == null) { + composeHelper = SentryComposeHelper(logger) + } + } + } + + val rootNode = element.root + addChild(composeHelper!!, parent, rootNode, rootNode) + return true + } + + private fun addChild( + composeHelper: SentryComposeHelper, + parent: ViewHierarchyNode, + rootNode: LayoutNode, + node: LayoutNode + ) { + if (node.isPlaced) { + val vhNode = ViewHierarchyNode() + setTag(composeHelper, node, vhNode) + setBounds(node, vhNode) + vhNode.type = vhNode.tag ?: "@Composable" + + if (parent.children == null) { + parent.children = ArrayList() + } + parent.children!!.add(vhNode) + + val children = node.zSortedChildren + val childrenCount = children.size + for (i in 0 until childrenCount) { + val child = children[i] + addChild(composeHelper, vhNode, rootNode, child) + } + } + } + + private fun setTag( + helper: SentryComposeHelper, + node: LayoutNode, + vhNode: ViewHierarchyNode + ) { + // needs to be in-sync with ComposeGestureTargetLocator + val modifiers = node.getModifierInfo() + for (modifierInfo in modifiers) { + val tag = helper.extractTag(modifierInfo.modifier) + if (tag != null) { + vhNode.tag = tag + } + } + } + + private fun setBounds( + node: LayoutNode, + vhNode: ViewHierarchyNode + ) { + // layout coordinates for view hierarchy are relative to the parent node + val bounds = node.coordinates.boundsInParent() + + vhNode.x = bounds.left.toDouble() + vhNode.y = bounds.top.toDouble() + vhNode.height = bounds.height.toDouble() + vhNode.width = bounds.width.toDouble() + } +} diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/ComposeIntegrationTests.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/ComposeIntegrationTests.kt new file mode 100644 index 00000000000..01e96dc0919 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/ComposeIntegrationTests.kt @@ -0,0 +1,110 @@ +package io.sentry.compose + +import android.app.Application +import android.content.ComponentName +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.core.view.children +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.NoOpLogger +import io.sentry.compose.SentryModifier.sentryTag +import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter +import io.sentry.protocol.ViewHierarchyNode +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ComposeIntegrationTests { + + // workaround for robolectric tests with composeRule + // from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + @get:Rule(order = 1) + val addActivityToRobolectricRule = object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + Shadows.shadowOf(appContext.packageManager).addActivityIfNotPresent( + ComponentName( + appContext.packageName, + ComponentActivity::class.java.name + ) + ) + } + } + + @get:Rule(order = 2) + val rule = createAndroidComposeRule() + + @Test + fun `Compose View Hierarchy is exported with the correct tags`() { + rule.setContent { + Column { + Box(modifier = Modifier.sentryTag("sentryTag")) + Box(modifier = Modifier.testTag("testTag")) + } + } + + rule.activityRule.scenario.onActivity { activity -> + val exporter = ComposeViewHierarchyExporter(NoOpLogger.getInstance()) + val root = ViewHierarchyNode() + val rootView = activity.findViewById(android.R.id.content) + val rootComposeView = locateAndroidComposeView(rootView) + assertNotNull(rootComposeView) + + exporter.export(root, rootComposeView) + + assertNotNull(locateNodeByTag(root, "sentryTag")) + assertNotNull(locateNodeByTag(root, "testTag")) + } + } + + private fun locateAndroidComposeView(root: View?): Any? { + if (root == null) { + return null + } + if (root.javaClass.name == "androidx.compose.ui.platform.AndroidComposeView") { + return root + } + if (root is ViewGroup) { + for (child in root.children) { + val found = locateAndroidComposeView(child) + if (found != null) { + return found + } + } + } + return null + } + + private fun locateNodeByTag(root: ViewHierarchyNode, tag: String): ViewHierarchyNode? { + if (root.tag == tag) { + return root + } + + val children = root.children + if (children != null) { + for (child in children) { + val found = locateNodeByTag(child, tag) + if (found != null) { + return found + } + } + } + + return null + } +} diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt index 38aa2585d3f..f4c8dc545c5 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt @@ -6,7 +6,7 @@ import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.ui.Modifier import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.compose.SentryModifier.sentryTag @@ -43,7 +43,7 @@ class SentryModifierComposeTest { } @get:Rule(order = 2) - val rule = createComposeRule() + val rule = createAndroidComposeRule() @Test fun sentryModifierAppliesTag() { diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.kt new file mode 100644 index 00000000000..dff518d2d93 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.kt @@ -0,0 +1,113 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.compose.viewhierarchy + +import androidx.compose.runtime.collection.mutableVectorOf +import androidx.compose.ui.geometry.Rect +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 org.junit.Assert +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ComposeViewHierarchyExporterTest { + @Test + fun testComposeViewHierarchyExport() { + val rootVhNode = ViewHierarchyNode() + + val childA = mockLayoutNode(true, "childA", 10, 20) + val childB = mockLayoutNode(true, null, 10, 20) + val childC = mockLayoutNode(false, null, 10, 20) + val parent = mockLayoutNode(true, "root", 30, 40, listOf(childA, childB, childC)) + + val node = mock() + whenever(node.root).thenReturn(parent) + + val exporter: ViewHierarchyExporter = + ComposeViewHierarchyExporter(NoOpLogger.getInstance()) + exporter.export(rootVhNode, node) + + Assert.assertEquals(1, rootVhNode.children!!.size.toLong()) + val parentVhNode = rootVhNode.children!![0] + + Assert.assertEquals("root", parentVhNode.tag) + Assert.assertEquals(30.0, parentVhNode.width!!, 0.001) + Assert.assertEquals(40.0, parentVhNode.height!!, 0.001) + + // ensure not placed elements (childC) are not part of the view hierarchy + Assert.assertEquals(2, parentVhNode.children!!.size.toLong()) + + val childAVhNode = parentVhNode.children!![0] + Assert.assertEquals("childA", childAVhNode.tag) + Assert.assertEquals(10.0, childAVhNode.width!!, 0.001) + Assert.assertEquals(20.0, childAVhNode.height!!, 0.001) + Assert.assertNull(childAVhNode.children) + + val childBVhNode = parentVhNode.children!![1] + Assert.assertNull(childBVhNode.tag) + } + + companion object { + private fun mockLayoutNode( + isPlaced: Boolean, + tag: String?, + width: Int, + height: Int, + children: List = emptyList() + ): LayoutNode { + val nodeA = Mockito.mock( + LayoutNode::class.java + ) + whenever(nodeA.isPlaced).thenReturn(isPlaced) + + val modifierInfo = Mockito.mock( + ModifierInfo::class.java + ) + whenever(modifierInfo.modifier) + .thenReturn( + object : SemanticsModifier { + override val semanticsConfiguration: SemanticsConfiguration + get() { + val config = SemanticsConfiguration() + config.set( + SemanticsPropertyKey( + "SentryTag" + ) { s: String?, s2: String? -> s }, + tag + ) + return config + } + } + ) + val modifierInfoList: MutableList = ArrayList() + modifierInfoList.add(modifierInfo) + whenever((nodeA.getModifierInfo())).thenReturn(modifierInfoList) + + whenever((nodeA.zSortedChildren)) + .thenReturn(mutableVectorOf().apply { addAll(children) }) + + val coordinates = Mockito.mock( + LayoutCoordinates::class.java + ) + val parentCoordinates = Mockito.mock( + LayoutCoordinates::class.java + ) + whenever(coordinates.parentLayoutCoordinates).thenReturn(parentCoordinates) + whenever(parentCoordinates.localBoundingBoxOf(any(), any())) + .thenReturn(Rect(0f, 0f, width.toFloat(), height.toFloat())) + whenever(nodeA.coordinates).thenReturn(coordinates) + return nodeA + } + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 04772af8aa0..cba0dfd2d77 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -126,7 +126,6 @@ dependencies { implementation(projects.sentryAndroidFragment) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) - implementation(projects.sentryComposeHelper) implementation(projects.sentryOkhttp) implementation(Config.Libs.fragment) implementation(Config.Libs.timber) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4c642f1abd6..44cbd8720ca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,7 +22,6 @@ include( "sentry-android-sqlite", "sentry-android-replay", "sentry-compose", - "sentry-compose-helper", "sentry-apollo", "sentry-apollo-3", "sentry-apollo-4",