From 8be4802ac64d23e51507120f6c3f5572a9f11b7d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 23 Nov 2022 15:27:33 +0100 Subject: [PATCH 01/24] Add integration for Compose clickables --- .../android/core/SentryAndroidOptions.java | 23 +----- .../compose/SentryClickableIntegration.kt | 77 +++++++++++++++++++ .../main/java/io/sentry/SentryOptions.java | 22 ++++++ 3 files changed, 100 insertions(+), 22 deletions(-) create mode 100644 sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index d58ea76314..66916a7103 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -39,8 +39,6 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable automatic breadcrumbs for App Components Using ComponentCallbacks */ private boolean enableAppComponentBreadcrumbs = true; - /** Enable or disable automatic breadcrumbs for User interactions Using Window.Callback */ - private boolean enableUserInteractionBreadcrumbs = true; /** * Enables the Auto instrumentation for Activity lifecycle tracing. @@ -93,9 +91,6 @@ public final class SentryAndroidOptions extends SentryOptions { */ private int profilingTracesHz = 101; - /** Enables the Auto instrumentation for user interaction tracing. */ - private boolean enableUserInteractionTracing = false; - /** Interface that loads the debug images list */ private @NotNull IDebugImagesLoader debugImagesLoader = NoOpDebugImagesLoader.getInstance(); @@ -241,14 +236,6 @@ public void setEnableAppComponentBreadcrumbs(boolean enableAppComponentBreadcrum this.enableAppComponentBreadcrumbs = enableAppComponentBreadcrumbs; } - public boolean isEnableUserInteractionBreadcrumbs() { - return enableUserInteractionBreadcrumbs; - } - - public void setEnableUserInteractionBreadcrumbs(boolean enableUserInteractionBreadcrumbs) { - this.enableUserInteractionBreadcrumbs = enableUserInteractionBreadcrumbs; - } - /** * Enable or disable all the automatic breadcrumbs * @@ -259,7 +246,7 @@ public void enableAllAutoBreadcrumbs(boolean enable) { enableAppComponentBreadcrumbs = enable; enableSystemEventBreadcrumbs = enable; enableAppLifecycleBreadcrumbs = enable; - enableUserInteractionBreadcrumbs = enable; + setEnableUserInteractionBreadcrumbs(enable); } /** @@ -343,14 +330,6 @@ public void setAttachScreenshot(boolean attachScreenshot) { this.attachScreenshot = attachScreenshot; } - public boolean isEnableUserInteractionTracing() { - return enableUserInteractionTracing; - } - - public void setEnableUserInteractionTracing(boolean enableUserInteractionTracing) { - this.enableUserInteractionTracing = enableUserInteractionTracing; - } - public boolean isCollectAdditionalContext() { return collectAdditionalContext; } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt new file mode 100644 index 0000000000..97a6a6b6d1 --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt @@ -0,0 +1,77 @@ +package io.sentry.compose + +import io.sentry.Breadcrumb +import io.sentry.IHub +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.TransactionNameSource + +public fun wrapClickable(clickable: () -> Unit, clickLabel: String?): () -> Unit { + return { + val hub = Sentry.getCurrentHub() + if (clickLabel.isNullOrEmpty()) { + hub.options + .logger + .log( + SentryLevel.DEBUG, + "Modifier.clickable clickLabel is null, skipping breadcrumb/transaction creation.", + ) + } else { + addBreadcrumb(hub, clickLabel) + startTransaction(hub, clickLabel) + } + + clickable() + } +} + +private fun addBreadcrumb(hub: IHub, label: String) { + if (!hub.options.isEnableUserInteractionBreadcrumbs) { + return + } + + val breadcrumb = Breadcrumb.userInteraction( + "action.click", + label, + null, + emptyMap() + ) + hub.addBreadcrumb(breadcrumb) +} + +private fun startTransaction(hub: IHub, label: String) { + if (!(hub.options.isTracingEnabled && hub.options.isEnableUserInteractionTracing)) { + return + } + + val transactionOptions = TransactionOptions().apply { + isWaitForChildren = true + idleTimeout = hub.options.idleTimeout + isTrimEnd = true + } + + val transaction: ITransaction = hub.startTransaction( + TransactionContext(label, TransactionNameSource.COMPONENT, "ui.action.click"), + transactionOptions + ) + + hub.configureScope { scope: Scope? -> + scope?.withTransaction { scopeTransaction: ITransaction? -> + if (scopeTransaction == null) { + scope.transaction = transaction + } else { + hub.options + .logger + .log( + SentryLevel.DEBUG, + "Transaction '%s' won't be bound to the Scope since there's one already in there.", + transaction.name + ) + } + } + } +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index e7c0980c2b..b5a3d1fe68 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -361,6 +361,12 @@ public class SentryOptions { /** Modules (dependencies, packages) that will be send along with each event. */ private @NotNull IModulesLoader modulesLoader = NoOpModulesLoader.getInstance(); + /** Enables the Auto instrumentation for user interaction tracing. */ + private boolean enableUserInteractionTracing = false; + + /** Enable or disable automatic breadcrumbs for User interactions Using Window.Callback */ + private boolean enableUserInteractionBreadcrumbs = true; + /** * Adds an event processor * @@ -1741,6 +1747,22 @@ public void setSendClientReports(boolean sendClientReports) { } } + public boolean isEnableUserInteractionTracing() { + return enableUserInteractionTracing; + } + + public void setEnableUserInteractionTracing(boolean enableUserInteractionTracing) { + this.enableUserInteractionTracing = enableUserInteractionTracing; + } + + public boolean isEnableUserInteractionBreadcrumbs() { + return enableUserInteractionBreadcrumbs; + } + + public void setEnableUserInteractionBreadcrumbs(boolean enableUserInteractionBreadcrumbs) { + this.enableUserInteractionBreadcrumbs = enableUserInteractionBreadcrumbs; + } + /** * Returns a ClientReportRecorder or a NoOp if sending of client reports has been disabled. * From 7efaaffffc2cc2892c29a882744a9295527cd2c2 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 23 Nov 2022 15:28:23 +0100 Subject: [PATCH 02/24] Fix only auto-create breadcrumbs for clicks when option is enabled --- .../core/internal/gestures/SentryGestureListener.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index a076bd6bf4..40c8310643 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -181,6 +181,11 @@ private void addBreadcrumb( final @NotNull String eventType, final @NotNull Map additionalData, final @NotNull MotionEvent motionEvent) { + + if (hub.getOptions().isEnableUserInteractionBreadcrumbs()) { + return; + } + @NotNull String className; @Nullable String canonicalName = target.getClass().getCanonicalName(); if (canonicalName != null) { From 56325fd004d880fd2e478c4629158c1ec65d1e36 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 23 Nov 2022 15:39:24 +0100 Subject: [PATCH 03/24] Fix missing .api file update and formatting --- sentry-android-core/api/sentry-android-core.api | 4 ---- .../java/io/sentry/android/core/SentryAndroidOptions.java | 1 - sentry-compose/api/android/sentry-compose.api | 4 ++++ .../kotlin/io/sentry/compose/SentryClickableIntegration.kt | 2 +- sentry/api/sentry.api | 4 ++++ 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 17dca925d3..278581e1f2 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -159,8 +159,6 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableFramesTracking ()Z public fun isEnableSystemEventBreadcrumbs ()Z - public fun isEnableUserInteractionBreadcrumbs ()Z - public fun isEnableUserInteractionTracing ()Z public fun setAnrEnabled (Z)V public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V @@ -174,8 +172,6 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableFramesTracking (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V - public fun setEnableUserInteractionBreadcrumbs (Z)V - public fun setEnableUserInteractionTracing (Z)V public fun setProfilingTracesHz (I)V public fun setProfilingTracesIntervalMillis (I)V } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 66916a7103..ca1f9a03c1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -39,7 +39,6 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable automatic breadcrumbs for App Components Using ComponentCallbacks */ private boolean enableAppComponentBreadcrumbs = true; - /** * Enables the Auto instrumentation for Activity lifecycle tracing. * diff --git a/sentry-compose/api/android/sentry-compose.api b/sentry-compose/api/android/sentry-compose.api index e30b863f6b..bb0f41fe73 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/SentryClickableIntegrationKt { + public static final fun wrapClickable (Lkotlin/jvm/functions/Function0;Ljava/lang/String;)Lkotlin/jvm/functions/Function0; +} + 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/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt index 97a6a6b6d1..27658450be 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt @@ -18,7 +18,7 @@ public fun wrapClickable(clickable: () -> Unit, clickLabel: String?): () -> Unit .logger .log( SentryLevel.DEBUG, - "Modifier.clickable clickLabel is null, skipping breadcrumb/transaction creation.", + "Modifier.clickable clickLabel is null, skipping breadcrumb/transaction creation." ) } else { addBreadcrumb(hub, clickLabel) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d514145178..28dd061262 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1420,6 +1420,8 @@ public class io/sentry/SentryOptions { public fun isEnableScopeSync ()Z public fun isEnableShutdownHook ()Z public fun isEnableUncaughtExceptionHandler ()Z + public fun isEnableUserInteractionBreadcrumbs ()Z + public fun isEnableUserInteractionTracing ()Z public fun isPrintUncaughtStackTrace ()Z public fun isProfilingEnabled ()Z public fun isSendClientReports ()Z @@ -1446,6 +1448,8 @@ public class io/sentry/SentryOptions { public fun setEnableScopeSync (Z)V public fun setEnableShutdownHook (Z)V public fun setEnableUncaughtExceptionHandler (Z)V + public fun setEnableUserInteractionBreadcrumbs (Z)V + public fun setEnableUserInteractionTracing (Z)V public fun setEnvelopeDiskCache (Lio/sentry/cache/IEnvelopeCache;)V public fun setEnvelopeReader (Lio/sentry/IEnvelopeReader;)V public fun setEnvironment (Ljava/lang/String;)V From b58a8d77185df422f47eea66d2e5a8ebb564111f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 24 Nov 2022 07:48:59 +0100 Subject: [PATCH 04/24] Only create clickable transaction when there's none running --- .../compose/SentryClickableIntegration.kt | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt index 27658450be..54fb583071 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt @@ -21,8 +21,8 @@ public fun wrapClickable(clickable: () -> Unit, clickLabel: String?): () -> Unit "Modifier.clickable clickLabel is null, skipping breadcrumb/transaction creation." ) } else { - addBreadcrumb(hub, clickLabel) startTransaction(hub, clickLabel) + addBreadcrumb(hub, clickLabel) } clickable() @@ -48,20 +48,19 @@ private fun startTransaction(hub: IHub, label: String) { return } - val transactionOptions = TransactionOptions().apply { - isWaitForChildren = true - idleTimeout = hub.options.idleTimeout - isTrimEnd = true - } - - val transaction: ITransaction = hub.startTransaction( - TransactionContext(label, TransactionNameSource.COMPONENT, "ui.action.click"), - transactionOptions - ) - hub.configureScope { scope: Scope? -> scope?.withTransaction { scopeTransaction: ITransaction? -> if (scopeTransaction == null) { + val transactionOptions = TransactionOptions().apply { + isWaitForChildren = true + idleTimeout = hub.options.idleTimeout + isTrimEnd = true + } + + val transaction: ITransaction = hub.startTransaction( + TransactionContext(label, TransactionNameSource.COMPONENT, "ui.action.click"), + transactionOptions + ) scope.transaction = transaction } else { hub.options @@ -69,7 +68,7 @@ private fun startTransaction(hub: IHub, label: String) { .log( SentryLevel.DEBUG, "Transaction '%s' won't be bound to the Scope since there's one already in there.", - transaction.name + label ) } } From 611beca188d278c86c10b4794ab9f75350d91a46 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 30 Nov 2022 11:21:03 +0100 Subject: [PATCH 05/24] Determine Compose click and scroll targets at runtime Used for automatic breadcrumbs and transaction starts --- .../gestures/AndroidComposeViewUtils.java | 102 +++++++++++++++++ .../gestures/SentryGestureListener.java | 86 ++++++-------- .../core/internal/gestures/ViewUtils.java | 107 +++++++++++++++--- .../src/main/java/io/sentry/Breadcrumb.java | 25 ++++ 4 files changed, 253 insertions(+), 67 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidComposeViewUtils.java diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidComposeViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidComposeViewUtils.java new file mode 100644 index 0000000000..0ef893d95f --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidComposeViewUtils.java @@ -0,0 +1,102 @@ +package io.sentry.android.core.internal.gestures; + +import android.view.View; +import androidx.compose.ui.layout.LayoutCoordinatesKt; +import androidx.compose.ui.layout.ModifierInfo; +import androidx.compose.ui.node.LayoutNode; +import androidx.compose.ui.platform.AndroidComposeView; +import androidx.compose.ui.semantics.SemanticsActions; +import androidx.compose.ui.semantics.SemanticsConfiguration; +import androidx.compose.ui.semantics.SemanticsModifier; +import androidx.compose.ui.semantics.SemanticsProperties; +import io.sentry.util.Objects; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("KotlinInternalInJava") +final class AndroidComposeViewUtils { + static boolean isComposeView(View view) { + return view instanceof AndroidComposeView; + } + + static @Nullable ViewUtils.UiElement findTarget( + final @NotNull View view, + final float x, + final float y, + final ViewUtils.TargetType targetType) { + @Nullable String targetTag = null; + + if (!isComposeView(view)) { + return null; + } + + final AndroidComposeView androidComposeView = (AndroidComposeView) view; + final LayoutNode root = androidComposeView.getRoot(); + + final Queue queue = new LinkedList<>(); + queue.add(root); + + while (!queue.isEmpty()) { + final LayoutNode node = Objects.requireNonNull(queue.poll(), "layoutnode is required"); + + if (node.isPlaced() && nodeBoundsContains(node, x, y)) { + boolean isClickable = false; + boolean isScrollable = false; + @Nullable String testTag = null; + + final List modifiers = node.getModifierInfo(); + for (ModifierInfo modifier : modifiers) { + if (modifier.getModifier() instanceof SemanticsModifier) { + final SemanticsModifier semanticsModifierCore = + ((SemanticsModifier) modifier.getModifier()); + final SemanticsConfiguration semanticsConfiguration = + semanticsModifierCore.getSemanticsConfiguration(); + + isScrollable = + isScrollable + || semanticsConfiguration.contains(SemanticsActions.INSTANCE.getScrollBy()); + isClickable = + isClickable + || semanticsConfiguration.contains(SemanticsActions.INSTANCE.getOnClick()); + + if (semanticsConfiguration.contains(SemanticsProperties.INSTANCE.getTestTag())) { + final String newTestTag = + semanticsConfiguration.get(SemanticsProperties.INSTANCE.getTestTag()); + if (newTestTag != null) { + testTag = newTestTag; + } + } + } + } + + if (isClickable && targetType == ViewUtils.TargetType.CLICKABLE) { + targetTag = testTag; + } else if (isScrollable && targetType == ViewUtils.TargetType.SCROLLABLE) { + targetTag = testTag; + // skip any children for scrollable targets + break; + } + } + queue.addAll(node.getChildren$ui_release()); + } + + return ViewUtils.UiElement.create(null, targetTag); + } + + private static boolean nodeBoundsContains( + @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)); + + return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 40c8310643..0bde524062 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -4,7 +4,6 @@ import static io.sentry.TypeCheckHint.ANDROID_VIEW; import android.app.Activity; -import android.content.res.Resources; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; @@ -38,7 +37,7 @@ public final class SentryGestureListener implements GestureDetector.OnGestureLis private final @NotNull SentryAndroidOptions options; private final boolean isAndroidXAvailable; - private @Nullable WeakReference activeView = null; + private @Nullable ViewUtils.UiElement activeUiElement = null; private @Nullable ITransaction activeTransaction = null; private @Nullable String activeEventType = null; @@ -57,7 +56,7 @@ public SentryGestureListener( public void onUp(final @NotNull MotionEvent motionEvent) { final View decorView = ensureWindowDecorView("onUp"); - final View scrollTarget = scrollState.targetRef.get(); + final ViewUtils.UiElement scrollTarget = scrollState.target; if (decorView == null || scrollTarget == null) { return; } @@ -98,12 +97,13 @@ public boolean onSingleTapUp(final @Nullable MotionEvent motionEvent) { } @SuppressWarnings("Convert2MethodRef") - final @Nullable View target = + final @Nullable ViewUtils.UiElement target = ViewUtils.findTarget( + isAndroidXAvailable, decorView, motionEvent.getX(), motionEvent.getY(), - view -> ViewUtils.isViewTappable(view)); + ViewUtils.TargetType.CLICKABLE); if (target == null) { options @@ -129,28 +129,23 @@ public boolean onScroll( } if (scrollState.type == null) { - final @Nullable View target = + final @Nullable ViewUtils.UiElement target = ViewUtils.findTarget( + isAndroidXAvailable, decorView, firstEvent.getX(), firstEvent.getY(), - new ViewTargetSelector() { - @Override - public boolean select(@NotNull View view) { - return ViewUtils.isViewScrollable(view, isAndroidXAvailable); - } - - @Override - public boolean skipChildren() { - return true; - } - }); + ViewUtils.TargetType.SCROLLABLE); if (target == null) { options .getLogger() .log(SentryLevel.DEBUG, "Unable to find scroll target. No breadcrumb captured."); return false; + } else { + options + .getLogger() + .log(SentryLevel.DEBUG, "Scroll target found: " + target.getIdentifier()); } scrollState.setTarget(target); @@ -177,34 +172,31 @@ public void onLongPress(MotionEvent motionEvent) {} // region utils private void addBreadcrumb( - final @NotNull View target, + final @NotNull ViewUtils.UiElement target, final @NotNull String eventType, final @NotNull Map additionalData, final @NotNull MotionEvent motionEvent) { - if (hub.getOptions().isEnableUserInteractionBreadcrumbs()) { + if (!hub.getOptions().isEnableUserInteractionBreadcrumbs()) { return; } - @NotNull String className; - @Nullable String canonicalName = target.getClass().getCanonicalName(); - if (canonicalName != null) { - className = canonicalName; - } else { - className = target.getClass().getSimpleName(); - } - final Hint hint = new Hint(); hint.set(ANDROID_MOTION_EVENT, motionEvent); - hint.set(ANDROID_VIEW, target); + hint.set(ANDROID_VIEW, target.getView()); hub.addBreadcrumb( Breadcrumb.userInteraction( - eventType, ViewUtils.getResourceIdWithFallback(target), className, additionalData), + eventType, + target.getResourceName(), + target.getClassName(), + target.getTag(), + additionalData), hint); } - private void startTracing(final @NotNull View target, final @NotNull String eventType) { + private void startTracing( + final @NotNull ViewUtils.UiElement target, final @NotNull String eventType) { if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) { return; } @@ -215,21 +207,11 @@ private void startTracing(final @NotNull View target, final @NotNull String even return; } - final String viewId; - try { - viewId = ViewUtils.getResourceId(target); - } catch (Resources.NotFoundException e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "View id cannot be retrieved from Resources, no transaction captured."); - return; - } + final @Nullable String viewIdentifier = target.getIdentifier(); + final ViewUtils.UiElement uiElement = activeUiElement; - final View view = (activeView != null) ? activeView.get() : null; if (activeTransaction != null) { - if (target.equals(view) + if (target.equals(uiElement) && eventType.equals(activeEventType) && !activeTransaction.isFinished()) { options @@ -237,7 +219,7 @@ private void startTracing(final @NotNull View target, final @NotNull String even .log( SentryLevel.DEBUG, "The view with id: " - + viewId + + viewIdentifier + " already has an ongoing transaction assigned. Rescheduling finish"); final Long idleTimeout = options.getIdleTimeout(); @@ -255,7 +237,7 @@ private void startTracing(final @NotNull View target, final @NotNull String even } // we can only bind to the scope if there's no running transaction - final String name = getActivityName(activity) + "." + viewId; + final String name = getActivityName(activity) + "." + viewIdentifier; final String op = UI_ACTION + "." + eventType; final TransactionOptions transactionOptions = new TransactionOptions(); @@ -273,7 +255,7 @@ private void startTracing(final @NotNull View target, final @NotNull String even }); activeTransaction = transaction; - activeView = new WeakReference<>(target); + activeUiElement = target; activeEventType = eventType; } @@ -286,8 +268,8 @@ void stopTracing(final @NotNull SpanStatus status) { clearScope(scope); }); activeTransaction = null; - if (activeView != null) { - activeView.clear(); + if (activeUiElement != null) { + activeUiElement = null; } activeEventType = null; } @@ -355,12 +337,12 @@ void applyScope(final @NotNull Scope scope, final @NotNull ITransaction transact // region scroll logic private static final class ScrollState { private @Nullable String type = null; - private WeakReference targetRef = new WeakReference<>(null); + private @Nullable ViewUtils.UiElement target; private float startX = 0f; private float startY = 0f; - private void setTarget(final @NotNull View target) { - targetRef = new WeakReference<>(target); + private void setTarget(final @NotNull ViewUtils.UiElement target) { + this.target = target; } /** @@ -390,7 +372,7 @@ private void setTarget(final @NotNull View target) { } private void reset() { - targetRef.clear(); + target = null; type = null; startX = 0f; startY = 0f; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index a8e84f1be7..b93a5d881e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -8,45 +8,116 @@ import android.widget.ScrollView; import androidx.core.view.ScrollingView; import io.sentry.util.Objects; -import java.util.ArrayDeque; +import java.lang.ref.WeakReference; +import java.util.LinkedList; import java.util.Queue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; final class ViewUtils { + + static class UiElement { + final @NotNull WeakReference viewRef; + final @Nullable String className; + final @Nullable String resourceName; + final @Nullable String tag; + + public static @Nullable UiElement create(@Nullable View view, @Nullable String tag) { + if (view == null && tag == null) { + return null; + } else { + return new UiElement(view, tag); + } + } + + private UiElement(@Nullable View view, @Nullable String tag) { + this.viewRef = new WeakReference<>(view); + if (view != null) { + this.resourceName = getResourceIdWithFallback(view); + @Nullable String canonicalName = view.getClass().getCanonicalName(); + if (canonicalName != null) { + this.className = canonicalName; + } else { + this.className = view.getClass().getSimpleName(); + } + } else { + this.resourceName = null; + this.className = null; + } + this.tag = tag; + } + + public @Nullable String getClassName() { + return className; + } + + public @Nullable String getResourceName() { + return resourceName; + } + + public @Nullable String getTag() { + return tag; + } + + public @NotNull String getIdentifier() { + if (resourceName != null) { + return resourceName; + } else { + return tag; + } + } + + public @Nullable View getView() { + return viewRef.get(); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(viewRef, resourceName, tag); + } + } + + enum TargetType { + CLICKABLE, + SCROLLABLE + } + /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the * given {@code viewTargetSelector}. * + * @param isAndroidXAvailable - true if androidx is available at runtime * @param decorView - the root view of this window * @param x - the x coordinate of a {@link MotionEvent} * @param y - the y coordinate of {@link MotionEvent} - * @param viewTargetSelector - the selector, which defines whether the given view is suitable as a - * target or not. + * @param targetType - the type of target to find * @return the {@link View} that contains the touch coordinates and complements the {@code * viewTargetSelector} */ - static @Nullable View findTarget( + static @Nullable ViewUtils.UiElement findTarget( + boolean isAndroidXAvailable, final @NotNull View decorView, final float x, final float y, - final @NotNull ViewTargetSelector viewTargetSelector) { - Queue queue = new ArrayDeque<>(); + final TargetType targetType) { + final Queue queue = new LinkedList<>(); queue.add(decorView); - @Nullable View target = null; + @Nullable View targetView = null; + // the coordinates variable can be method-local, but we allocate it here, to avoid allocation // in the while- and for-loops int[] coordinates = new int[2]; while (queue.size() > 0) { final View view = Objects.requireNonNull(queue.poll(), "view is required"); - - if (viewTargetSelector.select(view)) { - target = view; - if (viewTargetSelector.skipChildren()) { - return target; - } + if (targetType == TargetType.SCROLLABLE + && ViewUtils.isViewScrollable(view, isAndroidXAvailable)) { + targetView = view; + // skip any children for scrollable targets + break; + } else if (targetType == TargetType.CLICKABLE && ViewUtils.isViewTappable(view)) { + targetView = view; } if (view instanceof ViewGroup) { @@ -58,9 +129,15 @@ final class ViewUtils { } } } - } - return target; + if (AndroidComposeViewUtils.isComposeView(view)) { + final UiElement composeElement = AndroidComposeViewUtils.findTarget(view, x, y, targetType); + if (composeElement != null) { + return composeElement; + } + } + } + return UiElement.create(targetView, null); } private static boolean touchWithinBounds( diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 9d2231eeea..d3924cb6e1 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -237,6 +237,7 @@ public Breadcrumb(final @NotNull Date timestamp) { * @param subCategory - the category, for example "click" * @param viewId - the human-readable view id, for example "button_load" * @param viewClass - the fully qualified class name, for example "android.widget.Button" + * @param viewTag - the custom tag of the view, for example "button_launch_rocket" * @param additionalData - additional properties to be put into the data bag * @return the breadcrumb */ @@ -244,6 +245,7 @@ public Breadcrumb(final @NotNull Date timestamp) { final @NotNull String subCategory, final @Nullable String viewId, final @Nullable String viewClass, + final @Nullable String viewTag, final @NotNull Map additionalData) { final Breadcrumb breadcrumb = new Breadcrumb(); breadcrumb.setType("user"); @@ -254,6 +256,9 @@ public Breadcrumb(final @NotNull Date timestamp) { if (viewClass != null) { breadcrumb.setData("view.class", viewClass); } + if (viewTag != null) { + breadcrumb.setData("view.tag", viewTag); + } for (final Map.Entry entry : additionalData.entrySet()) { breadcrumb.getData().put(entry.getKey(), entry.getValue()); } @@ -261,6 +266,26 @@ public Breadcrumb(final @NotNull Date timestamp) { return breadcrumb; } + /** + * Creates user breadcrumb - a user interaction with your app's UI. The breadcrumb can contain + * additional data like {@code viewId} or {@code viewClass}. By default, the breadcrumb is + * captured with {@link SentryLevel} INFO level. + * + * @param subCategory - the category, for example "click" + * @param viewId - the human-readable view id, for example "button_load" + * @param viewClass - the fully qualified class name, for example "android.widget.Button" + * @param additionalData - additional properties to be put into the data bag + * @return the breadcrumb + */ + public static @NotNull Breadcrumb userInteraction( + final @NotNull String subCategory, + final @Nullable String viewId, + final @Nullable String viewClass, + final @NotNull Map additionalData) { + + return userInteraction(subCategory, viewId, viewClass, null, additionalData); + } + /** Breadcrumb ctor */ public Breadcrumb() { this(DateUtils.getCurrentDateTime()); From f185262239abdc7bf672f8e63b2010e6ca2c04e4 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 30 Nov 2022 11:27:43 +0100 Subject: [PATCH 06/24] Remove obsolete implementation --- .../compose/SentryClickableIntegration.kt | 76 ------------------- 1 file changed, 76 deletions(-) delete mode 100644 sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt deleted file mode 100644 index 54fb583071..0000000000 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryClickableIntegration.kt +++ /dev/null @@ -1,76 +0,0 @@ -package io.sentry.compose - -import io.sentry.Breadcrumb -import io.sentry.IHub -import io.sentry.ITransaction -import io.sentry.Scope -import io.sentry.Sentry -import io.sentry.SentryLevel -import io.sentry.TransactionContext -import io.sentry.TransactionOptions -import io.sentry.protocol.TransactionNameSource - -public fun wrapClickable(clickable: () -> Unit, clickLabel: String?): () -> Unit { - return { - val hub = Sentry.getCurrentHub() - if (clickLabel.isNullOrEmpty()) { - hub.options - .logger - .log( - SentryLevel.DEBUG, - "Modifier.clickable clickLabel is null, skipping breadcrumb/transaction creation." - ) - } else { - startTransaction(hub, clickLabel) - addBreadcrumb(hub, clickLabel) - } - - clickable() - } -} - -private fun addBreadcrumb(hub: IHub, label: String) { - if (!hub.options.isEnableUserInteractionBreadcrumbs) { - return - } - - val breadcrumb = Breadcrumb.userInteraction( - "action.click", - label, - null, - emptyMap() - ) - hub.addBreadcrumb(breadcrumb) -} - -private fun startTransaction(hub: IHub, label: String) { - if (!(hub.options.isTracingEnabled && hub.options.isEnableUserInteractionTracing)) { - return - } - - hub.configureScope { scope: Scope? -> - scope?.withTransaction { scopeTransaction: ITransaction? -> - if (scopeTransaction == null) { - val transactionOptions = TransactionOptions().apply { - isWaitForChildren = true - idleTimeout = hub.options.idleTimeout - isTrimEnd = true - } - - val transaction: ITransaction = hub.startTransaction( - TransactionContext(label, TransactionNameSource.COMPONENT, "ui.action.click"), - transactionOptions - ) - scope.transaction = transaction - } else { - hub.options - .logger - .log( - SentryLevel.DEBUG, - "Transaction '%s' won't be bound to the Scope since there's one already in there.", - label - ) - } - } - } -} From 89260ac476f09b68d66847f80fbf1c774406d8aa Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 30 Nov 2022 11:33:03 +0100 Subject: [PATCH 07/24] Update sentry/src/main/java/io/sentry/SentryOptions.java Co-authored-by: Roman Zavarnitsyn --- sentry/src/main/java/io/sentry/SentryOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 02ffb1b46d..738e4e9923 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -371,7 +371,7 @@ public class SentryOptions { /** Enables the Auto instrumentation for user interaction tracing. */ private boolean enableUserInteractionTracing = false; - /** Enable or disable automatic breadcrumbs for User interactions Using Window.Callback */ + /** Enable or disable automatic breadcrumbs for User interactions */ private boolean enableUserInteractionBreadcrumbs = true; /** Which framework is responsible for instrumenting. */ From 3dfe721b3cfb0b72f0ecc00ac29e31c6385fc6c8 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 30 Nov 2022 10:36:22 +0000 Subject: [PATCH 08/24] Format code --- sentry/src/main/java/io/sentry/SentryOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 738e4e9923..14764eb030 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -371,7 +371,7 @@ public class SentryOptions { /** Enables the Auto instrumentation for user interaction tracing. */ private boolean enableUserInteractionTracing = false; - /** Enable or disable automatic breadcrumbs for User interactions */ + /** Enable or disable automatic breadcrumbs for User interactions */ private boolean enableUserInteractionBreadcrumbs = true; /** Which framework is responsible for instrumenting. */ From 409d152eb212e1d735a76c91d5a8bfc31f5cdd03 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 1 Dec 2022 11:00:48 +0100 Subject: [PATCH 09/24] Enable UserInteractionIntegration according to settings --- .../android/core/UserInteractionIntegration.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index 5c6a736e53..7ede1a6aa3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -112,14 +112,14 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { this.hub = Objects.requireNonNull(hub, "Hub is required"); + final boolean integrationEnabled = + this.options.isEnableUserInteractionBreadcrumbs() + || this.options.isEnableUserInteractionTracing(); this.options .getLogger() - .log( - SentryLevel.DEBUG, - "UserInteractionIntegration enabled: %s", - this.options.isEnableUserInteractionBreadcrumbs()); + .log(SentryLevel.DEBUG, "UserInteractionIntegration enabled: %s", integrationEnabled); - if (this.options.isEnableUserInteractionBreadcrumbs()) { + if (integrationEnabled) { if (isAndroidXAvailable) { application.registerActivityLifecycleCallbacks(this); this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); From 7267040d76374736dada8754941ddb825ad55755 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 5 Dec 2022 09:17:33 +0100 Subject: [PATCH 10/24] Re-structure code, use reflection for compose click/scroll transactions --- sentry-android-core/build.gradle.kts | 1 + .../core/AndroidOptionsInitializer.java | 20 ++ .../android/core/SentryAndroidOptions.java | 14 ++ .../core/UserInteractionIntegration.java | 6 +- .../gestures/AndroidComposeViewUtils.java | 102 --------- .../AndroidViewGestureTargetLocator.java | 80 +++++++ .../gestures/SentryGestureListener.java | 38 ++-- .../core/internal/gestures/ViewUtils.java | 146 ++----------- sentry-compose/build.gradle.kts | 13 +- .../gestures/ComposeGestureTargetLocator.kt | 198 ++++++++++++++++++ .../android/compose/ComposeActivity.kt | 21 +- .../gestures/GestureTargetLocator.java | 11 + .../sentry/internal/gestures/UiElement.java | 59 ++++++ 13 files changed, 442 insertions(+), 267 deletions(-) delete mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidComposeViewUtils.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java create mode 100644 sentry-compose/src/jvmMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt create mode 100644 sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java create mode 100644 sentry/src/main/java/io/sentry/internal/gestures/UiElement.java diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index b60f1f8274..9b8a2623ac 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { api(projects.sentry) compileOnly(projects.sentryAndroidFragment) compileOnly(projects.sentryAndroidTimber) + compileOnly(projects.sentryCompose) // lifecycle processor, session tracking implementation(Config.Libs.lifecycleProcess) 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 821f147119..6831790c36 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 @@ -12,10 +12,13 @@ import io.sentry.SendFireAndForgetOutboxSender; import io.sentry.SentryLevel; import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; +import io.sentry.compose.gestures.ComposeGestureTargetLocator; +import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.Objects; import java.io.BufferedInputStream; @@ -23,6 +26,8 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; import java.util.Properties; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -130,6 +135,21 @@ static void initializeIntegrationsAndProcessors( options.setTransactionProfiler( new AndroidTransactionProfiler(context, options, buildInfoProvider, frameMetricsCollector)); options.setModulesLoader(new AssetsModulesLoader(context, options.getLogger())); + + final boolean isAndroidXScrollViewAvailable = + loadClass.isClassAvailable("androidx.core.view.ScrollingView", options); + final boolean isComposeGestureTargetLocatorAvailable = + loadClass.isClassAvailable( + "io.sentry.compose.gestures.ComposeGestureTargetLocator", options.getLogger()); + + if (options.getGestureTargetLocators().isEmpty()) { + final List gestureTargetLocators = new ArrayList<>(2); + gestureTargetLocators.add(new AndroidViewGestureTargetLocator(isAndroidXScrollViewAvailable)); + if (isComposeGestureTargetLocatorAvailable) { + gestureTargetLocators.add(new ComposeGestureTargetLocator(options)); + } + options.setGestureTargetLocators(gestureTargetLocators); + } } private static void installDefaultIntegrations( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index ca1f9a03c1..413a6c0097 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -5,7 +5,10 @@ import io.sentry.Sentry; import io.sentry.SentryOptions; import io.sentry.SpanStatus; +import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.protocol.SdkVersion; +import java.util.ArrayList; +import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -127,6 +130,8 @@ public final class SentryAndroidOptions extends SentryOptions { private boolean enableFramesTracking = true; + private final @NotNull List gestureTargetLocators = new ArrayList<>(); + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -350,6 +355,15 @@ public void setEnableFramesTracking(boolean enableFramesTracking) { this.enableFramesTracking = enableFramesTracking; } + public List getGestureTargetLocators() { + return gestureTargetLocators; + } + + public void setGestureTargetLocators(@NotNull final List evaluators) { + gestureTargetLocators.clear(); + gestureTargetLocators.addAll(evaluators); + } + /** * Returns the Startup Crash flush timeout in Millis * diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index 7ede1a6aa3..b23d6fcbb2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -25,16 +25,12 @@ public final class UserInteractionIntegration private @Nullable SentryAndroidOptions options; private final boolean isAndroidXAvailable; - private final boolean isAndroidXScrollViewAvailable; public UserInteractionIntegration( final @NotNull Application application, final @NotNull LoadClass classLoader) { this.application = Objects.requireNonNull(application, "Application is required"); - isAndroidXAvailable = classLoader.isClassAvailable("androidx.core.view.GestureDetectorCompat", options); - isAndroidXScrollViewAvailable = - classLoader.isClassAvailable("androidx.core.view.ScrollingView", options); } private void startTracking(final @NotNull Activity activity) { @@ -53,7 +49,7 @@ private void startTracking(final @NotNull Activity activity) { } final SentryGestureListener gestureListener = - new SentryGestureListener(activity, hub, options, isAndroidXScrollViewAvailable); + new SentryGestureListener(activity, hub, options); window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidComposeViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidComposeViewUtils.java deleted file mode 100644 index 0ef893d95f..0000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidComposeViewUtils.java +++ /dev/null @@ -1,102 +0,0 @@ -package io.sentry.android.core.internal.gestures; - -import android.view.View; -import androidx.compose.ui.layout.LayoutCoordinatesKt; -import androidx.compose.ui.layout.ModifierInfo; -import androidx.compose.ui.node.LayoutNode; -import androidx.compose.ui.platform.AndroidComposeView; -import androidx.compose.ui.semantics.SemanticsActions; -import androidx.compose.ui.semantics.SemanticsConfiguration; -import androidx.compose.ui.semantics.SemanticsModifier; -import androidx.compose.ui.semantics.SemanticsProperties; -import io.sentry.util.Objects; -import java.util.LinkedList; -import java.util.List; -import java.util.Queue; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@SuppressWarnings("KotlinInternalInJava") -final class AndroidComposeViewUtils { - static boolean isComposeView(View view) { - return view instanceof AndroidComposeView; - } - - static @Nullable ViewUtils.UiElement findTarget( - final @NotNull View view, - final float x, - final float y, - final ViewUtils.TargetType targetType) { - @Nullable String targetTag = null; - - if (!isComposeView(view)) { - return null; - } - - final AndroidComposeView androidComposeView = (AndroidComposeView) view; - final LayoutNode root = androidComposeView.getRoot(); - - final Queue queue = new LinkedList<>(); - queue.add(root); - - while (!queue.isEmpty()) { - final LayoutNode node = Objects.requireNonNull(queue.poll(), "layoutnode is required"); - - if (node.isPlaced() && nodeBoundsContains(node, x, y)) { - boolean isClickable = false; - boolean isScrollable = false; - @Nullable String testTag = null; - - final List modifiers = node.getModifierInfo(); - for (ModifierInfo modifier : modifiers) { - if (modifier.getModifier() instanceof SemanticsModifier) { - final SemanticsModifier semanticsModifierCore = - ((SemanticsModifier) modifier.getModifier()); - final SemanticsConfiguration semanticsConfiguration = - semanticsModifierCore.getSemanticsConfiguration(); - - isScrollable = - isScrollable - || semanticsConfiguration.contains(SemanticsActions.INSTANCE.getScrollBy()); - isClickable = - isClickable - || semanticsConfiguration.contains(SemanticsActions.INSTANCE.getOnClick()); - - if (semanticsConfiguration.contains(SemanticsProperties.INSTANCE.getTestTag())) { - final String newTestTag = - semanticsConfiguration.get(SemanticsProperties.INSTANCE.getTestTag()); - if (newTestTag != null) { - testTag = newTestTag; - } - } - } - } - - if (isClickable && targetType == ViewUtils.TargetType.CLICKABLE) { - targetTag = testTag; - } else if (isScrollable && targetType == ViewUtils.TargetType.SCROLLABLE) { - targetTag = testTag; - // skip any children for scrollable targets - break; - } - } - queue.addAll(node.getChildren$ui_release()); - } - - return ViewUtils.UiElement.create(null, targetTag); - } - - private static boolean nodeBoundsContains( - @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)); - - return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight); - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java new file mode 100644 index 0000000000..b9e1b1f041 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -0,0 +1,80 @@ +package io.sentry.android.core.internal.gestures; + +import android.view.View; +import android.widget.AbsListView; +import android.widget.ScrollView; +import androidx.core.view.ScrollingView; +import io.sentry.internal.gestures.GestureTargetLocator; +import io.sentry.internal.gestures.UiElement; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class AndroidViewGestureTargetLocator implements GestureTargetLocator { + + private final boolean isAndroidXAvailable; + private final int[] coordinates = new int[2]; + + public AndroidViewGestureTargetLocator(boolean isAndroidXAvailable) { + this.isAndroidXAvailable = isAndroidXAvailable; + } + + static boolean isViewTappable(final @NotNull View view) { + return view.isClickable() && view.getVisibility() == View.VISIBLE; + } + + static boolean isViewScrollable(final @NotNull View view, final boolean isAndroidXAvailable) { + return (isJetpackScrollingView(view, isAndroidXAvailable) + || AbsListView.class.isAssignableFrom(view.getClass()) + || ScrollView.class.isAssignableFrom(view.getClass())) + && view.getVisibility() == View.VISIBLE; + } + + private static boolean isJetpackScrollingView( + final @NotNull View view, final boolean isAndroidXAvailable) { + if (!isAndroidXAvailable) { + return false; + } + return ScrollingView.class.isAssignableFrom(view.getClass()); + } + + static boolean touchWithinBounds( + final @NotNull View view, final float x, final float y, final int[] coords) { + view.getLocationOnScreen(coords); + int vx = coords[0]; + int vy = coords[1]; + + int w = view.getWidth(); + int h = view.getHeight(); + + return !(x < vx || x > vx + w || y < vy || y > vy + h); + } + + @Override + public @Nullable UiElement locate( + @NotNull Object root, float x, float y, UiElement.Type targetType) { + if (!(root instanceof View)) { + return null; + } + final View view = (View) root; + if (touchWithinBounds(view, x, y, coordinates)) { + if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { + return createUiElement(view); + } else if (targetType == UiElement.Type.SCROLLABLE + && isViewScrollable(view, isAndroidXAvailable)) { + return createUiElement(view); + } + } + return null; + } + + private UiElement createUiElement(@NotNull View targetView) { + final String resourceName = ViewUtils.getResourceIdWithFallback(targetView); + @Nullable String className = targetView.getClass().getCanonicalName(); + if (className == null) { + className = targetView.getClass().getSimpleName(); + } + return new UiElement(targetView, className, resourceName, null); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 0bde524062..eb620aabc8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -18,6 +18,7 @@ import io.sentry.TransactionContext; import io.sentry.TransactionOptions; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.internal.gestures.UiElement; import io.sentry.protocol.TransactionNameSource; import java.lang.ref.WeakReference; import java.util.Collections; @@ -35,9 +36,8 @@ public final class SentryGestureListener implements GestureDetector.OnGestureLis private final @NotNull WeakReference activityRef; private final @NotNull IHub hub; private final @NotNull SentryAndroidOptions options; - private final boolean isAndroidXAvailable; - private @Nullable ViewUtils.UiElement activeUiElement = null; + private @Nullable UiElement activeUiElement = null; private @Nullable ITransaction activeTransaction = null; private @Nullable String activeEventType = null; @@ -46,17 +46,15 @@ public final class SentryGestureListener implements GestureDetector.OnGestureLis public SentryGestureListener( final @NotNull Activity currentActivity, final @NotNull IHub hub, - final @NotNull SentryAndroidOptions options, - final boolean isAndroidXAvailable) { + final @NotNull SentryAndroidOptions options) { this.activityRef = new WeakReference<>(currentActivity); this.hub = hub; this.options = options; - this.isAndroidXAvailable = isAndroidXAvailable; } public void onUp(final @NotNull MotionEvent motionEvent) { final View decorView = ensureWindowDecorView("onUp"); - final ViewUtils.UiElement scrollTarget = scrollState.target; + final UiElement scrollTarget = scrollState.target; if (decorView == null || scrollTarget == null) { return; } @@ -96,14 +94,9 @@ public boolean onSingleTapUp(final @Nullable MotionEvent motionEvent) { return false; } - @SuppressWarnings("Convert2MethodRef") - final @Nullable ViewUtils.UiElement target = + final @Nullable UiElement target = ViewUtils.findTarget( - isAndroidXAvailable, - decorView, - motionEvent.getX(), - motionEvent.getY(), - ViewUtils.TargetType.CLICKABLE); + options, decorView, motionEvent.getX(), motionEvent.getY(), UiElement.Type.CLICKABLE); if (target == null) { options @@ -129,13 +122,9 @@ public boolean onScroll( } if (scrollState.type == null) { - final @Nullable ViewUtils.UiElement target = + final @Nullable UiElement target = ViewUtils.findTarget( - isAndroidXAvailable, - decorView, - firstEvent.getX(), - firstEvent.getY(), - ViewUtils.TargetType.SCROLLABLE); + options, decorView, firstEvent.getX(), firstEvent.getY(), UiElement.Type.SCROLLABLE); if (target == null) { options @@ -172,7 +161,7 @@ public void onLongPress(MotionEvent motionEvent) {} // region utils private void addBreadcrumb( - final @NotNull ViewUtils.UiElement target, + final @NotNull UiElement target, final @NotNull String eventType, final @NotNull Map additionalData, final @NotNull MotionEvent motionEvent) { @@ -195,8 +184,7 @@ private void addBreadcrumb( hint); } - private void startTracing( - final @NotNull ViewUtils.UiElement target, final @NotNull String eventType) { + private void startTracing(final @NotNull UiElement target, final @NotNull String eventType) { if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) { return; } @@ -208,7 +196,7 @@ private void startTracing( } final @Nullable String viewIdentifier = target.getIdentifier(); - final ViewUtils.UiElement uiElement = activeUiElement; + final UiElement uiElement = activeUiElement; if (activeTransaction != null) { if (target.equals(uiElement) @@ -337,11 +325,11 @@ void applyScope(final @NotNull Scope scope, final @NotNull ITransaction transact // region scroll logic private static final class ScrollState { private @Nullable String type = null; - private @Nullable ViewUtils.UiElement target; + private @Nullable UiElement target; private float startX = 0f; private float startY = 0f; - private void setTarget(final @NotNull ViewUtils.UiElement target) { + private void setTarget(final @NotNull UiElement target) { this.target = target; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index b93a5d881e..c92b7a389c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -4,11 +4,10 @@ import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.widget.AbsListView; -import android.widget.ScrollView; -import androidx.core.view.ScrollingView; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.internal.gestures.GestureTargetLocator; +import io.sentry.internal.gestures.UiElement; import io.sentry.util.Objects; -import java.lang.ref.WeakReference; import java.util.LinkedList; import java.util.Queue; import org.jetbrains.annotations.NotNull; @@ -16,77 +15,10 @@ final class ViewUtils { - static class UiElement { - final @NotNull WeakReference viewRef; - final @Nullable String className; - final @Nullable String resourceName; - final @Nullable String tag; - - public static @Nullable UiElement create(@Nullable View view, @Nullable String tag) { - if (view == null && tag == null) { - return null; - } else { - return new UiElement(view, tag); - } - } - - private UiElement(@Nullable View view, @Nullable String tag) { - this.viewRef = new WeakReference<>(view); - if (view != null) { - this.resourceName = getResourceIdWithFallback(view); - @Nullable String canonicalName = view.getClass().getCanonicalName(); - if (canonicalName != null) { - this.className = canonicalName; - } else { - this.className = view.getClass().getSimpleName(); - } - } else { - this.resourceName = null; - this.className = null; - } - this.tag = tag; - } - - public @Nullable String getClassName() { - return className; - } - - public @Nullable String getResourceName() { - return resourceName; - } - - public @Nullable String getTag() { - return tag; - } - - public @NotNull String getIdentifier() { - if (resourceName != null) { - return resourceName; - } else { - return tag; - } - } - - public @Nullable View getView() { - return viewRef.get(); - } - - @Override - public int hashCode() { - return java.util.Objects.hash(viewRef, resourceName, tag); - } - } - - enum TargetType { - CLICKABLE, - SCROLLABLE - } - /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the * given {@code viewTargetSelector}. * - * @param isAndroidXAvailable - true if androidx is available at runtime * @param decorView - the root view of this window * @param x - the x coordinate of a {@link MotionEvent} * @param y - the y coordinate of {@link MotionEvent} @@ -94,81 +26,39 @@ enum TargetType { * @return the {@link View} that contains the touch coordinates and complements the {@code * viewTargetSelector} */ - static @Nullable ViewUtils.UiElement findTarget( - boolean isAndroidXAvailable, + static @Nullable UiElement findTarget( + final @NotNull SentryAndroidOptions options, final @NotNull View decorView, final float x, final float y, - final TargetType targetType) { + final UiElement.Type targetType) { + final Queue queue = new LinkedList<>(); queue.add(decorView); - @Nullable View targetView = null; - - // the coordinates variable can be method-local, but we allocate it here, to avoid allocation - // in the while- and for-loops - int[] coordinates = new int[2]; - + @Nullable UiElement target = null; while (queue.size() > 0) { final View view = Objects.requireNonNull(queue.poll(), "view is required"); - if (targetType == TargetType.SCROLLABLE - && ViewUtils.isViewScrollable(view, isAndroidXAvailable)) { - targetView = view; - // skip any children for scrollable targets - break; - } else if (targetType == TargetType.CLICKABLE && ViewUtils.isViewTappable(view)) { - targetView = view; - } if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; for (int i = 0; i < viewGroup.getChildCount(); i++) { - final View child = viewGroup.getChildAt(i); - if (touchWithinBounds(child, x, y, coordinates)) { - queue.add(child); - } + queue.add(viewGroup.getChildAt(i)); } } - if (AndroidComposeViewUtils.isComposeView(view)) { - final UiElement composeElement = AndroidComposeViewUtils.findTarget(view, x, y, targetType); - if (composeElement != null) { - return composeElement; + for (GestureTargetLocator provider : options.getGestureTargetLocators()) { + final @Nullable UiElement newTarget = provider.locate(view, x, y, targetType); + if (newTarget != null) { + if (targetType == UiElement.Type.CLICKABLE) { + target = newTarget; + } else { + return newTarget; + } } } } - return UiElement.create(targetView, null); - } - - private static boolean touchWithinBounds( - final @NotNull View view, final float x, final float y, final int[] coords) { - view.getLocationOnScreen(coords); - int vx = coords[0]; - int vy = coords[1]; - - int w = view.getWidth(); - int h = view.getHeight(); - - return !(x < vx || x > vx + w || y < vy || y > vy + h); - } - - static boolean isViewTappable(final @NotNull View view) { - return view.isClickable() && view.getVisibility() == View.VISIBLE; - } - - static boolean isViewScrollable(final @NotNull View view, final boolean isAndroidXAvailable) { - return (isJetpackScrollingView(view, isAndroidXAvailable) - || AbsListView.class.isAssignableFrom(view.getClass()) - || ScrollView.class.isAssignableFrom(view.getClass())) - && view.getVisibility() == View.VISIBLE; - } - - private static boolean isJetpackScrollingView( - final @NotNull View view, final boolean isAndroidXAvailable) { - if (!isAndroidXAvailable) { - return false; - } - return ScrollingView.class.isAssignableFrom(view.getClass()); + return target; } /** diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index e8839e6701..e9f8c4f3d5 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -18,7 +18,7 @@ kotlin { android { publishLibraryVariants("release") } - jvm("desktop") { + jvm() { compilations.all { kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() } @@ -41,9 +41,18 @@ kotlin { implementation(Config.Libs.kotlinStdLib) } } - val androidMain by getting { + + val jvmMain by getting { dependencies { api(projects.sentry) + implementation(Config.Libs.kotlinStdLib) + } + } + + val androidMain by getting { + dependsOn(jvmMain) + + dependencies { api(projects.sentryAndroidNavigation) api(Config.Libs.composeNavigation) diff --git a/sentry-compose/src/jvmMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/jvmMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt new file mode 100644 index 0000000000..7a6c1f3661 --- /dev/null +++ b/sentry-compose/src/jvmMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -0,0 +1,198 @@ +package io.sentry.compose.gestures + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.ModifierInfo +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsProperties.TestTag +import androidx.compose.ui.semantics.getOrNull +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.internal.gestures.GestureTargetLocator +import io.sentry.internal.gestures.UiElement +import java.lang.reflect.Method +import java.util.LinkedList +import java.util.Queue + +public class ComposeGestureTargetLocator(private val options: SentryOptions) : + GestureTargetLocator { + + private val composeLayoutNodeApiWrapper: ComposeLayoutNodeApiWrapper? by lazy { + try { + ComposeLayoutNodeApiWrapper(options) + } catch (t: Throwable) { + options.logger.log( + SentryLevel.WARNING, + "Could not init Compose LayoutNode API wrapper", + t + ) + null + } + } + + override fun locate( + root: Any, + x: Float, + y: Float, + targetType: UiElement.Type + ): UiElement? { + var targetTag: String? = null + + composeLayoutNodeApiWrapper?.let { wrapper -> + if (!wrapper.isLayoutNodeOwner(root)) { + return null + } + + val rootLayoutNode = wrapper.ownerClassGetRoot(root) + val queue: Queue = LinkedList() + queue.add(rootLayoutNode) + + while (!queue.isEmpty()) { + val node = queue.poll() ?: continue + + if (wrapper.layoutNodeIsPlaced(node) && wrapper.layoutNodeBoundsContain( + node, + x, + y + ) + ) { + var isClickable = false + var isScrollable = false + var testTag: String? = null + val modifiers = wrapper.layoutNodeGetModifierInfo(node) + + for (modifier in modifiers) { + if (modifier.modifier is SemanticsModifier) { + val semanticsModifierCore = modifier.modifier as SemanticsModifier + val semanticsConfiguration = + semanticsModifierCore.semanticsConfiguration + isScrollable = isScrollable || + semanticsConfiguration.any { + it.key.name == "ScrollBy" + } + + isClickable = isClickable || + semanticsConfiguration.any { + it.key.name == "OnClick" + } + + if (semanticsConfiguration.contains(TestTag)) { + val newTestTag = semanticsConfiguration.getOrNull(TestTag) + if (newTestTag != null) { + testTag = newTestTag + } + } + } + } + if (isClickable && targetType == UiElement.Type.CLICKABLE) { + targetTag = testTag + } else if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { + targetTag = testTag + // skip any children for scrollable targets + break + } + } + queue.addAll(wrapper.children(node)) + } + } + + return if (targetTag == null) { + null + } else { + UiElement(null, null, null, targetTag) + } + } +} + +private class ComposeLayoutNodeApiWrapper(private val options: SentryOptions) { + val ownerClass: Class<*> = Class.forName("androidx.compose.ui.node.Owner") + val layoutNodeClass: Class<*> = Class.forName("androidx.compose.ui.node.LayoutNode") + + val ownerClassGetRoot: Method = ownerClass.getMethod("getRoot") + val layoutNodeIsPlaced: Method = layoutNodeClass.getMethod("isPlaced") + val layoutNodeGetChildren: Method = layoutNodeClass.getMethod("getChildren\$ui_release") + val layoutNodeGetModifierInfo: Method = layoutNodeClass.getMethod("getModifierInfo") + val layoutNodeGetWidth: Method = layoutNodeClass.getMethod("getWidth") + val layoutNodeGetHeight: Method = layoutNodeClass.getMethod("getHeight") + val layoutNodeGetCoordinates: Method = layoutNodeClass.getMethod("getCoordinates") + + fun isLayoutNodeOwner(obj: Any): Boolean { + return try { + return ownerClass.isAssignableFrom(obj.javaClass) + } catch (ex: Throwable) { + options.logger.log(SentryLevel.WARNING, "androidx.compose.ui.node.Owner failed", ex) + false + } + } + + fun layoutNodeIsPlaced(obj: Any): Boolean { + return try { + return layoutNodeIsPlaced.invoke(obj) as Boolean + } catch (ex: Throwable) { + options.logger.log(SentryLevel.WARNING, "LayoutNode.getIsPlaced failed", ex) + false + } + } + + @Suppress("UNCHECKED_CAST") + fun layoutNodeGetModifierInfo(obj: Any): List { + return try { + return layoutNodeGetModifierInfo.invoke(obj) as List + } catch (ex: Throwable) { + options.logger.log(SentryLevel.WARNING, "LayoutNode.getModifierInfo failed", ex) + emptyList() + } + } + + @Suppress("UNCHECKED_CAST") + fun children(obj: Any): List { + return try { + return layoutNodeGetChildren.invoke(obj) as List + } catch (ex: Throwable) { + options.logger.log(SentryLevel.WARNING, "LayoutNode.children failed", ex) + emptyList() + } + } + + fun width(obj: Any): Int { + return try { + layoutNodeGetWidth.invoke(obj) as Int + } catch (ex: Throwable) { + options.logger.log(SentryLevel.WARNING, "LayoutNode.width failed", ex) + 0 + } + } + + fun height(obj: Any): Int { + return try { + return layoutNodeGetHeight.invoke(obj) as Int + } catch (ex: Throwable) { + options.logger.log(SentryLevel.WARNING, "LayoutNode.height failed", ex) + 0 + } + } + + fun coordinates(obj: Any): LayoutCoordinates? { + return try { + return layoutNodeGetCoordinates.invoke(obj) as LayoutCoordinates + } catch (ex: Throwable) { + options.logger.log(SentryLevel.WARNING, "LayoutNode.coordinates failed", ex) + null + } + } + + fun layoutNodeBoundsContain( + node: Any, + x: Float, + y: Float + ): Boolean { + val nodeHeight = height(node) + val nodeWidth = width(node) + val nodePosition: Offset = coordinates(node)?.positionInWindow() ?: Offset.Unspecified + + val nodeX = nodePosition.x + val nodeY = nodePosition.y + return nodePosition.isValid() && x >= nodeX && x <= nodeX + nodeWidth && y >= nodeY && y <= nodeY + nodeHeight + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 74f7cdccfc..259947399a 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -60,20 +61,28 @@ fun Landing( modifier = Modifier.fillMaxSize() ) { Button( - onClick = { navigateGithub() }, - modifier = Modifier.padding(top = 32.dp) + onClick = { + navigateGithub() + }, + modifier = Modifier + .testTag("button_nav_github") + .padding(top = 32.dp) ) { Text("Navigate to Github Page") } Button( onClick = { navigateGithubWithArgs() }, - modifier = Modifier.padding(top = 32.dp) + modifier = Modifier + .testTag("button_nav_github_args") + .padding(top = 32.dp) ) { Text("Navigate to Github Page With Args") } Button( onClick = { throw RuntimeException("Crash from Compose") }, - modifier = Modifier.padding(top = 32.dp) + modifier = Modifier + .testTag("button_crash") + .padding(top = 32.dp) ) { Text("Crash from Compose") } @@ -111,7 +120,9 @@ fun Github( result = GithubAPI.service.listReposAsync(user.text, perPage).random().full_name } }, - modifier = Modifier.padding(top = 32.dp) + modifier = Modifier + .testTag("button_list_repos_async") + .padding(top = 32.dp) ) { Text("Make Request") } diff --git a/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java b/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java new file mode 100644 index 0000000000..79109f70ff --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java @@ -0,0 +1,11 @@ +package io.sentry.internal.gestures; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface GestureTargetLocator { + + @Nullable + UiElement locate( + final @NotNull Object root, final float x, final float y, final UiElement.Type targetType); +} diff --git a/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java new file mode 100644 index 0000000000..92797622fb --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java @@ -0,0 +1,59 @@ +package io.sentry.internal.gestures; + +import java.lang.ref.WeakReference; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class UiElement { + final @NotNull WeakReference viewRef; + final @Nullable String className; + final @Nullable String resourceName; + final @Nullable String tag; + + public UiElement( + @Nullable Object view, + @Nullable String className, + @Nullable String resourceName, + @Nullable String tag) { + this.viewRef = new WeakReference<>(view); + this.className = className; + this.resourceName = resourceName; + this.tag = tag; + } + + public @Nullable String getClassName() { + return className; + } + + public @Nullable String getResourceName() { + return resourceName; + } + + public @Nullable String getTag() { + return tag; + } + + public @NotNull String getIdentifier() { + // either resourcename or tag is not null + if (resourceName != null) { + return resourceName; + } else { + return Objects.requireNonNull(tag); + } + } + + public @Nullable Object getView() { + return viewRef.get(); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(viewRef, resourceName, tag); + } + + public enum Type { + CLICKABLE, + SCROLLABLE + } +} From 27ba08a37b0e1816a5be4a0c59db1c205dfcd31a Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 7 Dec 2022 17:05:32 +0100 Subject: [PATCH 11/24] Add UI tests for Jetpack Compose user interaction breadcrumbs --- .../sentry-uitest-android/build.gradle.kts | 13 ++- .../uitest/android/UserInteractionTests.kt | 84 +++++++++++++++++++ .../src/main/AndroidManifest.xml | 3 + .../sentry/uitest/android/ComposeActivity.kt | 61 ++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt 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 a7f79fa5b5..73a62b455a 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -14,7 +14,7 @@ android { namespace = "io.sentry.uitest.android" defaultConfig { - minSdk = Config.Android.minSdkVersionNdk + minSdk = 21 targetSdk = Config.Android.targetSdkVersion versionCode = 1 versionName = "1.0.0" @@ -33,6 +33,11 @@ android { // Determines whether to support View Binding. // Note that the viewBinding.enabled property is now deprecated. viewBinding = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Config.composeVersion } signingConfigs { @@ -84,8 +89,12 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION)) implementation(projects.sentryAndroid) + implementation(projects.sentryCompose) implementation(Config.Libs.appCompat) implementation(Config.Libs.androidxCore) + implementation(Config.Libs.composeActivity) + implementation(Config.Libs.composeFoundation) + implementation(Config.Libs.composeMaterial) implementation(Config.Libs.androidxRecylerView) implementation(Config.Libs.constraintLayout) implementation(Config.TestLibs.espressoIdlingResource) @@ -103,6 +112,8 @@ dependencies { androidTestImplementation(Config.TestLibs.androidxTestCoreKtx) androidTestImplementation(Config.TestLibs.mockWebserver) androidTestImplementation(Config.TestLibs.androidxJunit) + androidTestImplementation(Config.TestLibs.composeJunit) + androidTestImplementation(Config.TestLibs.mockitoKotlin) androidTestUtil(Config.TestLibs.androidxTestOrchestrator) } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt new file mode 100644 index 0000000000..a4829e017f --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -0,0 +1,84 @@ +package io.sentry.uitest.android + +import android.view.InputDevice +import android.view.MotionEvent +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Tap +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.android.core.SentryAndroidOptions +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class UserInteractionTests : BaseUiTest() { + + @Test + fun composableClickGeneratesMatchingBreadcrumb() { + val breadcrumbs = mutableListOf() + initSentryAndCollectBreadcrumbs(breadcrumbs) + + val activity = launchActivity() + activity.moveToState(Lifecycle.State.RESUMED) + Espresso.onView(ViewMatchers.withId(android.R.id.content)).perform( + GeneralClickAction( + Tap.SINGLE, + { floatArrayOf(100f, 100f) }, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY + ) + ) + activity.moveToState(Lifecycle.State.DESTROYED) + assertTrue( + breadcrumbs.filter { + it.category == "ui.click" && it.data["view.tag"] == "button_login" + }.size == 1 + ) + } + + @Test + fun composableSwipeGeneratesMatchingBreadcrumb() { + val breadcrumbs = mutableListOf() + initSentryAndCollectBreadcrumbs(breadcrumbs) + + val activity = launchActivity() + activity.moveToState(Lifecycle.State.RESUMED) + Espresso.onView(ViewMatchers.withId(android.R.id.content)).perform( + ViewActions.swipeUp() + ) + activity.moveToState(Lifecycle.State.DESTROYED) + assertTrue( + breadcrumbs.filter { + it.category == "ui.swipe" && + it.data["view.tag"] == "list" && + it.data["direction"] == "up" + }.size == 1 + ) + } + + private fun initSentryAndCollectBreadcrumbs(breadcrumbs: MutableList) { + initSentry(false) { options: SentryAndroidOptions -> + options.isDebug = true + options.setDiagnosticLevel(SentryLevel.DEBUG) + options.tracesSampleRate = 1.0 + options.profilesSampleRate = 1.0 + options.isEnableUserInteractionTracing = true + options.isEnableUserInteractionBreadcrumbs = true + options.beforeBreadcrumb = + SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } + } + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml index 6a02e5c7c5..dea9d863ff 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ + Date: Wed, 7 Dec 2022 17:06:34 +0100 Subject: [PATCH 12/24] Remove obsolete dependencies --- .../sentry-uitest-android/build.gradle.kts | 2 -- 1 file changed, 2 deletions(-) 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 73a62b455a..8a7b2447fb 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -112,8 +112,6 @@ dependencies { androidTestImplementation(Config.TestLibs.androidxTestCoreKtx) androidTestImplementation(Config.TestLibs.mockWebserver) androidTestImplementation(Config.TestLibs.androidxJunit) - androidTestImplementation(Config.TestLibs.composeJunit) - androidTestImplementation(Config.TestLibs.mockitoKotlin) androidTestUtil(Config.TestLibs.androidxTestOrchestrator) } From 3d8b83316568b2b5ab0fb653a1a26315af06faa0 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 7 Dec 2022 17:10:08 +0100 Subject: [PATCH 13/24] Add changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5c9a9ba8..fd1c5b8ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Provide automatic breadcrumbs and transactions for click/scroll events for Compose ([#2390](https://github.com/getsentry/sentry-java/pull/2390)) + ## 6.9.2 ### Fixes From a979bf309f5cf5eb388dc51861fbfa1fc47a04d9 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 9 Dec 2022 10:38:03 +0100 Subject: [PATCH 14/24] Update sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java Co-authored-by: Roman Zavarnitsyn --- .../core/internal/gestures/AndroidViewGestureTargetLocator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index b9e1b1f041..4a98337c56 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -16,7 +16,7 @@ public class AndroidViewGestureTargetLocator implements GestureTargetLocator { private final boolean isAndroidXAvailable; private final int[] coordinates = new int[2]; - public AndroidViewGestureTargetLocator(boolean isAndroidXAvailable) { + public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { this.isAndroidXAvailable = isAndroidXAvailable; } From ed79435ef27a7b208096d84123dc785d05edd39c Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 9 Dec 2022 11:19:04 +0100 Subject: [PATCH 15/24] Refactor based on PR comments --- .../android/core/SentryAndroidOptions.java | 11 --------- .../AndroidViewGestureTargetLocator.java | 2 +- .../sentry-uitest-android/build.gradle.kts | 2 +- .../main/java/io/sentry/SentryOptions.java | 23 +++++++++++++++++++ 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 413a6c0097..1158b86c15 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -130,8 +130,6 @@ public final class SentryAndroidOptions extends SentryOptions { private boolean enableFramesTracking = true; - private final @NotNull List gestureTargetLocators = new ArrayList<>(); - public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -355,15 +353,6 @@ public void setEnableFramesTracking(boolean enableFramesTracking) { this.enableFramesTracking = enableFramesTracking; } - public List getGestureTargetLocators() { - return gestureTargetLocators; - } - - public void setGestureTargetLocators(@NotNull final List evaluators) { - gestureTargetLocators.clear(); - gestureTargetLocators.addAll(evaluators); - } - /** * Returns the Startup Crash flush timeout in Millis * diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index b9e1b1f041..ece4c48ab2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -11,7 +11,7 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public class AndroidViewGestureTargetLocator implements GestureTargetLocator { +public final class AndroidViewGestureTargetLocator implements GestureTargetLocator { private final boolean isAndroidXAvailable; private final int[] coordinates = new int[2]; 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 8a7b2447fb..c651065602 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -14,7 +14,7 @@ android { namespace = "io.sentry.uitest.android" defaultConfig { - minSdk = 21 + minSdk = Config.Android.minSdkVersionCompose targetSdk = Config.Android.targetSdkVersion versionCode = 1 versionName = "1.0.0" diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 14764eb030..490ce71939 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -5,6 +5,7 @@ import io.sentry.clientreport.ClientReportRecorder; import io.sentry.clientreport.IClientReportRecorder; import io.sentry.clientreport.NoOpClientReportRecorder; +import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.modules.IModulesLoader; import io.sentry.internal.modules.NoOpModulesLoader; import io.sentry.protocol.SdkVersion; @@ -377,6 +378,9 @@ 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 **/ + private final @NotNull List gestureTargetLocators = new ArrayList<>(); + /** * Adds an event processor * @@ -1839,6 +1843,25 @@ public void setModulesLoader(final @Nullable IModulesLoader modulesLoader) { this.modulesLoader = modulesLoader != null ? modulesLoader : NoOpModulesLoader.getInstance(); } + /** + * Returns a list of all {@link GestureTargetLocator} instances used to determine which {@link io.sentry.internal.gestures.UiElement} was part of an user interaction. + * + * @return a list of {@link GestureTargetLocator} + */ + public List getGestureTargetLocators() { + return gestureTargetLocators; + } + + /** + * Sets the list of {@link GestureTargetLocator} being used to determine relevant {@link io.sentry.internal.gestures.UiElement} for user interactions. + * + * @param locators a list of {@link GestureTargetLocator} + */ + public void setGestureTargetLocators(@NotNull final List locators) { + gestureTargetLocators.clear(); + gestureTargetLocators.addAll(locators); + } + /** The BeforeSend callback */ public interface BeforeSendCallback { From 44c70962af37d2d7bcd66df0f4575e61ef85785b Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 9 Dec 2022 13:17:25 +0100 Subject: [PATCH 16/24] Adapts code to PR comments --- .../android/core/SentryAndroidOptions.java | 3 - .../AndroidViewGestureTargetLocator.java | 66 +++++++++---------- .../core/internal/gestures/ViewUtils.java | 4 +- .../main/java/io/sentry/SentryOptions.java | 8 ++- .../sentry/internal/gestures/UiElement.java | 6 +- .../src/main/java/io/sentry/util/Objects.java | 9 +++ 6 files changed, 52 insertions(+), 44 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 1158b86c15..ca1f9a03c1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -5,10 +5,7 @@ import io.sentry.Sentry; import io.sentry.SentryOptions; import io.sentry.SpanStatus; -import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.protocol.SdkVersion; -import java.util.ArrayList; -import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index 7fa1555fe0..8ed4a0bdb0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -20,37 +20,6 @@ public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { this.isAndroidXAvailable = isAndroidXAvailable; } - static boolean isViewTappable(final @NotNull View view) { - return view.isClickable() && view.getVisibility() == View.VISIBLE; - } - - static boolean isViewScrollable(final @NotNull View view, final boolean isAndroidXAvailable) { - return (isJetpackScrollingView(view, isAndroidXAvailable) - || AbsListView.class.isAssignableFrom(view.getClass()) - || ScrollView.class.isAssignableFrom(view.getClass())) - && view.getVisibility() == View.VISIBLE; - } - - private static boolean isJetpackScrollingView( - final @NotNull View view, final boolean isAndroidXAvailable) { - if (!isAndroidXAvailable) { - return false; - } - return ScrollingView.class.isAssignableFrom(view.getClass()); - } - - static boolean touchWithinBounds( - final @NotNull View view, final float x, final float y, final int[] coords) { - view.getLocationOnScreen(coords); - int vx = coords[0]; - int vy = coords[1]; - - int w = view.getWidth(); - int h = view.getHeight(); - - return !(x < vx || x > vx + w || y < vy || y > vy + h); - } - @Override public @Nullable UiElement locate( @NotNull Object root, float x, float y, UiElement.Type targetType) { @@ -58,7 +27,7 @@ static boolean touchWithinBounds( return null; } final View view = (View) root; - if (touchWithinBounds(view, x, y, coordinates)) { + if (touchWithinBounds(view, x, y)) { if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { return createUiElement(view); } else if (targetType == UiElement.Type.SCROLLABLE @@ -69,7 +38,7 @@ && isViewScrollable(view, isAndroidXAvailable)) { return null; } - private UiElement createUiElement(@NotNull View targetView) { + private UiElement createUiElement(final @NotNull View targetView) { final String resourceName = ViewUtils.getResourceIdWithFallback(targetView); @Nullable String className = targetView.getClass().getCanonicalName(); if (className == null) { @@ -77,4 +46,35 @@ private UiElement createUiElement(@NotNull View targetView) { } return new UiElement(targetView, className, resourceName, null); } + + private boolean touchWithinBounds(final @NotNull View view, final float x, final float y) { + view.getLocationOnScreen(coordinates); + int vx = coordinates[0]; + int vy = coordinates[1]; + + int w = view.getWidth(); + int h = view.getHeight(); + + return !(x < vx || x > vx + w || y < vy || y > vy + h); + } + + private static boolean isViewTappable(final @NotNull View view) { + return view.isClickable() && view.getVisibility() == View.VISIBLE; + } + + private static boolean isViewScrollable( + final @NotNull View view, final boolean isAndroidXAvailable) { + return (isJetpackScrollingView(view, isAndroidXAvailable) + || AbsListView.class.isAssignableFrom(view.getClass()) + || ScrollView.class.isAssignableFrom(view.getClass())) + && view.getVisibility() == View.VISIBLE; + } + + private static boolean isJetpackScrollingView( + final @NotNull View view, final boolean isAndroidXAvailable) { + if (!isAndroidXAvailable) { + return false; + } + return ScrollingView.class.isAssignableFrom(view.getClass()); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index c92b7a389c..68360451c9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -47,8 +47,8 @@ final class ViewUtils { } } - for (GestureTargetLocator provider : options.getGestureTargetLocators()) { - final @Nullable UiElement newTarget = provider.locate(view, x, y, targetType); + for (GestureTargetLocator locator : options.getGestureTargetLocators()) { + final @Nullable UiElement newTarget = locator.locate(view, x, y, targetType); if (newTarget != null) { if (targetType == UiElement.Type.CLICKABLE) { target = newTarget; diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 490ce71939..f0d200113d 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -378,7 +378,7 @@ 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<>(); /** @@ -1844,7 +1844,8 @@ public void setModulesLoader(final @Nullable IModulesLoader modulesLoader) { } /** - * Returns a list of all {@link GestureTargetLocator} instances used to determine which {@link io.sentry.internal.gestures.UiElement} was part of an user interaction. + * Returns a list of all {@link GestureTargetLocator} instances used to determine which {@link + * io.sentry.internal.gestures.UiElement} was part of an user interaction. * * @return a list of {@link GestureTargetLocator} */ @@ -1853,7 +1854,8 @@ public List getGestureTargetLocators() { } /** - * Sets the list of {@link GestureTargetLocator} being used to determine relevant {@link io.sentry.internal.gestures.UiElement} for user interactions. + * Sets the list of {@link GestureTargetLocator} being used to determine relevant {@link + * io.sentry.internal.gestures.UiElement} for user interactions. * * @param locators a list of {@link GestureTargetLocator} */ diff --git a/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java index 92797622fb..62f63afb7b 100644 --- a/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java +++ b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java @@ -1,7 +1,7 @@ package io.sentry.internal.gestures; +import io.sentry.util.Objects; import java.lang.ref.WeakReference; -import java.util.Objects; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -39,7 +39,7 @@ public UiElement( if (resourceName != null) { return resourceName; } else { - return Objects.requireNonNull(tag); + return Objects.requireNonNull(tag, "UiElement.tag can't be null"); } } @@ -49,7 +49,7 @@ public UiElement( @Override public int hashCode() { - return java.util.Objects.hash(viewRef, resourceName, tag); + return Objects.hash(viewRef, resourceName, tag); } public enum Type { diff --git a/sentry/src/main/java/io/sentry/util/Objects.java b/sentry/src/main/java/io/sentry/util/Objects.java index 1ab070157b..44d8751a9c 100644 --- a/sentry/src/main/java/io/sentry/util/Objects.java +++ b/sentry/src/main/java/io/sentry/util/Objects.java @@ -1,5 +1,6 @@ package io.sentry.util; +import java.util.Arrays; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -12,4 +13,12 @@ public static T requireNonNull(final @Nullable T obj, final @NotNull String if (obj == null) throw new IllegalArgumentException(message); return obj; } + + public static boolean equals(Object a, Object b) { + return (a == b) || (a != null && a.equals(b)); + } + + public static int hash(Object... values) { + return Arrays.hashCode(values); + } } From 8438cb4e35388f75c285a535c6f9b2b2b87c1385 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 12 Dec 2022 16:13:44 +0100 Subject: [PATCH 17/24] Port ComposeGestureTargetLocator back to Java --- .../core/AndroidOptionsInitializer.java | 2 +- sentry-compose-helper/build.gradle.kts | 40 ++++ .../gestures/ComposeGestureTargetLocator.java | 106 ++++++++++ sentry-compose/build.gradle.kts | 22 ++ .../gestures/ComposeGestureTargetLocator.kt | 198 ------------------ .../src/main/java/io/sentry/util/Objects.java | 4 +- settings.gradle.kts | 1 + 7 files changed, 172 insertions(+), 201 deletions(-) create mode 100644 sentry-compose-helper/build.gradle.kts create mode 100644 sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java delete mode 100644 sentry-compose/src/jvmMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt 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 6831790c36..eeef755b3a 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 @@ -146,7 +146,7 @@ static void initializeIntegrationsAndProcessors( final List gestureTargetLocators = new ArrayList<>(2); gestureTargetLocators.add(new AndroidViewGestureTargetLocator(isAndroidXScrollViewAvailable)); if (isComposeGestureTargetLocatorAvailable) { - gestureTargetLocators.add(new ComposeGestureTargetLocator(options)); + gestureTargetLocators.add(new ComposeGestureTargetLocator()); } options.setGestureTargetLocators(gestureTargetLocators); } diff --git a/sentry-compose-helper/build.gradle.kts b/sentry-compose-helper/build.gradle.kts new file mode 100644 index 0000000000..c3326df05f --- /dev/null +++ b/sentry-compose-helper/build.gradle.kts @@ -0,0 +1,40 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id("org.jetbrains.compose") + id(Config.QualityPlugins.gradleVersions) + id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +dependencies { + api(projects.sentry) + api(compose.runtime) + api(compose.ui) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +val embeddedJar by configurations.creating { + isCanBeConsumed = true + isCanBeResolved = false +} + +artifacts { + add("embeddedJar", File("$buildDir/libs/sentry-compose-helper-$version.jar")) +} diff --git a/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java new file mode 100644 index 0000000000..efeb49302e --- /dev/null +++ b/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -0,0 +1,106 @@ +package io.sentry.compose.gestures; + +import androidx.compose.ui.layout.LayoutCoordinatesKt; +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.internal.gestures.GestureTargetLocator; +import io.sentry.internal.gestures.UiElement; +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 { + + @Override + public @Nullable UiElement locate( + @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable String targetTag = null; + + if (!(root instanceof Owner)) { + return null; + } + + final @NotNull Queue queue = new LinkedList<>(); + queue.add(((Owner) root).getRoot()); + + while (!queue.isEmpty()) { + final @Nullable LayoutNode node = queue.poll(); + if (node == null) { + continue; + } + + final boolean isPlaced = node.isPlaced(); + final boolean inBounds = layoutNodeBoundsContain(node, x, y); + + if (node.isPlaced() && layoutNodeBoundsContain(node, x, y)) { + boolean isClickable = false; + boolean isScrollable = false; + @Nullable String testTag = null; + + 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(); + switch (key) { + case "ScrollBy": + isScrollable = true; + break; + case "OnClick": + isClickable = true; + break; + case "TestTag": + if (entry.getValue() instanceof String) { + testTag = (String) entry.getValue(); + } + break; + } + } + } + } + + if (isClickable && targetType == UiElement.Type.CLICKABLE) { + targetTag = testTag; + } + if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { + targetTag = testTag; + // 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); + } + } + + 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)); + + return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight); + } +} diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index e9f8c4f3d5..e7d97622df 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.gradle.internal.tasks.LibraryAarJarsTask import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.dokka.gradle.DokkaTask @@ -46,6 +47,7 @@ kotlin { dependencies { api(projects.sentry) implementation(Config.Libs.kotlinStdLib) + api(projects.sentryComposeHelper) } } @@ -137,3 +139,23 @@ tasks.withType().configureEach { } } } + +/** + * Due to https://youtrack.jetbrains.com/issue/KT-30878 + * you can not 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/src/jvmMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/jvmMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt deleted file mode 100644 index 7a6c1f3661..0000000000 --- a/sentry-compose/src/jvmMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt +++ /dev/null @@ -1,198 +0,0 @@ -package io.sentry.compose.gestures - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.ModifierInfo -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.semantics.SemanticsModifier -import androidx.compose.ui.semantics.SemanticsProperties.TestTag -import androidx.compose.ui.semantics.getOrNull -import io.sentry.SentryLevel -import io.sentry.SentryOptions -import io.sentry.internal.gestures.GestureTargetLocator -import io.sentry.internal.gestures.UiElement -import java.lang.reflect.Method -import java.util.LinkedList -import java.util.Queue - -public class ComposeGestureTargetLocator(private val options: SentryOptions) : - GestureTargetLocator { - - private val composeLayoutNodeApiWrapper: ComposeLayoutNodeApiWrapper? by lazy { - try { - ComposeLayoutNodeApiWrapper(options) - } catch (t: Throwable) { - options.logger.log( - SentryLevel.WARNING, - "Could not init Compose LayoutNode API wrapper", - t - ) - null - } - } - - override fun locate( - root: Any, - x: Float, - y: Float, - targetType: UiElement.Type - ): UiElement? { - var targetTag: String? = null - - composeLayoutNodeApiWrapper?.let { wrapper -> - if (!wrapper.isLayoutNodeOwner(root)) { - return null - } - - val rootLayoutNode = wrapper.ownerClassGetRoot(root) - val queue: Queue = LinkedList() - queue.add(rootLayoutNode) - - while (!queue.isEmpty()) { - val node = queue.poll() ?: continue - - if (wrapper.layoutNodeIsPlaced(node) && wrapper.layoutNodeBoundsContain( - node, - x, - y - ) - ) { - var isClickable = false - var isScrollable = false - var testTag: String? = null - val modifiers = wrapper.layoutNodeGetModifierInfo(node) - - for (modifier in modifiers) { - if (modifier.modifier is SemanticsModifier) { - val semanticsModifierCore = modifier.modifier as SemanticsModifier - val semanticsConfiguration = - semanticsModifierCore.semanticsConfiguration - isScrollable = isScrollable || - semanticsConfiguration.any { - it.key.name == "ScrollBy" - } - - isClickable = isClickable || - semanticsConfiguration.any { - it.key.name == "OnClick" - } - - if (semanticsConfiguration.contains(TestTag)) { - val newTestTag = semanticsConfiguration.getOrNull(TestTag) - if (newTestTag != null) { - testTag = newTestTag - } - } - } - } - if (isClickable && targetType == UiElement.Type.CLICKABLE) { - targetTag = testTag - } else if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { - targetTag = testTag - // skip any children for scrollable targets - break - } - } - queue.addAll(wrapper.children(node)) - } - } - - return if (targetTag == null) { - null - } else { - UiElement(null, null, null, targetTag) - } - } -} - -private class ComposeLayoutNodeApiWrapper(private val options: SentryOptions) { - val ownerClass: Class<*> = Class.forName("androidx.compose.ui.node.Owner") - val layoutNodeClass: Class<*> = Class.forName("androidx.compose.ui.node.LayoutNode") - - val ownerClassGetRoot: Method = ownerClass.getMethod("getRoot") - val layoutNodeIsPlaced: Method = layoutNodeClass.getMethod("isPlaced") - val layoutNodeGetChildren: Method = layoutNodeClass.getMethod("getChildren\$ui_release") - val layoutNodeGetModifierInfo: Method = layoutNodeClass.getMethod("getModifierInfo") - val layoutNodeGetWidth: Method = layoutNodeClass.getMethod("getWidth") - val layoutNodeGetHeight: Method = layoutNodeClass.getMethod("getHeight") - val layoutNodeGetCoordinates: Method = layoutNodeClass.getMethod("getCoordinates") - - fun isLayoutNodeOwner(obj: Any): Boolean { - return try { - return ownerClass.isAssignableFrom(obj.javaClass) - } catch (ex: Throwable) { - options.logger.log(SentryLevel.WARNING, "androidx.compose.ui.node.Owner failed", ex) - false - } - } - - fun layoutNodeIsPlaced(obj: Any): Boolean { - return try { - return layoutNodeIsPlaced.invoke(obj) as Boolean - } catch (ex: Throwable) { - options.logger.log(SentryLevel.WARNING, "LayoutNode.getIsPlaced failed", ex) - false - } - } - - @Suppress("UNCHECKED_CAST") - fun layoutNodeGetModifierInfo(obj: Any): List { - return try { - return layoutNodeGetModifierInfo.invoke(obj) as List - } catch (ex: Throwable) { - options.logger.log(SentryLevel.WARNING, "LayoutNode.getModifierInfo failed", ex) - emptyList() - } - } - - @Suppress("UNCHECKED_CAST") - fun children(obj: Any): List { - return try { - return layoutNodeGetChildren.invoke(obj) as List - } catch (ex: Throwable) { - options.logger.log(SentryLevel.WARNING, "LayoutNode.children failed", ex) - emptyList() - } - } - - fun width(obj: Any): Int { - return try { - layoutNodeGetWidth.invoke(obj) as Int - } catch (ex: Throwable) { - options.logger.log(SentryLevel.WARNING, "LayoutNode.width failed", ex) - 0 - } - } - - fun height(obj: Any): Int { - return try { - return layoutNodeGetHeight.invoke(obj) as Int - } catch (ex: Throwable) { - options.logger.log(SentryLevel.WARNING, "LayoutNode.height failed", ex) - 0 - } - } - - fun coordinates(obj: Any): LayoutCoordinates? { - return try { - return layoutNodeGetCoordinates.invoke(obj) as LayoutCoordinates - } catch (ex: Throwable) { - options.logger.log(SentryLevel.WARNING, "LayoutNode.coordinates failed", ex) - null - } - } - - fun layoutNodeBoundsContain( - node: Any, - x: Float, - y: Float - ): Boolean { - val nodeHeight = height(node) - val nodeWidth = width(node) - val nodePosition: Offset = coordinates(node)?.positionInWindow() ?: Offset.Unspecified - - val nodeX = nodePosition.x - val nodeY = nodePosition.y - return nodePosition.isValid() && x >= nodeX && x <= nodeX + nodeWidth && y >= nodeY && y <= nodeY + nodeHeight - } -} diff --git a/sentry/src/main/java/io/sentry/util/Objects.java b/sentry/src/main/java/io/sentry/util/Objects.java index 44d8751a9c..df7aeab38b 100644 --- a/sentry/src/main/java/io/sentry/util/Objects.java +++ b/sentry/src/main/java/io/sentry/util/Objects.java @@ -14,11 +14,11 @@ public static T requireNonNull(final @Nullable T obj, final @NotNull String return obj; } - public static boolean equals(Object a, Object b) { + public static boolean equals(@Nullable Object a, @Nullable Object b) { return (a == b) || (a != null && a.equals(b)); } - public static int hash(Object... values) { + public static int hash(@Nullable Object... values) { return Arrays.hashCode(values); } } diff --git a/settings.gradle.kts b/settings.gradle.kts index eb534489d4..3d8af33808 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ include( "sentry-android-fragment", "sentry-android-navigation", "sentry-compose", + "sentry-compose-helper", "sentry-apollo", "sentry-apollo-3", "sentry-test-support", From 6cead69119775f8fd09edde0d09999a3d04dee45 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 13 Dec 2022 12:48:32 +0100 Subject: [PATCH 18/24] Integrate PR comments --- .../gestures/SentryGestureListener.java | 2 +- sentry-compose-helper/build.gradle.kts | 7 +++-- .../gestures/ComposeGestureTargetLocator.java | 23 ++++++---------- sentry-compose/api/android/sentry-compose.api | 4 --- sentry/api/sentry.api | 26 +++++++++++++++++++ 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index eb620aabc8..2bc8a52694 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -166,7 +166,7 @@ private void addBreadcrumb( final @NotNull Map additionalData, final @NotNull MotionEvent motionEvent) { - if (!hub.getOptions().isEnableUserInteractionBreadcrumbs()) { + if (!options.isEnableUserInteractionBreadcrumbs()) { return; } diff --git a/sentry-compose-helper/build.gradle.kts b/sentry-compose-helper/build.gradle.kts index c3326df05f..0243cacb44 100644 --- a/sentry-compose-helper/build.gradle.kts +++ b/sentry-compose-helper/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` - kotlin("jvm") jacoco id("org.jetbrains.compose") id(Config.QualityPlugins.gradleVersions) @@ -19,9 +18,9 @@ tasks.withType().configureEach { } dependencies { - api(projects.sentry) - api(compose.runtime) - api(compose.ui) + implementation(projects.sentry) + implementation(compose.runtime) + implementation(compose.ui) } configure { diff --git a/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java index efeb49302e..d6a6e79ed6 100644 --- a/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java +++ b/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -37,9 +37,6 @@ public final class ComposeGestureTargetLocator implements GestureTargetLocator { continue; } - final boolean isPlaced = node.isPlaced(); - final boolean inBounds = layoutNodeBoundsContain(node, x, y); - if (node.isPlaced() && layoutNodeBoundsContain(node, x, y)) { boolean isClickable = false; boolean isScrollable = false; @@ -54,18 +51,14 @@ public final class ComposeGestureTargetLocator implements GestureTargetLocator { semanticsModifierCore.getSemanticsConfiguration(); for (Map.Entry, ?> entry : semanticsConfiguration) { final @Nullable String key = entry.getKey().getName(); - switch (key) { - case "ScrollBy": - isScrollable = true; - break; - case "OnClick": - isClickable = true; - break; - case "TestTag": - if (entry.getValue() instanceof String) { - testTag = (String) entry.getValue(); - } - break; + if ("ScrollBy".equals(key)) { + isScrollable = true; + } else if ("OnClick".equals(key)) { + isClickable = true; + } else if ("TestTag".equals(key)) { + if (entry.getValue() instanceof String) { + testTag = (String) entry.getValue(); + } } } } diff --git a/sentry-compose/api/android/sentry-compose.api b/sentry-compose/api/android/sentry-compose.api index bb0f41fe73..e30b863f6b 100644 --- a/sentry-compose/api/android/sentry-compose.api +++ b/sentry-compose/api/android/sentry-compose.api @@ -6,10 +6,6 @@ public final class io/sentry/compose/BuildConfig { public fun ()V } -public final class io/sentry/compose/SentryClickableIntegrationKt { - public static final fun wrapClickable (Lkotlin/jvm/functions/Function0;Ljava/lang/String;)Lkotlin/jvm/functions/Function0; -} - 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/api/sentry.api b/sentry/api/sentry.api index 2c6daed3a2..d81a8a5fd3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -107,6 +107,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public static fun ui (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun user (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun userInteraction (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; + public static fun userInteraction (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lio/sentry/Breadcrumb; public static fun userInteraction (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lio/sentry/Breadcrumb; } @@ -1391,6 +1392,7 @@ public class io/sentry/SentryOptions { public fun getEventProcessors ()Ljava/util/List; public fun getExecutorService ()Lio/sentry/ISentryExecutorService; public fun getFlushTimeoutMillis ()J + public fun getGestureTargetLocators ()Ljava/util/List; public fun getHostnameVerifier ()Ljavax/net/ssl/HostnameVerifier; public fun getIdleTimeout ()Ljava/lang/Long; public fun getIgnoredExceptionsForType ()Ljava/util/Set; @@ -1480,6 +1482,7 @@ public class io/sentry/SentryOptions { public fun setEnvironment (Ljava/lang/String;)V public fun setExecutorService (Lio/sentry/ISentryExecutorService;)V public fun setFlushTimeoutMillis (J)V + public fun setGestureTargetLocators (Ljava/util/List;)V public fun setHostnameVerifier (Ljavax/net/ssl/HostnameVerifier;)V public fun setIdleTimeout (Ljava/lang/Long;)V public fun setInstrumenter (Lio/sentry/Instrumenter;)V @@ -2250,6 +2253,27 @@ public final class io/sentry/instrumentation/file/SentryFileWriter : java/io/Out public fun (Ljava/lang/String;Z)V } +public abstract interface class io/sentry/internal/gestures/GestureTargetLocator { + public abstract fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement; +} + +public final class io/sentry/internal/gestures/UiElement { + public fun (Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public fun getClassName ()Ljava/lang/String; + public fun getIdentifier ()Ljava/lang/String; + public fun getResourceName ()Ljava/lang/String; + public fun getTag ()Ljava/lang/String; + public fun getView ()Ljava/lang/Object; + public fun hashCode ()I +} + +public final class io/sentry/internal/gestures/UiElement$Type : java/lang/Enum { + public static final field CLICKABLE Lio/sentry/internal/gestures/UiElement$Type; + public static final field SCROLLABLE Lio/sentry/internal/gestures/UiElement$Type; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/internal/gestures/UiElement$Type; + public static fun values ()[Lio/sentry/internal/gestures/UiElement$Type; +} + public abstract interface class io/sentry/internal/modules/IModulesLoader { public abstract fun getOrLoadModules ()Ljava/util/Map; } @@ -3455,6 +3479,8 @@ public final class io/sentry/util/LogUtils { } public final class io/sentry/util/Objects { + public static fun equals (Ljava/lang/Object;Ljava/lang/Object;)Z + public static fun hash ([Ljava/lang/Object;)I public static fun requireNonNull (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; } From 9f4600e9c7c8a1729e47a1f039c27ee695aa25c8 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 13 Dec 2022 14:29:40 +0100 Subject: [PATCH 19/24] Add README to sentry-compose-helper module --- sentry-compose-helper/README.md | 10 ++++++++++ sentry-compose/build.gradle.kts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 sentry-compose-helper/README.md diff --git a/sentry-compose-helper/README.md b/sentry-compose-helper/README.md new file mode 100644 index 0000000000..f5b900ddff --- /dev/null +++ b/sentry-compose-helper/README.md @@ -0,0 +1,10 @@ +# 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/build.gradle.kts b/sentry-compose/build.gradle.kts index e7d97622df..8b9abab586 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -142,7 +142,7 @@ tasks.withType().configureEach { /** * Due to https://youtrack.jetbrains.com/issue/KT-30878 - * you can not java sources in a KMP-enabled project which has the android-lib plugin applied. + * 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 { From 50f6792d1b61666ad2aaf970754be1c239c9c940 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 13 Dec 2022 16:03:33 +0100 Subject: [PATCH 20/24] Fix failing tests --- .../core/AndroidOptionsInitializer.java | 9 ++++---- .../AndroidViewGestureTargetLocator.java | 15 ++++++++----- .../SentryGestureListenerClickTest.kt | 21 ++++++++++++------- .../SentryGestureListenerScrollTest.kt | 13 ++++++------ .../SentryGestureListenerTracingTest.kt | 14 +++++++------ .../core/internal/gestures/ViewHelpers.kt | 6 +++++- .../sentry/uitest/android/ComposeActivity.kt | 7 ++++++- sentry/api/sentry.api | 1 + .../sentry/internal/gestures/UiElement.java | 11 ++++++++++ 9 files changed, 66 insertions(+), 31 deletions(-) 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 eeef755b3a..fcbb6822cc 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 @@ -138,15 +138,16 @@ static void initializeIntegrationsAndProcessors( final boolean isAndroidXScrollViewAvailable = loadClass.isClassAvailable("androidx.core.view.ScrollingView", options); - final boolean isComposeGestureTargetLocatorAvailable = - loadClass.isClassAvailable( - "io.sentry.compose.gestures.ComposeGestureTargetLocator", options.getLogger()); if (options.getGestureTargetLocators().isEmpty()) { final List gestureTargetLocators = new ArrayList<>(2); gestureTargetLocators.add(new AndroidViewGestureTargetLocator(isAndroidXScrollViewAvailable)); - if (isComposeGestureTargetLocatorAvailable) { + try { gestureTargetLocators.add(new ComposeGestureTargetLocator()); + } catch (NoClassDefFoundError error) { + options + .getLogger() + .log(SentryLevel.DEBUG, "ComposeGestureTargetLocator not available, consider adding the `sentry-compose` library.", error); } options.setGestureTargetLocators(gestureTargetLocators); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index 8ed4a0bdb0..224d60491c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -1,5 +1,6 @@ package io.sentry.android.core.internal.gestures; +import android.content.res.Resources; import android.view.View; import android.widget.AbsListView; import android.widget.ScrollView; @@ -39,12 +40,16 @@ && isViewScrollable(view, isAndroidXAvailable)) { } private UiElement createUiElement(final @NotNull View targetView) { - final String resourceName = ViewUtils.getResourceIdWithFallback(targetView); - @Nullable String className = targetView.getClass().getCanonicalName(); - if (className == null) { - className = targetView.getClass().getSimpleName(); + try { + final String resourceName = ViewUtils.getResourceId(targetView); + @Nullable String className = targetView.getClass().getCanonicalName(); + if (className == null) { + className = targetView.getClass().getSimpleName(); + } + return new UiElement(targetView, className, resourceName, null); + } catch (Resources.NotFoundException ignored) { + return null; } - return new UiElement(targetView, className, resourceName, null); } private boolean touchWithinBounds(final @NotNull View view, final float x, final float y) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt index ed4abc1f12..b61f3805f0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -30,6 +30,9 @@ class SentryGestureListenerClickTest { val context = mock() val resources = mock() val options = SentryAndroidOptions().apply { + isEnableUserInteractionBreadcrumbs = true + isEnableUserInteractionTracing = true + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) dsn = "https://key@sentry.io/proj" } val hub = mock() @@ -47,13 +50,15 @@ class SentryGestureListenerClickTest { invalidTarget = mockView( event = event, visible = isInvalidTargetVisible, - clickable = isInvalidTargetClickable + clickable = isInvalidTargetClickable, + context = context ) if (targetOverride == null) { this.target = mockView( event = event, - clickable = true + clickable = true, + context = context ) } else { this.target = targetOverride @@ -61,7 +66,8 @@ class SentryGestureListenerClickTest { if (attachViewsToRoot) { window.mockDecorView( - event = event + event = event, + context = context ) { whenever(it.childCount).thenReturn(2) whenever(it.getChildAt(0)).thenReturn(invalidTarget) @@ -76,8 +82,7 @@ class SentryGestureListenerClickTest { return SentryGestureListener( activity, hub, - options, - true + options ) } } @@ -93,15 +98,15 @@ class SentryGestureListenerClickTest { attachViewsToRoot = false ) - val container1 = mockView(event = event, touchWithinBounds = false) + val container1 = mockView(event = event, touchWithinBounds = false, context = fixture.context) val notClickableInvalidTarget = mockView(event = event) - val container2 = mockView(event = event, clickable = true) { + val container2 = mockView(event = event, clickable = true, context = fixture.context) { whenever(it.childCount).thenReturn(3) whenever(it.getChildAt(0)).thenReturn(notClickableInvalidTarget) whenever(it.getChildAt(1)).thenReturn(fixture.invalidTarget) whenever(it.getChildAt(2)).thenReturn(fixture.target) } - fixture.window.mockDecorView(event = event) { + fixture.window.mockDecorView(event = event, context = fixture.context) { whenever(it.childCount).thenReturn(2) whenever(it.getChildAt(0)).thenReturn(container1) whenever(it.getChildAt(1)).thenReturn(container2) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt index 414538c657..e00d22c73e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt @@ -35,6 +35,8 @@ class SentryGestureListenerScrollTest { val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" isEnableUserInteractionBreadcrumbs = true + isEnableUserInteractionTracing = true + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) } val hub = mock() @@ -47,12 +49,12 @@ class SentryGestureListenerScrollTest { internal inline fun getSut( resourceName: String = "test_scroll_view", touchWithinBounds: Boolean = true, - direction: String = "", - isAndroidXAvailable: Boolean = true + direction: String = "" ): SentryGestureListener { target = mockView( event = firstEvent, - touchWithinBounds = touchWithinBounds + touchWithinBounds = touchWithinBounds, + context = context ) window.mockDecorView(event = firstEvent) { whenever(it.childCount).thenReturn(1) @@ -70,8 +72,7 @@ class SentryGestureListenerScrollTest { return SentryGestureListener( activity, hub, - options, - isAndroidXAvailable + options ) } } @@ -170,7 +171,7 @@ class SentryGestureListenerScrollTest { @Test fun `if androidX is not available, does not capture a breadcrumb for ScrollingView`() { - val sut = fixture.getSut(isAndroidXAvailable = false) + val sut = fixture.getSut() sut.onDown(fixture.firstEvent) fixture.eventsInBetween.forEach { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index 4ba36a9ae8..bd31d98fda 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -52,12 +52,15 @@ class SentryGestureListenerTracingTest { ): SentryGestureListener { options.tracesSampleRate = tracesSampleRate options.isEnableUserInteractionTracing = isEnableUserInteractionTracing + options.isEnableUserInteractionBreadcrumbs = true + options.gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) + whenever(hub.options).thenReturn(options) this.transaction = transaction ?: SentryTracer(TransactionContext("name", "op"), hub) - target = mockView(event = event, clickable = true) - window.mockDecorView(event = event) { + target = mockView(event = event, clickable = true, context = context) + window.mockDecorView(event = event, context = context) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(target) } @@ -80,8 +83,7 @@ class SentryGestureListenerTracingTest { return SentryGestureListener( activity, hub, - options, - true + options ) } } @@ -228,13 +230,13 @@ class SentryGestureListenerTracingTest { clearInvocations(fixture.hub) // second view interaction with another view - val newTarget = mockView(event = fixture.event, clickable = true) + val newTarget = mockView(event = fixture.event, clickable = true, context = fixture.context) val newContext = mock() val newRes = mock() newRes.mockForTarget(newTarget, "test_checkbox") whenever(newContext.resources).thenReturn(newRes) whenever(newTarget.context).thenReturn(newContext) - fixture.window.mockDecorView(event = fixture.event) { + fixture.window.mockDecorView(event = fixture.event, context = fixture.context) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(newTarget) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt index 4664cfeb55..16eeeb676f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt @@ -1,5 +1,6 @@ package io.sentry.android.core.internal.gestures +import android.content.Context import android.content.res.Resources import android.view.MotionEvent import android.view.View @@ -17,9 +18,10 @@ internal inline fun Window.mockDecorView( touchWithinBounds: Boolean = true, clickable: Boolean = false, visible: Boolean = true, + context: Context? = null, finalize: (T) -> Unit = {} ): T { - val view = mockView(id, event, touchWithinBounds, clickable, visible, finalize) + val view = mockView(id, event, touchWithinBounds, clickable, visible, context, finalize) whenever(decorView).doReturn(view) return view } @@ -30,6 +32,7 @@ internal inline fun mockView( touchWithinBounds: Boolean = true, clickable: Boolean = false, visible: Boolean = true, + context: Context? = null, finalize: (T) -> Unit = {} ): T { val coordinates = IntArray(2) @@ -42,6 +45,7 @@ internal inline fun mockView( } val mockView: T = mock { whenever(it.id).thenReturn(id) + whenever(it.context).thenReturn(context) whenever(it.isClickable).thenReturn(clickable) whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt index 9d94bf5fa0..75a193e67c 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt @@ -21,8 +21,13 @@ import androidx.compose.ui.unit.dp class ComposeActivity : AppCompatActivity() { + companion object { + private const val ITEM_COUNT: Int = 100 + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContent { MaterialTheme { Surface( @@ -45,7 +50,7 @@ class ComposeActivity : AppCompatActivity() { Text("Login") } } - items(100) { + items(ITEM_COUNT) { Box( modifier = Modifier .size(96.dp) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6c89afc23b..fbfa9c1bc7 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2260,6 +2260,7 @@ public abstract interface class io/sentry/internal/gestures/GestureTargetLocator public final class io/sentry/internal/gestures/UiElement { public fun (Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z public fun getClassName ()Ljava/lang/String; public fun getIdentifier ()Ljava/lang/String; public fun getResourceName ()Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java index 62f63afb7b..36f6126675 100644 --- a/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java +++ b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java @@ -43,6 +43,17 @@ public UiElement( } } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UiElement uiElement = (UiElement) o; + + return Objects.equals(className, uiElement.className) + && Objects.equals(resourceName, uiElement.resourceName) + && Objects.equals(tag, uiElement.tag); + } + public @Nullable Object getView() { return viewRef.get(); } From 1c582d84b917db814ae0fb11267e009908067e3c Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 13 Dec 2022 15:08:24 +0000 Subject: [PATCH 21/24] Format code --- .../io/sentry/android/core/AndroidOptionsInitializer.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 fcbb6822cc..82b746eb62 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 @@ -147,7 +147,10 @@ static void initializeIntegrationsAndProcessors( } catch (NoClassDefFoundError error) { options .getLogger() - .log(SentryLevel.DEBUG, "ComposeGestureTargetLocator not available, consider adding the `sentry-compose` library.", error); + .log( + SentryLevel.DEBUG, + "ComposeGestureTargetLocator not available, consider adding the `sentry-compose` library.", + error); } options.setGestureTargetLocators(gestureTargetLocators); } From c1753c094bd33bbfc91c6d5a86942978c95a31b1 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 13 Dec 2022 16:36:20 +0100 Subject: [PATCH 22/24] Fix missing code formatting --- .../io/sentry/android/core/AndroidOptionsInitializer.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 fcbb6822cc..82b746eb62 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 @@ -147,7 +147,10 @@ static void initializeIntegrationsAndProcessors( } catch (NoClassDefFoundError error) { options .getLogger() - .log(SentryLevel.DEBUG, "ComposeGestureTargetLocator not available, consider adding the `sentry-compose` library.", error); + .log( + SentryLevel.DEBUG, + "ComposeGestureTargetLocator not available, consider adding the `sentry-compose` library.", + error); } options.setGestureTargetLocators(gestureTargetLocators); } From 14fddbdf28e1862dd97b7bfb28fe67c86c98702c Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 14 Dec 2022 08:28:06 +0100 Subject: [PATCH 23/24] Add potential fix for saucelabs ui tests --- .../io/sentry/uitest/android/UserInteractionTests.kt | 12 +++++++++++- .../java/io/sentry/uitest/android/ComposeActivity.kt | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt index a4829e017f..4cbc7dfc3e 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -1,5 +1,6 @@ package io.sentry.uitest.android +import android.util.DisplayMetrics import android.view.InputDevice import android.view.MotionEvent import androidx.lifecycle.Lifecycle @@ -29,10 +30,19 @@ class UserInteractionTests : BaseUiTest() { val activity = launchActivity() activity.moveToState(Lifecycle.State.RESUMED) + + // some sane defaults + var height = 500 + var width = 500 + activity.onActivity { + height = it.resources.displayMetrics.heightPixels + width = it.resources.displayMetrics.widthPixels + } + Espresso.onView(ViewMatchers.withId(android.R.id.content)).perform( GeneralClickAction( Tap.SINGLE, - { floatArrayOf(100f, 100f) }, + { floatArrayOf(width / 2f, height / 2f) }, Press.FINGER, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt index 75a193e67c..f41da133ec 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt @@ -41,7 +41,7 @@ class ComposeActivity : AppCompatActivity() { modifier = Modifier .background(Color.Gray) .fillParentMaxWidth() - .height(100.dp) + .fillParentMaxHeight() .clickable { // no-op } From 534cda5c6f77d29435bfccd520fb8a7fbdb3e4ab Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 14 Dec 2022 08:29:24 +0100 Subject: [PATCH 24/24] Fix unused imports --- .../java/io/sentry/uitest/android/UserInteractionTests.kt | 1 - .../src/main/java/io/sentry/uitest/android/ComposeActivity.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt index 4cbc7dfc3e..635eb7a51f 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -1,6 +1,5 @@ package io.sentry.uitest.android -import android.util.DisplayMetrics import android.view.InputDevice import android.view.MotionEvent import androidx.lifecycle.Lifecycle diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt index f41da133ec..034a59c4b7 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn