diff --git a/CHANGELOG.md b/CHANGELOG.md index 097c379ea0..5e77af3173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add time-to-initial-display span to Activity transactions ([#2369](https://github.com/getsentry/sentry-java/pull/2369)) - Start a session after init if AutoSessionTracking is enabled ([#2356](https://github.com/getsentry/sentry-java/pull/2356)) +- Provide automatic breadcrumbs and transactions for click/scroll events for Compose ([#2390](https://github.com/getsentry/sentry-java/pull/2390)) ### Dependencies diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index fff1af2668..05316fa0d4 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -160,8 +160,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 @@ -175,8 +173,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/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..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 @@ -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,25 @@ 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); + + if (options.getGestureTargetLocators().isEmpty()) { + final List gestureTargetLocators = new ArrayList<>(2); + gestureTargetLocators.add(new AndroidViewGestureTargetLocator(isAndroidXScrollViewAvailable)); + 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); + } } 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 d58ea76314..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,9 +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 +90,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 +235,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 +245,7 @@ public void enableAllAutoBreadcrumbs(boolean enable) { enableAppComponentBreadcrumbs = enable; enableSystemEventBreadcrumbs = enable; enableAppLifecycleBreadcrumbs = enable; - enableUserInteractionBreadcrumbs = enable; + setEnableUserInteractionBreadcrumbs(enable); } /** @@ -343,14 +329,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-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..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)); } } @@ -112,14 +108,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."); 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..224d60491c --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -0,0 +1,85 @@ +package io.sentry.android.core.internal.gestures; + +import android.content.res.Resources; +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 final class AndroidViewGestureTargetLocator implements GestureTargetLocator { + + private final boolean isAndroidXAvailable; + private final int[] coordinates = new int[2]; + + public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { + this.isAndroidXAvailable = isAndroidXAvailable; + } + + @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)) { + 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(final @NotNull View targetView) { + 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; + } + } + + 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/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 72a2d3983c..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 @@ -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; @@ -19,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; @@ -36,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 WeakReference activeView = null; + private @Nullable UiElement activeUiElement = null; private @Nullable ITransaction activeTransaction = null; private @Nullable String activeEventType = null; @@ -47,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 View scrollTarget = scrollState.targetRef.get(); + final UiElement scrollTarget = scrollState.target; if (decorView == null || scrollTarget == null) { return; } @@ -97,13 +94,9 @@ public boolean onSingleTapUp(final @Nullable MotionEvent motionEvent) { return false; } - @SuppressWarnings("Convert2MethodRef") - final @Nullable View target = + final @Nullable UiElement target = ViewUtils.findTarget( - decorView, - motionEvent.getX(), - motionEvent.getY(), - view -> ViewUtils.isViewTappable(view)); + options, decorView, motionEvent.getX(), motionEvent.getY(), UiElement.Type.CLICKABLE); if (target == null) { options @@ -129,28 +122,19 @@ public boolean onScroll( } if (scrollState.type == null) { - final @Nullable View target = + final @Nullable UiElement target = ViewUtils.findTarget( - 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; - } - }); + options, decorView, firstEvent.getX(), firstEvent.getY(), UiElement.Type.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 +161,30 @@ public void onLongPress(MotionEvent motionEvent) {} // region utils private void addBreadcrumb( - final @NotNull View target, + final @NotNull UiElement target, final @NotNull String eventType, final @NotNull Map additionalData, final @NotNull MotionEvent motionEvent) { - if ((!options.isEnableUserInteractionBreadcrumbs())) { + if (!options.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 UiElement target, final @NotNull String eventType) { if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) { return; } @@ -215,21 +195,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 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 +207,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 +225,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 +243,7 @@ private void startTracing(final @NotNull View target, final @NotNull String even }); activeTransaction = transaction; - activeView = new WeakReference<>(target); + activeUiElement = target; activeEventType = eventType; } @@ -286,8 +256,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 +325,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 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 UiElement target) { + this.target = target; } /** @@ -390,7 +360,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..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 @@ -4,16 +4,17 @@ 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.util.ArrayDeque; +import java.util.LinkedList; import java.util.Queue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; final class ViewUtils { + /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the * given {@code viewTargetSelector}. @@ -21,79 +22,45 @@ final class ViewUtils { * @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 UiElement findTarget( + final @NotNull SentryAndroidOptions options, final @NotNull View decorView, final float x, final float y, - final @NotNull ViewTargetSelector viewTargetSelector) { - Queue queue = new ArrayDeque<>(); - queue.add(decorView); + final UiElement.Type targetType) { - @Nullable View target = 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]; + final Queue queue = new LinkedList<>(); + queue.add(decorView); + @Nullable UiElement target = null; 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 (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)); + } + } + + 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; + } else { + return newTarget; } } } } - return target; } - 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()); - } - /** * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, falls * back to a hexadecimal id representation in case the view id is not available in the resources. 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/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 350cc35a5c..f189234316 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 = Config.Android.minSdkVersionCompose targetSdk = Config.Android.targetSdkVersion versionCode = 1 versionName = "1.0.0" @@ -36,6 +36,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 { @@ -87,8 +92,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) 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..635eb7a51f --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -0,0 +1,93 @@ +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) + + // 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(width / 2f, height / 2f) }, + 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 @@ + { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +dependencies { + implementation(projects.sentry) + implementation(compose.runtime) + implementation(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..d6a6e79ed6 --- /dev/null +++ b/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -0,0 +1,99 @@ +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; + } + + 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(); + 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(); + } + } + } + } + } + + 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 e8839e6701..8b9abab586 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 @@ -18,7 +19,7 @@ kotlin { android { publishLibraryVariants("release") } - jvm("desktop") { + jvm() { compilations.all { kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() } @@ -41,9 +42,19 @@ kotlin { implementation(Config.Libs.kotlinStdLib) } } - val androidMain by getting { + + val jvmMain by getting { dependencies { api(projects.sentry) + implementation(Config.Libs.kotlinStdLib) + api(projects.sentryComposeHelper) + } + } + + val androidMain by getting { + dependsOn(jvmMain) + + dependencies { api(projects.sentryAndroidNavigation) api(Config.Libs.composeNavigation) @@ -128,3 +139,23 @@ tasks.withType().configureEach { } } } + +/** + * Due to https://youtrack.jetbrains.com/issue/KT-30878 + * you can not have java sources in a KMP-enabled project which has the android-lib plugin applied. + * Thus we compile relevant java code in sentry-compose-helper first and embed it in here. + */ +val embedComposeHelperConfig by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + embedComposeHelperConfig( + project(":" + projects.sentryComposeHelper.name, "embeddedJar") + ) +} + +tasks.withType { + mainScopeClassFiles.setFrom(embedComposeHelperConfig) +} diff --git a/sentry-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/api/sentry.api b/sentry/api/sentry.api index be126c57cb..fbfa9c1bc7 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; } @@ -1392,6 +1393,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; @@ -1445,6 +1447,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 @@ -1472,11 +1476,14 @@ 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 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 @@ -2247,6 +2254,28 @@ 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 equals (Ljava/lang/Object;)Z + 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; } @@ -3452,6 +3481,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; } 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()); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 4b5543d601..f0d200113d 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; @@ -368,9 +369,18 @@ 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 */ + private boolean enableUserInteractionBreadcrumbs = true; + /** 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 * @@ -1770,6 +1780,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; + } + /** * Sets the instrumenter used for performance instrumentation. * @@ -1817,6 +1843,27 @@ 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 { 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..36f6126675 --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java @@ -0,0 +1,70 @@ +package io.sentry.internal.gestures; + +import io.sentry.util.Objects; +import java.lang.ref.WeakReference; +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, "UiElement.tag can't be null"); + } + } + + @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(); + } + + @Override + public int hashCode() { + return Objects.hash(viewRef, resourceName, tag); + } + + public enum Type { + CLICKABLE, + SCROLLABLE + } +} diff --git a/sentry/src/main/java/io/sentry/util/Objects.java b/sentry/src/main/java/io/sentry/util/Objects.java index 1ab070157b..df7aeab38b 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(@Nullable Object a, @Nullable Object b) { + return (a == b) || (a != null && a.equals(b)); + } + + 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",