diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc2fea92a..35cad351ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased * Fix: Do not create SentryExceptionResolver bean when Spring MVC is not on the classpath (#1865) +* Feat: Add breadcrumbs support for UI events (automatically captured) (#1876) ## 5.5.2 diff --git a/build.gradle.kts b/build.gradle.kts index c137bbc8ed..235d730cf0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,11 @@ buildscript { } apiValidation { + ignoredPackages.addAll( + setOf( + "io.sentry.android.core.internal" + ) + ) ignoredProjects.addAll( listOf( "sentry-samples-android", diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index d6625fd87b..27532842d9 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -54,6 +54,7 @@ object Config { val lifecycleProcess = "androidx.lifecycle:lifecycle-process:$lifecycleVersion" val lifecycleCommonJava8 = "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" val androidxCore = "androidx.core:core:1.3.2" + val androidxRecylerView = "androidx.recyclerview:recyclerview:1.2.1" val slf4jApi = "org.slf4j:slf4j-api:1.7.30" val logbackVersion = "1.2.9" diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 132691edb0..9f8d6b0256 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -105,6 +105,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableAppLifecycleBreadcrumbs ()Z public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableSystemEventBreadcrumbs ()Z + public fun isEnableUserInteractionBreadcrumbs ()Z public fun setAnrEnabled (Z)V public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V @@ -115,6 +116,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableAppLifecycleBreadcrumbs (Z)V public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V + public fun setEnableUserInteractionBreadcrumbs (Z)V } public final class io/sentry/android/core/SentryInitProvider : android/content/ContentProvider { @@ -162,36 +164,16 @@ public final class io/sentry/android/core/TempSensorBreadcrumbsIntegration : and public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/util/ConnectivityChecker { - public static fun getConnectionStatus (Landroid/content/Context;Lio/sentry/ILogger;)Lio/sentry/android/core/util/ConnectivityChecker$Status; - public static fun getConnectionType (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/android/core/IBuildInfoProvider;)Ljava/lang/String; -} - -public final class io/sentry/android/core/util/ConnectivityChecker$Status : java/lang/Enum { - public static final field CONNECTED Lio/sentry/android/core/util/ConnectivityChecker$Status; - public static final field NOT_CONNECTED Lio/sentry/android/core/util/ConnectivityChecker$Status; - public static final field NO_PERMISSION Lio/sentry/android/core/util/ConnectivityChecker$Status; - public static final field UNKNOWN Lio/sentry/android/core/util/ConnectivityChecker$Status; - public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/core/util/ConnectivityChecker$Status; - public static fun values ()[Lio/sentry/android/core/util/ConnectivityChecker$Status; -} - -public final class io/sentry/android/core/util/DeviceOrientations { - public static fun getOrientation (I)Lio/sentry/protocol/Device$DeviceOrientation; -} - -public final class io/sentry/android/core/util/MainThreadChecker { - public static fun isMainThread ()Z - public static fun isMainThread (Lio/sentry/protocol/SentryThread;)Z - public static fun isMainThread (Ljava/lang/Thread;)Z -} - -public final class io/sentry/android/core/util/Permissions { - public static fun hasPermission (Landroid/content/Context;Ljava/lang/String;)Z -} - -public final class io/sentry/android/core/util/RootChecker { - public fun (Landroid/content/Context;Lio/sentry/android/core/IBuildInfoProvider;Lio/sentry/ILogger;)V - public fun isDeviceRooted ()Z +public final class io/sentry/android/core/UserInteractionIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { + public fun (Landroid/app/Application;Lio/sentry/android/core/LoadClass;)V + public fun close ()V + public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityResumed (Landroid/app/Activity;)V + public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V + public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } 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 e35980e721..d69c8890c2 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 @@ -27,6 +27,7 @@ * Android Options initializer, it reads configurations from AndroidManifest and sets to the * SentryOptions. It also adds default values for some fields. */ +@SuppressWarnings("Convert2MethodRef") // older AGP versions do not support method references final class AndroidOptionsInitializer { /** private ctor */ @@ -153,12 +154,13 @@ private static void installDefaultIntegrations( options.addIntegration( new ActivityLifecycleIntegration( (Application) context, buildInfoProvider, activityFramesTracker)); + options.addIntegration(new UserInteractionIntegration((Application) context, loadClass)); } else { options .getLogger() .log( SentryLevel.WARNING, - "ActivityBreadcrumbsIntegration needs an Application class to be installed."); + "ActivityLifecycle and UserInteraction Integrations need an Application class to be installed."); } options.addIntegration(new AppComponentsBreadcrumbsIntegration(context)); options.addIntegration(new SystemEventsBreadcrumbsIntegration(context)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransportGate.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransportGate.java index 4b9f800c95..fd9215970a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransportGate.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransportGate.java @@ -2,7 +2,7 @@ import android.content.Context; import io.sentry.ILogger; -import io.sentry.android.core.util.ConnectivityChecker; +import io.sentry.android.core.internal.util.ConnectivityChecker; import io.sentry.transport.ITransportGate; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index 7157a039d9..a3d990aab2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -10,7 +10,7 @@ import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.util.DeviceOrientations; +import io.sentry.android.core.internal.util.DeviceOrientations; import io.sentry.protocol.Device; import io.sentry.util.Objects; import java.io.Closeable; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index 62a9b6d657..5d2e7a3cc2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -5,7 +5,7 @@ import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.util.MainThreadChecker; +import io.sentry.android.core.internal.util.MainThreadChecker; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 0341f95282..3b5539536b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -24,10 +24,10 @@ import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryLevel; -import io.sentry.android.core.util.ConnectivityChecker; -import io.sentry.android.core.util.DeviceOrientations; -import io.sentry.android.core.util.MainThreadChecker; -import io.sentry.android.core.util.RootChecker; +import io.sentry.android.core.internal.util.ConnectivityChecker; +import io.sentry.android.core.internal.util.DeviceOrientations; +import io.sentry.android.core.internal.util.MainThreadChecker; +import io.sentry.android.core.internal.util.RootChecker; import io.sentry.protocol.App; import io.sentry.protocol.Device; import io.sentry.protocol.OperatingSystem; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 4b987d57a7..d3f7884195 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -44,6 +44,8 @@ final class ManifestMetadataReader { static final String BREADCRUMBS_APP_LIFECYCLE_ENABLE = "io.sentry.breadcrumbs.app-lifecycle"; static final String BREADCRUMBS_SYSTEM_EVENTS_ENABLE = "io.sentry.breadcrumbs.system-events"; static final String BREADCRUMBS_APP_COMPONENTS_ENABLE = "io.sentry.breadcrumbs.app-components"; + static final String BREADCRUMBS_USER_INTERACTION_ENABLE = + "io.sentry.breadcrumbs.user-interaction"; static final String UNCAUGHT_EXCEPTION_HANDLER_ENABLE = "io.sentry.uncaught-exception-handler.enable"; @@ -175,6 +177,13 @@ static void applyMetadata( BREADCRUMBS_APP_COMPONENTS_ENABLE, options.isEnableAppComponentBreadcrumbs())); + options.setEnableUserInteractionBreadcrumbs( + readBool( + metadata, + logger, + BREADCRUMBS_USER_INTERACTION_ENABLE, + options.isEnableUserInteractionBreadcrumbs())); + options.setEnableUncaughtExceptionHandler( readBool( metadata, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java index 16febc0705..96708bb468 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java @@ -10,7 +10,7 @@ import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.util.Permissions; +import io.sentry.android.core.internal.util.Permissions; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; 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 f1862f6301..62a6367f2f 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 @@ -37,6 +37,9 @@ 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. * @@ -189,6 +192,14 @@ 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 * @@ -199,6 +210,7 @@ public void enableAllAutoBreadcrumbs(boolean enable) { enableAppComponentBreadcrumbs = enable; enableSystemEventBreadcrumbs = enable; enableAppLifecycleBreadcrumbs = enable; + enableUserInteractionBreadcrumbs = enable; } /** 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 new file mode 100644 index 0000000000..7a851b74bd --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -0,0 +1,160 @@ +package io.sentry.android.core; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import android.os.Bundle; +import android.view.Window; +import io.sentry.IHub; +import io.sentry.Integration; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.internal.gestures.NoOpWindowCallback; +import io.sentry.android.core.internal.gestures.SentryGestureListener; +import io.sentry.android.core.internal.gestures.SentryWindowCallback; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import java.lang.ref.WeakReference; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class UserInteractionIntegration + implements Integration, Closeable, Application.ActivityLifecycleCallbacks { + + private final @NotNull Application application; + private @Nullable IHub hub; + 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 = checkAndroidXAvailability(classLoader); + isAndroidXScrollViewAvailable = checkAndroidXScrollViewAvailability(classLoader); + } + + private static boolean checkAndroidXAvailability(final @NotNull LoadClass loadClass) { + try { + loadClass.loadClass("androidx.core.view.GestureDetectorCompat"); + return true; + } catch (ClassNotFoundException ignored) { + return false; + } + } + + private static boolean checkAndroidXScrollViewAvailability(final @NotNull LoadClass loadClass) { + try { + loadClass.loadClass("androidx.core.view.ScrollingView"); + return true; + } catch (ClassNotFoundException ignored) { + return false; + } + } + + private void startTracking(final @Nullable Window window, final @NotNull Context context) { + if (window == null) { + if (options != null) { + options.getLogger().log(SentryLevel.INFO, "Window was null in startTracking"); + } + return; + } + + if (hub != null && options != null) { + Window.Callback delegate = window.getCallback(); + if (delegate == null) { + delegate = new NoOpWindowCallback(); + } + + final SentryGestureListener gestureListener = + new SentryGestureListener( + new WeakReference<>(window), hub, options, isAndroidXScrollViewAvailable); + window.setCallback(new SentryWindowCallback(delegate, context, gestureListener, options)); + } + } + + private void stopTracking(final @Nullable Window window) { + if (window == null) { + if (options != null) { + options.getLogger().log(SentryLevel.INFO, "Window was null in stopTracking"); + } + return; + } + + final Window.Callback current = window.getCallback(); + if (current instanceof SentryWindowCallback) { + if (((SentryWindowCallback) current).getDelegate() instanceof NoOpWindowCallback) { + window.setCallback(null); + } else { + window.setCallback(((SentryWindowCallback) current).getDelegate()); + } + } + } + + @Override + public void onActivityCreated(@NotNull Activity activity, @Nullable Bundle bundle) {} + + @Override + public void onActivityStarted(@NotNull Activity activity) {} + + @Override + public void onActivityResumed(@NotNull Activity activity) { + startTracking(activity.getWindow(), activity); + } + + @Override + public void onActivityPaused(@NotNull Activity activity) { + stopTracking(activity.getWindow()); + } + + @Override + public void onActivityStopped(@NotNull Activity activity) {} + + @Override + public void onActivitySaveInstanceState(@NotNull Activity activity, @NotNull Bundle bundle) {} + + @Override + public void onActivityDestroyed(@NotNull Activity activity) {} + + @Override + public void register(@NotNull IHub hub, @NotNull SentryOptions options) { + this.options = + Objects.requireNonNull( + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, + "SentryAndroidOptions is required"); + + this.hub = Objects.requireNonNull(hub, "Hub is required"); + + this.options + .getLogger() + .log( + SentryLevel.DEBUG, + "UserInteractionIntegration enabled: %s", + this.options.isEnableUserInteractionBreadcrumbs()); + + if (this.options.isEnableUserInteractionBreadcrumbs()) { + if (isAndroidXAvailable) { + application.registerActivityLifecycleCallbacks(this); + this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); + } else { + options + .getLogger() + .log( + SentryLevel.INFO, + "androidx.core is not available, UserInteractionIntegration won't be installed"); + } + } + } + + @Override + public void close() throws IOException { + application.unregisterActivityLifecycleCallbacks(this); + + if (options != null) { + options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration removed."); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/NoOpWindowCallback.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/NoOpWindowCallback.java new file mode 100644 index 0000000000..db50d855e6 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/NoOpWindowCallback.java @@ -0,0 +1,120 @@ +package io.sentry.android.core.internal.gestures; + +import android.view.ActionMode; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SearchEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class NoOpWindowCallback implements Window.Callback { + @Override + public boolean dispatchKeyEvent(KeyEvent keyEvent) { + return false; + } + + @Override + public boolean dispatchKeyShortcutEvent(KeyEvent keyEvent) { + return false; + } + + @Override + public boolean dispatchTouchEvent(MotionEvent motionEvent) { + return false; + } + + @Override + public boolean dispatchTrackballEvent(MotionEvent motionEvent) { + return false; + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent motionEvent) { + return false; + } + + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent accessibilityEvent) { + return false; + } + + @Nullable + @Override + public View onCreatePanelView(int i) { + return null; + } + + @Override + public boolean onCreatePanelMenu(int i, @NonNull Menu menu) { + return false; + } + + @Override + public boolean onPreparePanel(int i, @Nullable View view, @NonNull Menu menu) { + return false; + } + + @Override + public boolean onMenuOpened(int i, @NonNull Menu menu) { + return false; + } + + @Override + public boolean onMenuItemSelected(int i, @NonNull MenuItem menuItem) { + return false; + } + + @Override + public void onWindowAttributesChanged(WindowManager.LayoutParams layoutParams) {} + + @Override + public void onContentChanged() {} + + @Override + public void onWindowFocusChanged(boolean b) {} + + @Override + public void onAttachedToWindow() {} + + @Override + public void onDetachedFromWindow() {} + + @Override + public void onPanelClosed(int i, @NonNull Menu menu) {} + + @Override + public boolean onSearchRequested() { + return false; + } + + @Override + public boolean onSearchRequested(SearchEvent searchEvent) { + return false; + } + + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { + return null; + } + + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, int i) { + return null; + } + + @Override + public void onActionModeStarted(ActionMode actionMode) {} + + @Override + public void onActionModeFinished(ActionMode actionMode) {} +} 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 new file mode 100644 index 0000000000..9fa3ecb7a3 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -0,0 +1,236 @@ +package io.sentry.android.core.internal.gestures; + +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.view.Window; +import io.sentry.Breadcrumb; +import io.sentry.IHub; +import io.sentry.SentryLevel; +import io.sentry.android.core.SentryAndroidOptions; +import java.lang.ref.WeakReference; +import java.util.Collections; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SentryGestureListener implements GestureDetector.OnGestureListener { + + private final @NotNull WeakReference windowRef; + private final @NotNull IHub hub; + private final @NotNull SentryAndroidOptions options; + private final boolean isAndroidXAvailable; + + private final ScrollState scrollState = new ScrollState(); + + public SentryGestureListener( + final @NotNull WeakReference windowRef, + final @NotNull IHub hub, + final @NotNull SentryAndroidOptions options, + final boolean isAndroidXAvailable) { + this.windowRef = windowRef; + 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(); + if (decorView == null || scrollTarget == null) { + return; + } + + if (scrollState.type == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Unable to define scroll type. No breadcrumb captured."); + return; + } + + final String direction = scrollState.calculateDirection(motionEvent); + addBreadcrumb(scrollTarget, scrollState.type, Collections.singletonMap("direction", direction)); + scrollState.reset(); + } + + @Override + public boolean onDown(final @Nullable MotionEvent motionEvent) { + if (motionEvent == null) { + return false; + } + scrollState.reset(); + scrollState.startX = motionEvent.getX(); + scrollState.startY = motionEvent.getY(); + return false; + } + + @Override + public boolean onSingleTapUp(final @Nullable MotionEvent motionEvent) { + final View decorView = ensureWindowDecorView("onSingleTapUp"); + if (decorView == null || motionEvent == null) { + return false; + } + + @SuppressWarnings("Convert2MethodRef") + final @Nullable View target = + ViewUtils.findTarget( + decorView, + motionEvent.getX(), + motionEvent.getY(), + view -> ViewUtils.isViewTappable(view)); + + if (target == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Unable to find click target. No breadcrumb captured."); + return false; + } + + addBreadcrumb(target, "click", Collections.emptyMap()); + return false; + } + + @Override + public boolean onScroll( + final @Nullable MotionEvent firstEvent, + final @Nullable MotionEvent currentEvent, + final float distX, + final float distY) { + final View decorView = ensureWindowDecorView("onScroll"); + if (decorView == null || firstEvent == null) { + return false; + } + + if (scrollState.type == null) { + final @Nullable View 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; + } + }); + + if (target == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Unable to find scroll target. No breadcrumb captured."); + return false; + } + + scrollState.setTarget(target); + scrollState.type = "scroll"; + } + return false; + } + + @Override + public boolean onFling( + final @Nullable MotionEvent motionEvent, + final @Nullable MotionEvent motionEvent1, + final float v, + final float v1) { + scrollState.type = "swipe"; + return false; + } + + @Override + public void onShowPress(MotionEvent motionEvent) {} + + @Override + public void onLongPress(MotionEvent motionEvent) {} + + // region utils + private void addBreadcrumb( + final @NotNull View target, + final @NotNull String eventType, + final @NotNull Map additionalData) { + @NotNull String className; + @Nullable String canonicalName = target.getClass().getCanonicalName(); + if (canonicalName != null) { + className = canonicalName; + } else { + className = target.getClass().getSimpleName(); + } + + hub.addBreadcrumb( + Breadcrumb.userInteraction( + eventType, ViewUtils.getResourceId(target), className, additionalData)); + } + + private @Nullable View ensureWindowDecorView(final @NotNull String caller) { + final Window window = windowRef.get(); + if (window == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Window is null in " + caller + ". No breadcrumb captured."); + return null; + } + + final View decorView = window.getDecorView(); + if (decorView == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "DecorView is null in " + caller + ". No breadcrumb captured."); + return null; + } + return decorView; + } + // endregion + + // region scroll logic + private static final class ScrollState { + private @Nullable String type = null; + private WeakReference targetRef = new WeakReference<>(null); + private float startX = 0f; + private float startY = 0f; + + private void setTarget(final @NotNull View target) { + targetRef = new WeakReference<>(target); + } + + /** + * Calculates the direction of the scroll/swipe based on startX and startY and a given event + * + * @param endEvent - the event which notifies when the scroll/swipe ended + * @return String, one of (left|right|up|down) + */ + private @NotNull String calculateDirection(MotionEvent endEvent) { + final float diffX = endEvent.getX() - startX; + final float diffY = endEvent.getY() - startY; + final String direction; + if (Math.abs(diffX) > Math.abs(diffY)) { + if (diffX > 0f) { + direction = "right"; + } else { + direction = "left"; + } + } else { + if (diffY > 0) { + direction = "down"; + } else { + direction = "up"; + } + } + return direction; + } + + private void reset() { + targetRef.clear(); + type = null; + startX = 0f; + startY = 0f; + } + } + // endregion +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java new file mode 100644 index 0000000000..81a9401ac0 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryWindowCallback.java @@ -0,0 +1,84 @@ +package io.sentry.android.core.internal.gestures; + +import android.content.Context; +import android.view.MotionEvent; +import android.view.Window; +import androidx.core.view.GestureDetectorCompat; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SentryWindowCallback extends WindowCallbackAdapter { + + private final @NotNull Window.Callback delegate; + private final @NotNull SentryGestureListener gestureListener; + private final @NotNull GestureDetectorCompat gestureDetector; + private final @Nullable SentryOptions options; + private final @NotNull MotionEventObtainer motionEventObtainer; + + public SentryWindowCallback( + final @NotNull Window.Callback delegate, + final @NotNull Context context, + final @NotNull SentryGestureListener gestureListener, + final @Nullable SentryOptions options) { + this( + delegate, + new GestureDetectorCompat(context, gestureListener), + gestureListener, + options, + new MotionEventObtainer() {}); + } + + SentryWindowCallback( + final @NotNull Window.Callback delegate, + final @NotNull GestureDetectorCompat gestureDetector, + final @NotNull SentryGestureListener gestureListener, + final @Nullable SentryOptions options, + final @NotNull MotionEventObtainer motionEventObtainer) { + super(delegate); + this.delegate = delegate; + this.gestureListener = gestureListener; + this.options = options; + this.gestureDetector = gestureDetector; + this.motionEventObtainer = motionEventObtainer; + } + + @Override + public boolean dispatchTouchEvent(final @Nullable MotionEvent motionEvent) { + if (motionEvent != null) { + final MotionEvent copy = motionEventObtainer.obtain(motionEvent); + try { + handleTouchEvent(copy); + } catch (Throwable e) { + if (options != null) { + options.getLogger().log(SentryLevel.ERROR, "Error dispatching touch event", e); + } + } finally { + copy.recycle(); + } + } + return super.dispatchTouchEvent(motionEvent); + } + + private void handleTouchEvent(final @NotNull MotionEvent motionEvent) { + gestureDetector.onTouchEvent(motionEvent); + int action = motionEvent.getActionMasked(); + if (action == MotionEvent.ACTION_UP) { + gestureListener.onUp(motionEvent); + } + } + + public @NotNull Window.Callback getDelegate() { + return delegate; + } + + interface MotionEventObtainer { + @NotNull + default MotionEvent obtain(@NotNull MotionEvent origin) { + return MotionEvent.obtain(origin); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewTargetSelector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewTargetSelector.java new file mode 100644 index 0000000000..d2da63ada9 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewTargetSelector.java @@ -0,0 +1,25 @@ +package io.sentry.android.core.internal.gestures; + +import android.view.View; +import org.jetbrains.annotations.NotNull; + +interface ViewTargetSelector { + /** + * Defines whether the given {@code view} should be selected from the view hierarchy. + * + * @param view - the view to be selected. + * @return true, when the view should be selected, false otherwise. + */ + boolean select(@NotNull View view); + + /** + * Defines whether the view from the select method is eligible for children traversal, in case + * it's a ViewGroup. + * + * @return true, when the ViewGroup is sufficient to be selected and children traversal is not + * necessary. + */ + default boolean skipChildren() { + return false; + } +} 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 new file mode 100644 index 0000000000..3c8880a4d8 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -0,0 +1,118 @@ +package io.sentry.android.core.internal.gestures; + +import android.content.res.Resources; +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.util.Objects; +import java.util.ArrayDeque; +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}. + * + * @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. + * @return the {@link View} that contains the touch coordinates and complements the {@code + * viewTargetSelector} + */ + static @Nullable View findTarget( + final @NotNull View decorView, + final float x, + final float y, + final @NotNull ViewTargetSelector viewTargetSelector) { + Queue queue = new ArrayDeque<>(); + queue.add(decorView); + + @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]; + + 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); + } + } + } + } + + 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. + * + * @param view - the view that the id is being retrieved for. + * @return human-readable view id + */ + static String getResourceId(final @NotNull View view) { + final int viewId = view.getId(); + final Resources resources = view.getContext().getResources(); + String resourceId = ""; + try { + if (resources != null) { + resourceId = resources.getResourceEntryName(viewId); + } + } catch (Resources.NotFoundException e) { + // fall back to hex representation of the id + resourceId = "0x" + Integer.toString(viewId, 16); + } + return resourceId; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java new file mode 100644 index 0000000000..a6568ad057 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/WindowCallbackAdapter.java @@ -0,0 +1,146 @@ +package io.sentry.android.core.internal.gestures; + +import android.annotation.SuppressLint; +import android.view.ActionMode; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SearchEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Open +class WindowCallbackAdapter implements Window.Callback { + + private final @NotNull Window.Callback delegate; + + WindowCallbackAdapter(final Window.@NotNull Callback delegate) { + this.delegate = delegate; + } + + @Override + public boolean dispatchKeyEvent(KeyEvent keyEvent) { + return delegate.dispatchKeyEvent(keyEvent); + } + + @Override + public boolean dispatchKeyShortcutEvent(KeyEvent keyEvent) { + return delegate.dispatchKeyShortcutEvent(keyEvent); + } + + @Override + public boolean dispatchTouchEvent(@Nullable MotionEvent motionEvent) { + return delegate.dispatchTouchEvent(motionEvent); + } + + @Override + public boolean dispatchTrackballEvent(MotionEvent motionEvent) { + return delegate.dispatchTrackballEvent(motionEvent); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent motionEvent) { + return delegate.dispatchGenericMotionEvent(motionEvent); + } + + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent accessibilityEvent) { + return delegate.dispatchPopulateAccessibilityEvent(accessibilityEvent); + } + + @Nullable + @Override + public View onCreatePanelView(int i) { + return delegate.onCreatePanelView(i); + } + + @Override + public boolean onCreatePanelMenu(int i, @NotNull Menu menu) { + return delegate.onCreatePanelMenu(i, menu); + } + + @Override + public boolean onPreparePanel(int i, @Nullable View view, @NotNull Menu menu) { + return delegate.onPreparePanel(i, view, menu); + } + + @Override + public boolean onMenuOpened(int i, @NotNull Menu menu) { + return delegate.onMenuOpened(i, menu); + } + + @Override + public boolean onMenuItemSelected(int i, @NotNull MenuItem menuItem) { + return delegate.onMenuItemSelected(i, menuItem); + } + + @Override + public void onWindowAttributesChanged(WindowManager.LayoutParams layoutParams) { + delegate.onWindowAttributesChanged(layoutParams); + } + + @Override + public void onContentChanged() { + delegate.onContentChanged(); + } + + @Override + public void onWindowFocusChanged(boolean b) { + delegate.onWindowFocusChanged(b); + } + + @Override + public void onAttachedToWindow() { + delegate.onAttachedToWindow(); + } + + @Override + public void onDetachedFromWindow() { + delegate.onDetachedFromWindow(); + } + + @Override + public void onPanelClosed(int i, @NotNull Menu menu) { + delegate.onPanelClosed(i, menu); + } + + @Override + public boolean onSearchRequested() { + return delegate.onSearchRequested(); + } + + @SuppressLint("NewApi") + @Override + public boolean onSearchRequested(SearchEvent searchEvent) { + return delegate.onSearchRequested(searchEvent); + } + + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { + return delegate.onWindowStartingActionMode(callback); + } + + @SuppressLint("NewApi") + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, int i) { + return delegate.onWindowStartingActionMode(callback, i); + } + + @Override + public void onActionModeStarted(ActionMode actionMode) { + delegate.onActionModeStarted(actionMode); + } + + @Override + public void onActionModeFinished(ActionMode actionMode) { + delegate.onActionModeFinished(actionMode); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/ConnectivityChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ConnectivityChecker.java similarity index 99% rename from sentry-android-core/src/main/java/io/sentry/android/core/util/ConnectivityChecker.java rename to sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ConnectivityChecker.java index a9f8e0fe81..8f4d0f785c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/util/ConnectivityChecker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ConnectivityChecker.java @@ -1,4 +1,4 @@ -package io.sentry.android.core.util; +package io.sentry.android.core.internal.util; import android.Manifest; import android.annotation.SuppressLint; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/DeviceOrientations.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/DeviceOrientations.java similarity index 95% rename from sentry-android-core/src/main/java/io/sentry/android/core/util/DeviceOrientations.java rename to sentry-android-core/src/main/java/io/sentry/android/core/internal/util/DeviceOrientations.java index 638298b66c..7ecd848710 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/util/DeviceOrientations.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/DeviceOrientations.java @@ -1,4 +1,4 @@ -package io.sentry.android.core.util; +package io.sentry.android.core.internal.util; import android.content.res.Configuration; import io.sentry.protocol.Device; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/MainThreadChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/MainThreadChecker.java similarity index 96% rename from sentry-android-core/src/main/java/io/sentry/android/core/util/MainThreadChecker.java rename to sentry-android-core/src/main/java/io/sentry/android/core/internal/util/MainThreadChecker.java index bbc2a7b67e..c4c389134c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/util/MainThreadChecker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/MainThreadChecker.java @@ -1,4 +1,4 @@ -package io.sentry.android.core.util; +package io.sentry.android.core.internal.util; import android.os.Looper; import io.sentry.protocol.SentryThread; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/Permissions.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/Permissions.java similarity index 93% rename from sentry-android-core/src/main/java/io/sentry/android/core/util/Permissions.java rename to sentry-android-core/src/main/java/io/sentry/android/core/internal/util/Permissions.java index 9effb636cb..57e846897e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/util/Permissions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/Permissions.java @@ -1,4 +1,4 @@ -package io.sentry.android.core.util; +package io.sentry.android.core.internal.util; import android.content.Context; import android.content.pm.PackageManager; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/RootChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/RootChecker.java similarity index 99% rename from sentry-android-core/src/main/java/io/sentry/android/core/util/RootChecker.java rename to sentry-android-core/src/main/java/io/sentry/android/core/internal/util/RootChecker.java index b224b3ce8b..4bf95cba50 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/util/RootChecker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/RootChecker.java @@ -1,4 +1,4 @@ -package io.sentry.android.core.util; +package io.sentry.android.core.internal.util; import android.content.Context; import android.content.pm.PackageManager; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index c52f4f0b46..f0a0de175e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -315,7 +315,7 @@ class AndroidOptionsInitializerTest { } @Test - fun `When given Context is not an Application class, do not add ActivityBreadcrumbsIntegration`() { + fun `When given Context is not an Application class, do not add ActivityLifecycleIntegration`() { val sentryOptions = SentryAndroidOptions() val mockContext = mock() whenever(mockContext.applicationContext).thenReturn(null) @@ -325,6 +325,17 @@ class AndroidOptionsInitializerTest { assertNull(actual) } + @Test + fun `When given Context is not an Application class, do not add UserInteractionIntegration`() { + val sentryOptions = SentryAndroidOptions() + val mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(null) + + AndroidOptionsInitializer.init(sentryOptions, mockContext) + val actual = sentryOptions.integrations.firstOrNull { it is UserInteractionIntegration } + assertNull(actual) + } + private fun createMockContext(): Context { val mockContext = ContextUtilsTest.createMockContext() whenever(mockContext.cacheDir).thenReturn(file) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransportGateTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransportGateTest.kt index 7f2132f1e4..ef017cb97a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransportGateTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransportGateTest.kt @@ -1,7 +1,7 @@ package io.sentry.android.core import com.nhaarman.mockitokotlin2.mock -import io.sentry.android.core.util.ConnectivityChecker +import io.sentry.android.core.internal.util.ConnectivityChecker import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertNotNull diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ConnectivityCheckerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ConnectivityCheckerTest.kt index 9525591b96..04977d29d0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ConnectivityCheckerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ConnectivityCheckerTest.kt @@ -17,7 +17,7 @@ import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever -import io.sentry.android.core.util.ConnectivityChecker +import io.sentry.android.core.internal.util.ConnectivityChecker import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 718024c60e..af3e83e0d7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -738,4 +738,29 @@ class ManifestMetadataReaderTest { // Assert assertNull(fixture.options.proguardUuid) } + + @Test + fun `applyMetadata reads ui events breadcrumbs to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.BREADCRUMBS_USER_INTERACTION_ENABLE to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options) + + // Assert + assertFalse(fixture.options.isEnableUserInteractionBreadcrumbs) + } + + @Test + fun `applyMetadata reads ui events breadcrumbs and keep default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options) + + // Assert + assertTrue(fixture.options.isEnableUserInteractionBreadcrumbs) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PermissionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PermissionsTest.kt index a0b24eee76..c7df046ec9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PermissionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PermissionsTest.kt @@ -4,7 +4,7 @@ import android.Manifest import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.android.core.util.Permissions +import io.sentry.android.core.internal.util.Permissions import org.junit.runner.RunWith import kotlin.test.BeforeTest import kotlin.test.Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt new file mode 100644 index 0000000000..1f4e993695 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt @@ -0,0 +1,163 @@ +package io.sentry.android.core + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.res.Resources +import android.util.DisplayMetrics +import android.view.Window +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import io.sentry.Hub +import io.sentry.android.core.internal.gestures.NoOpWindowCallback +import io.sentry.android.core.internal.gestures.SentryWindowCallback +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class UserInteractionIntegrationTest { + + private class Fixture { + val application = mock() + val hub = mock() + val options = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + } + val activity = mock() + val window = mock() + val loadClass = mock() + + fun getSut(callback: Window.Callback? = null): UserInteractionIntegration { + whenever(hub.options).thenReturn(options) + whenever(window.callback).thenReturn(callback) + whenever(activity.window).thenReturn(window) + + val resources = mockResources() + whenever(activity.resources).thenReturn(resources) + return UserInteractionIntegration(application, loadClass) + } + + companion object { + fun mockResources(): Resources { + val displayMetrics = mock() + displayMetrics.density = 1.0f + + val resources = mock() + whenever(resources.displayMetrics).thenReturn(displayMetrics) + return resources + } + } + } + + private val fixture = Fixture() + + @Test + fun `when user interaction breadcrumb is enabled registers a callback`() { + val sut = fixture.getSut() + sut.register(fixture.hub, fixture.options) + + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `when user interaction breadcrumb is disabled doesn't register a callback`() { + val sut = fixture.getSut() + fixture.options.isEnableUserInteractionBreadcrumbs = false + + sut.register(fixture.hub, fixture.options) + + verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `when UserInteractionIntegration is closed unregisters the callback`() { + val sut = fixture.getSut() + sut.register(fixture.hub, fixture.options) + + sut.close() + + verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `when androidx is unavailable doesn't register a callback`() { + whenever(fixture.loadClass.loadClass(any())).thenThrow(ClassNotFoundException()) + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `registers window callback on activity resumed`() { + val sut = fixture.getSut() + sut.register(fixture.hub, fixture.options) + + sut.onActivityResumed(fixture.activity) + + val argumentCaptor = argumentCaptor() + verify(fixture.window).callback = argumentCaptor.capture() + assertTrue { argumentCaptor.firstValue is SentryWindowCallback } + } + + @Test + fun `when no original callback delegates to NoOpWindowCallback`() { + val sut = fixture.getSut() + sut.register(fixture.hub, fixture.options) + + sut.onActivityResumed(fixture.activity) + + val argumentCaptor = argumentCaptor() + verify(fixture.window).callback = argumentCaptor.capture() + assertTrue { + argumentCaptor.firstValue is SentryWindowCallback && + (argumentCaptor.firstValue as SentryWindowCallback).delegate is NoOpWindowCallback + } + } + + @Test + fun `unregisters window callback on activity paused`() { + val context = mock() + val resources = Fixture.mockResources() + whenever(context.resources).thenReturn(resources) + val sut = fixture.getSut( + SentryWindowCallback( + NoOpWindowCallback(), + context, + mock(), + mock() + ) + ) + + sut.onActivityPaused(fixture.activity) + + verify(fixture.window).callback = null + } + + @Test + fun `preserves original callback on activity paused`() { + val delegate = mock() + val context = mock() + val resources = Fixture.mockResources() + whenever(context.resources).thenReturn(resources) + val sut = fixture.getSut( + SentryWindowCallback( + delegate, + context, + mock(), + mock() + ) + ) + + sut.onActivityPaused(fixture.activity) + + verify(fixture.window).callback = delegate + } +} 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 new file mode 100644 index 0000000000..5bafe1b6cf --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -0,0 +1,218 @@ +package io.sentry.android.core.internal.gestures + +import android.content.Context +import android.content.res.Resources +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.widget.CheckBox +import android.widget.RadioButton +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.check +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import io.sentry.Breadcrumb +import io.sentry.IHub +import io.sentry.SentryLevel.INFO +import io.sentry.android.core.SentryAndroidOptions +import org.junit.Test +import java.lang.ref.WeakReference +import kotlin.test.assertEquals + +class SentryGestureListenerClickTest { + class Fixture { + val window = mock() + val context = mock() + val resources = mock() + val options = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + } + val hub = mock() + lateinit var target: View + lateinit var invalidTarget: View + + internal inline fun getSut( + event: MotionEvent, + resourceName: String = "test_button", + isInvalidTargetVisible: Boolean = true, + isInvalidTargetClickable: Boolean = true, + attachViewsToRoot: Boolean = true, + targetOverride: View? = null + ): SentryGestureListener { + invalidTarget = mockView( + event = event, + visible = isInvalidTargetVisible, + clickable = isInvalidTargetClickable, + ) + + if (targetOverride == null) { + this.target = mockView( + event = event, + clickable = true + ) + } else { + this.target = targetOverride + } + + if (attachViewsToRoot) { + window.mockDecorView( + event = event, + ) { + whenever(it.childCount).thenReturn(2) + whenever(it.getChildAt(0)).thenReturn(invalidTarget) + whenever(it.getChildAt(1)).thenReturn(target) + } + } + + resources.mockForTarget(this.target, resourceName) + whenever(context.resources).thenReturn(resources) + whenever(this.target.context).thenReturn(context) + return SentryGestureListener( + WeakReference(window), + hub, + options, + true + ) + } + } + + private val fixture = Fixture() + + @Test + fun `when target and its ViewGroup are clickable, captures a breadcrumb for target`() { + val event = mock() + val sut = fixture.getSut( + event, + isInvalidTargetVisible = false, + attachViewsToRoot = false + ) + + val container1 = mockView(event = event, touchWithinBounds = false) + val notClickableInvalidTarget = mockView(event = event) + val container2 = mockView(event = event, clickable = true) { + 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) { + whenever(it.childCount).thenReturn(2) + whenever(it.getChildAt(0)).thenReturn(container1) + whenever(it.getChildAt(1)).thenReturn(container2) + } + + sut.onSingleTapUp(event) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("ui.click", it.category) + assertEquals("user", it.type) + assertEquals("test_button", it.data["view.id"]) + assertEquals("android.view.View", it.data["view.class"]) + assertEquals(INFO, it.level) + } + ) + } + + @Test + fun `ignores invisible or gone views`() { + val event = mock() + val sut = fixture.getSut( + event, + "radio_button", + isInvalidTargetVisible = false + ) + + sut.onSingleTapUp(event) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("radio_button", it.data["view.id"]) + assertEquals("android.widget.RadioButton", it.data["view.class"]) + } + ) + } + + @Test + fun `ignores not clickable targets`() { + val event = mock() + val sut = fixture.getSut( + event, + "check_box", + isInvalidTargetClickable = false + ) + + sut.onSingleTapUp(event) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("check_box", it.data["view.id"]) + assertEquals("android.widget.CheckBox", it.data["view.class"]) + } + ) + } + + @Test + fun `when no children present and decor view not clickable, does not capture a breadcrumb`() { + val event = mock() + val sut = fixture.getSut(event, attachViewsToRoot = false) + fixture.window.mockDecorView(event = event) { + whenever(it.childCount).thenReturn(0) + } + + sut.onSingleTapUp(event) + + verify(fixture.hub, never()).addBreadcrumb(any()) + } + + @Test + fun `when target is decorView, captures a breadcrumb for decorView`() { + val event = mock() + val decorView = fixture.window.mockDecorView(event = event, clickable = true) { + whenever(it.childCount).thenReturn(0) + } + + val sut = fixture.getSut(event, "decor_view", targetOverride = decorView) + sut.onSingleTapUp(event) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals(decorView.javaClass.canonicalName, it.data["view.class"]) + assertEquals("decor_view", it.data["view.id"]) + } + ) + } + + @Test + fun `does not capture breadcrumbs when view reference is null`() { + val event = mock() + val sut = fixture.getSut(event, attachViewsToRoot = false) + + sut.onSingleTapUp(event) + + verify(fixture.hub, never()).addBreadcrumb(any()) + } + + @Test + fun `uses simple class name if canonical name isn't available`() { + class LocalView(context: Context) : View(context) + + val event = mock() + val sut = fixture.getSut(event, attachViewsToRoot = false) + fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(fixture.target) + } + + sut.onSingleTapUp(event) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals(fixture.target.javaClass.simpleName, it.data["view.class"]) + } + ) + } +} 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 new file mode 100644 index 0000000000..690a0da5e0 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt @@ -0,0 +1,219 @@ +package io.sentry.android.core.internal.gestures + +import android.content.Context +import android.content.res.Resources +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.widget.AbsListView +import android.widget.ListAdapter +import androidx.core.view.ScrollingView +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.check +import com.nhaarman.mockitokotlin2.inOrder +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.whenever +import io.sentry.Breadcrumb +import io.sentry.IHub +import io.sentry.SentryLevel.INFO +import io.sentry.android.core.SentryAndroidOptions +import org.junit.Test +import java.lang.ref.WeakReference +import kotlin.test.assertEquals + +class SentryGestureListenerScrollTest { + class Fixture { + val window = mock() + val context = mock() + val resources = mock() + val options = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + } + val hub = mock() + + val firstEvent = mock() + val eventsInBetween = listOf(mock(), mock(), mock()) + val endEvent = eventsInBetween.last() + lateinit var target: View + val directions = setOf("up", "down", "left", "right") + + internal inline fun getSut( + resourceName: String = "test_scroll_view", + touchWithinBounds: Boolean = true, + direction: String = "", + isAndroidXAvailable: Boolean = true + ): SentryGestureListener { + target = mockView( + event = firstEvent, + touchWithinBounds = touchWithinBounds + ) + window.mockDecorView(event = firstEvent) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(target) + } + + resources.mockForTarget(target, resourceName) + whenever(context.resources).thenReturn(resources) + whenever(target.context).thenReturn(context) + + if (direction in directions) { + endEvent.mockDirection(firstEvent, direction) + } + return SentryGestureListener( + WeakReference(window), + hub, + options, + isAndroidXAvailable + ) + } + } + + private val fixture = Fixture() + + @Test + fun `captures a scroll breadcrumb`() { + val sut = fixture.getSut(direction = "left") + + sut.onDown(fixture.firstEvent) + fixture.eventsInBetween.forEach { + sut.onScroll(fixture.firstEvent, it, 10.0f, 0f) + } + sut.onUp(fixture.endEvent) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("ui.scroll", it.category) + assertEquals("user", it.type) + assertEquals("test_scroll_view", it.data["view.id"]) + assertEquals(fixture.target.javaClass.canonicalName, it.data["view.class"]) + assertEquals("left", it.data["direction"]) + assertEquals(INFO, it.level) + } + ) + } + + @Test + fun `if no target found, does not capture a breadcrumb`() { + val sut = fixture.getSut(touchWithinBounds = false) + + sut.onDown(fixture.firstEvent) + fixture.eventsInBetween.forEach { + sut.onScroll(fixture.firstEvent, it, 10f, 0f) + } + sut.onUp(fixture.endEvent) + + verify(fixture.hub, never()).addBreadcrumb(any()) + } + + @Test + fun `resets scroll state between gestures`() { + val sut = fixture.getSut(resourceName = "pager", direction = "down") + + // first scroll down + sut.onDown(fixture.firstEvent) + fixture.eventsInBetween.forEach { sut.onScroll(fixture.firstEvent, it, 0f, 30.0f) } + sut.onFling(fixture.firstEvent, fixture.endEvent, 1.0f, 1.0f) + sut.onUp(fixture.endEvent) + + // second scroll up + fixture.endEvent.mockDirection(fixture.firstEvent, "up") + + sut.onDown(fixture.firstEvent) + fixture.eventsInBetween.forEach { sut.onScroll(fixture.firstEvent, it, 0f, -30.0f) } + sut.onFling(fixture.firstEvent, fixture.endEvent, 1.0f, 1.0f) + sut.onUp(fixture.endEvent) + + inOrder(fixture.hub) { + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("ui.swipe", it.category) + assertEquals("user", it.type) + assertEquals("pager", it.data["view.id"]) + assertEquals(fixture.target.javaClass.canonicalName, it.data["view.class"]) + assertEquals("down", it.data["direction"]) + assertEquals(INFO, it.level) + } + ) + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("ui.swipe", it.category) + assertEquals("user", it.type) + assertEquals("pager", it.data["view.id"]) + assertEquals(fixture.target.javaClass.canonicalName, it.data["view.class"]) + assertEquals("up", it.data["direction"]) + assertEquals(INFO, it.level) + } + ) + } + verifyNoMoreInteractions(fixture.hub) + } + + @Test + fun `if no scroll or swipe event occurred, does not capture a breadcrumb`() { + val sut = fixture.getSut() + sut.onUp(fixture.firstEvent) + sut.onDown(fixture.endEvent) + + verify(fixture.hub, never()).addBreadcrumb(any()) + } + + @Test + fun `if androidX is not available, does not capture a breadcrumb for ScrollingView`() { + val sut = fixture.getSut(isAndroidXAvailable = false) + + sut.onDown(fixture.firstEvent) + fixture.eventsInBetween.forEach { + sut.onScroll(fixture.firstEvent, it, 10.0f, 0f) + } + sut.onUp(fixture.endEvent) + + verify(fixture.hub, never()).addBreadcrumb(any()) + } + + internal class ScrollableView : View(mock()), ScrollingView { + override fun computeVerticalScrollOffset(): Int = 0 + override fun computeVerticalScrollExtent(): Int = 0 + override fun computeVerticalScrollRange(): Int = 0 + override fun computeHorizontalScrollOffset(): Int = 0 + override fun computeHorizontalScrollRange(): Int = 0 + override fun computeHorizontalScrollExtent(): Int = 0 + } + + internal open class ScrollableListView : AbsListView(mock()) { + override fun getAdapter(): ListAdapter = mock() + override fun setSelection(position: Int) = Unit + } + + companion object { + + private fun MotionEvent.mockDirection( + firstEvent: MotionEvent, + direction: String + ) { + val initialStartX = firstEvent.x + val initialStartY = firstEvent.y + when (direction) { + "up" -> { + whenever(x).thenReturn(initialStartX) + whenever(y).thenReturn((initialStartY - 2)) + } + "down" -> { + whenever(x).thenReturn(initialStartX) + whenever(y).thenReturn((initialStartY + 2)) + } + "right" -> { + whenever(x).thenReturn((initialStartX + 2)) + whenever(y).thenReturn(initialStartY) + } + "left" -> { + whenever(x).thenReturn((initialStartX - 2)) + whenever(y).thenReturn(initialStartY) + } + } + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt new file mode 100644 index 0000000000..dd8f10b56f --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt @@ -0,0 +1,92 @@ +package io.sentry.android.core.internal.gestures + +import android.view.MotionEvent +import android.view.Window +import androidx.core.view.GestureDetectorCompat +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.inOrder +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.android.core.internal.gestures.SentryWindowCallback.MotionEventObtainer +import org.junit.Test + +class SentryWindowCallbackTest { + class Fixture { + val delegate = mock() + val options = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + } + val gestureDetector = mock() + val gestureListener = mock() + val motionEventCopy = mock() + + fun getSut(): SentryWindowCallback { + return SentryWindowCallback( + delegate, + gestureDetector, + gestureListener, + options, + object : MotionEventObtainer { + override fun obtain(origin: MotionEvent): MotionEvent { + val actionMasked = origin.actionMasked + whenever(motionEventCopy.actionMasked).doReturn(actionMasked) + return motionEventCopy + } + } + ) + } + } + + private val fixture = Fixture() + + @Test + fun `delegates the events to the gesture detector`() { + val event = mock() + val sut = fixture.getSut() + + sut.dispatchTouchEvent(event) + + verify(fixture.gestureDetector).onTouchEvent(fixture.motionEventCopy) + verify(fixture.motionEventCopy).recycle() + } + + @Test + fun `on action up will call the gesture listener after delegating to gesture detector`() { + val event = mock { + whenever(it.actionMasked).thenReturn(MotionEvent.ACTION_UP) + } + val sut = fixture.getSut() + + sut.dispatchTouchEvent(event) + + inOrder(fixture.gestureDetector, fixture.gestureListener) { + verify(fixture.gestureDetector).onTouchEvent(fixture.motionEventCopy) + verify(fixture.gestureListener).onUp(fixture.motionEventCopy) + } + } + + @Test + fun `other events are ignored for gesture listener`() { + val event = mock { + whenever(it.actionMasked).thenReturn(MotionEvent.ACTION_DOWN) + } + val sut = fixture.getSut() + + sut.dispatchTouchEvent(event) + + verify(fixture.gestureListener, never()).onUp(any()) + } + + @Test + fun `nullable event is ignored`() { + val sut = fixture.getSut() + + sut.dispatchTouchEvent(null) + + verify(fixture.gestureDetector, never()).onTouchEvent(any()) + } +} 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 new file mode 100644 index 0000000000..f637fcd3a4 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt @@ -0,0 +1,68 @@ +package io.sentry.android.core.internal.gestures + +import android.content.res.Resources +import android.view.MotionEvent +import android.view.View +import android.view.Window +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlin.math.abs + +internal inline fun Window.mockDecorView( + id: Int = View.generateViewId(), + event: MotionEvent, + touchWithinBounds: Boolean = true, + clickable: Boolean = false, + visible: Boolean = true, + finalize: (T) -> Unit = {} +): T { + val view = mockView(id, event, touchWithinBounds, clickable, visible, finalize) + whenever(decorView).doReturn(view) + return view +} + +internal inline fun mockView( + id: Int = View.generateViewId(), + event: MotionEvent, + touchWithinBounds: Boolean = true, + clickable: Boolean = false, + visible: Boolean = true, + finalize: (T) -> Unit = {} +): T { + val coordinates = IntArray(2) + if (!touchWithinBounds) { + coordinates[0] = (event.x).toInt() + 10 + coordinates[1] = (event.y).toInt() + 10 + } else { + coordinates[0] = (event.x).toInt() - 10 + coordinates[1] = (event.y).toInt() - 10 + } + val mockView: T = mock { + whenever(it.id).thenReturn(id) + whenever(it.isClickable).thenReturn(clickable) + whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) + + whenever(it.getLocationOnScreen(any())).doAnswer { + val array = it.arguments[0] as IntArray + array[0] = coordinates[0] + array[1] = coordinates[1] + null + } + + val diffPosX = abs(event.x - coordinates[0]).toInt() + val diffPosY = abs(event.y - coordinates[1]).toInt() + whenever(it.width).thenReturn(diffPosX + 10) + whenever(it.height).thenReturn(diffPosY + 10) + + finalize(this.mock) + } + + return mockView +} + +internal fun Resources.mockForTarget(target: View, expectedResourceName: String) { + whenever(getResourceEntryName(target.id)).thenReturn(expectedResourceName) +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt new file mode 100644 index 0000000000..e7d5fa1489 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -0,0 +1,43 @@ +package io.sentry.android.core.internal.gestures + +import android.content.Context +import android.content.res.Resources +import android.view.View +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.junit.Test +import kotlin.test.assertEquals + +class ViewUtilsTest { + + @Test + fun `getResourceId returns resourceId when available`() { + val view = mock { + whenever(it.id).doReturn(View.generateViewId()) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view") + whenever(context.resources).thenReturn(resources) + whenever(it.context).thenReturn(context) + } + + assertEquals(ViewUtils.getResourceId(view), "test_view") + } + + @Test + fun `getResourceId falls back to hexadecimal id when resource not found`() { + val view = mock { + whenever(it.id).doReturn(1234) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenThrow(Resources.NotFoundException()) + whenever(context.resources).thenReturn(resources) + whenever(it.context).thenReturn(context) + } + + assertEquals(ViewUtils.getResourceId(view), "0x4d2") + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/util/DeviceOrientationsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/DeviceOrientationsTest.kt similarity index 88% rename from sentry-android-core/src/test/java/io/sentry/android/core/util/DeviceOrientationsTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/internal/util/DeviceOrientationsTest.kt index f0148f67ef..1de7d8d92c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/util/DeviceOrientationsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/DeviceOrientationsTest.kt @@ -1,10 +1,10 @@ -package io.sentry.android.core.util +package io.sentry.android.core.internal.util import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.ORIENTATION_PORTRAIT import android.content.res.Configuration.ORIENTATION_SQUARE import android.content.res.Configuration.ORIENTATION_UNDEFINED -import io.sentry.android.core.util.DeviceOrientations.getOrientation +import io.sentry.android.core.internal.util.DeviceOrientations.getOrientation import io.sentry.protocol.Device import kotlin.test.Test import kotlin.test.assertEquals diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/util/MainThreadCheckerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/MainThreadCheckerTest.kt similarity index 96% rename from sentry-android-core/src/test/java/io/sentry/android/core/util/MainThreadCheckerTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/internal/util/MainThreadCheckerTest.kt index 2a122d6f87..391074325a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/util/MainThreadCheckerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/MainThreadCheckerTest.kt @@ -1,4 +1,4 @@ -package io.sentry.android.core.util +package io.sentry.android.core.internal.util import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.protocol.SentryThread diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/util/RootCheckerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/RootCheckerTest.kt similarity index 94% rename from sentry-android-core/src/test/java/io/sentry/android/core/util/RootCheckerTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/internal/util/RootCheckerTest.kt index a17b19694a..f60a2dd2b4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/util/RootCheckerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/RootCheckerTest.kt @@ -1,4 +1,4 @@ -package io.sentry.android.core.util +package io.sentry.android.core.internal.util import android.content.Context import android.content.pm.PackageInfo @@ -32,7 +32,14 @@ class RootCheckerTest { whenever(buildInfoProvider.buildTags).thenReturn(tags) whenever(context.packageManager).thenReturn(packageManager) - return RootChecker(context, buildInfoProvider, logger, rootFiles, rootPackages, runtime) + return RootChecker( + context, + buildInfoProvider, + logger, + rootFiles, + rootPackages, + runtime + ) } } private val fixture = Fixture() diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 602b4d9684..9c6ff20748 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -113,6 +113,7 @@ dependencies { // } implementation(Config.Libs.appCompat) + implementation(Config.Libs.androidxRecylerView) implementation(Config.Libs.retrofit2) implementation(Config.Libs.retrofit2Gson) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 3852bf2e86..c6f42d48dc 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -41,6 +41,10 @@ android:name=".ThirdActivityFragment" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GesturesActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GesturesActivity.kt new file mode 100644 index 0000000000..6f6f0fe8e0 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/GesturesActivity.kt @@ -0,0 +1,87 @@ +package io.sentry.samples.android + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentStatePagerAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.sentry.samples.android.R.layout +import io.sentry.samples.android.databinding.ActivityGesturesBinding +import io.sentry.samples.android.databinding.FragmentRecyclerBinding +import java.util.UUID + +@Suppress("DEPRECATION") +class GesturesActivity : AppCompatActivity() { + + private lateinit var binding: ActivityGesturesBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityGesturesBinding.inflate(layoutInflater) + + binding.pager.adapter = object : FragmentStatePagerAdapter(supportFragmentManager) { + override fun getCount(): Int = 2 + + override fun getItem(position: Int): Fragment = + if (position == 0) ScrollingFragment() else RecyclerFragment() + } + + binding.scrollingCrash.setOnClickListener { + throw RuntimeException("Uncaught Exception") + } + + setContentView(binding.root) + } +} + +class ScrollingFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(layout.fragment_scrolling, container, false) +} + +class RecyclerFragment : Fragment() { + + private var binding: FragmentRecyclerBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + this.binding = FragmentRecyclerBinding.inflate(inflater, container, false) + val binding = requireNotNull(this.binding) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.adapter = RecyclerAdapter() + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private class RecyclerAdapter : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(android.R.layout.simple_list_item_1, parent, false) + return object : ViewHolder(view) {} + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + (holder.itemView as TextView).text = UUID.randomUUID().toString() + } + + override fun getItemCount(): Int = 100 + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 05ff3893ab..efeeca1d41 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -160,9 +160,10 @@ protected void onCreate(Bundle savedInstanceState) { view -> SampleFragment.newInstance().show(getSupportFragmentManager(), null)); binding.openThirdFragment.setOnClickListener( - view -> { - startActivity(new Intent(this, ThirdActivityFragment.class)); - }); + view -> startActivity(new Intent(this, ThirdActivityFragment.class))); + + binding.openGesturesActivity.setOnClickListener( + view -> startActivity(new Intent(this, GesturesActivity.class))); setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_gestures.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_gestures.xml new file mode 100644 index 0000000000..9650b237ae --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_gestures.xml @@ -0,0 +1,25 @@ + + + + + +