diff --git a/CHANGELOG.md b/CHANGELOG.md index dba0f4dcdf..a5abf567b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ ### Fixes +- Fix ANR caused by `GestureDetectorCompat` Handler/MessageQueue lock contention in `SentryWindowCallback` ([#5138](https://github.com/getsentry/sentry-java/pull/5138)) - Fix crash when unregistering `SystemEventsBroadcastReceiver` with try-catch block. ([#5106](https://github.com/getsentry/sentry-java/pull/5106)) ### Dependencies diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 25086b4d2b..4706a95479 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -1,7 +1,6 @@ ##---------------Begin: proguard configuration for android-core ---------- ##---------------Begin: proguard configuration for androidx.core ---------- --keep class androidx.core.view.GestureDetectorCompat { (...); } -keep class androidx.core.app.FrameMetricsAggregator { (...); } -keep interface androidx.core.view.ScrollingView { *; } ##---------------End: proguard configuration for androidx.core ---------- 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 9f47fc8666..c0dd3f9eb7 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 @@ -28,14 +28,11 @@ public final class UserInteractionIntegration private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; - private final boolean isAndroidXAvailable; private final boolean isAndroidxLifecycleAvailable; public UserInteractionIntegration( final @NotNull Application application, final @NotNull io.sentry.util.LoadClass classLoader) { this.application = Objects.requireNonNull(application, "Application is required"); - isAndroidXAvailable = - classLoader.isClassAvailable("androidx.core.view.GestureDetectorCompat", options); isAndroidxLifecycleAvailable = classLoader.isClassAvailable("androidx.lifecycle.Lifecycle", options); } @@ -128,27 +125,19 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { .log(SentryLevel.DEBUG, "UserInteractionIntegration enabled: %s", integrationEnabled); if (integrationEnabled) { - if (isAndroidXAvailable) { - application.registerActivityLifecycleCallbacks(this); - this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); - addIntegrationToSdkVersion("UserInteraction"); - - // In case of a deferred init, we hook into any resumed activity - if (isAndroidxLifecycleAvailable) { - final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); - if (activity instanceof LifecycleOwner) { - if (((LifecycleOwner) activity).getLifecycle().getCurrentState() - == Lifecycle.State.RESUMED) { - startTracking(activity); - } + application.registerActivityLifecycleCallbacks(this); + this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); + addIntegrationToSdkVersion("UserInteraction"); + + // In case of a deferred init, we hook into any resumed activity + if (isAndroidxLifecycleAvailable) { + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + if (activity instanceof LifecycleOwner) { + if (((LifecycleOwner) activity).getLifecycle().getCurrentState() + == Lifecycle.State.RESUMED) { + startTracking(activity); } } - } else { - options - .getLogger() - .log( - SentryLevel.INFO, - "androidx.core is not available, UserInteractionIntegration won't be installed"); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java new file mode 100644 index 0000000000..228ecaa850 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java @@ -0,0 +1,129 @@ +package io.sentry.android.core.internal.gestures; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.ViewConfiguration; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A lightweight gesture detector that replaces {@link + * androidx.core.view.GestureDetectorCompat}/{@link GestureDetector} to avoid ANRs caused by + * Handler/MessageQueue lock contention and IPC calls (FrameworkStatsLog.write). + * + *

Only detects click (tap), scroll, and fling — the gestures used by {@link + * SentryGestureListener}. Long-press, show-press, and double-tap detection (which require Handler + * message scheduling) are intentionally omitted. + */ +@ApiStatus.Internal +public final class SentryGestureDetector { + + private final @NotNull GestureDetector.OnGestureListener listener; + private final int touchSlopSquare; + private final int minimumFlingVelocity; + private final int maximumFlingVelocity; + + private boolean isInTapRegion; + private float downX; + private float downY; + private float lastX; + private float lastY; + private @Nullable MotionEvent currentDownEvent; + private @Nullable VelocityTracker velocityTracker; + + SentryGestureDetector( + final @NotNull Context context, final @NotNull GestureDetector.OnGestureListener listener) { + this.listener = listener; + final ViewConfiguration config = ViewConfiguration.get(context); + final int touchSlop = config.getScaledTouchSlop(); + this.touchSlopSquare = touchSlop * touchSlop; + this.minimumFlingVelocity = config.getScaledMinimumFlingVelocity(); + this.maximumFlingVelocity = config.getScaledMaximumFlingVelocity(); + } + + boolean onTouchEvent(final @NotNull MotionEvent event) { + final int action = event.getActionMasked(); + + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } + + if (action == MotionEvent.ACTION_DOWN) { + velocityTracker.clear(); + } + velocityTracker.addMovement(event); + + switch (action) { + case MotionEvent.ACTION_DOWN: + downX = event.getX(); + downY = event.getY(); + lastX = downX; + lastY = downY; + isInTapRegion = true; + + if (currentDownEvent != null) { + currentDownEvent.recycle(); + } + currentDownEvent = MotionEvent.obtain(event); + + listener.onDown(event); + break; + + case MotionEvent.ACTION_MOVE: + { + final float x = event.getX(); + final float y = event.getY(); + final float dx = x - downX; + final float dy = y - downY; + final float distanceSquare = (dx * dx) + (dy * dy); + + if (distanceSquare > touchSlopSquare) { + final float scrollX = lastX - x; + final float scrollY = lastY - y; + listener.onScroll(currentDownEvent, event, scrollX, scrollY); + isInTapRegion = false; + lastX = x; + lastY = y; + } + break; + } + + case MotionEvent.ACTION_UP: + if (isInTapRegion) { + listener.onSingleTapUp(event); + } else if (velocityTracker != null) { + final int pointerId = event.getPointerId(0); + velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); + final float velocityX = velocityTracker.getXVelocity(pointerId); + final float velocityY = velocityTracker.getYVelocity(pointerId); + + if (Math.abs(velocityX) > minimumFlingVelocity + || Math.abs(velocityY) > minimumFlingVelocity) { + listener.onFling(currentDownEvent, event, velocityX, velocityY); + } + } + cleanup(); + break; + + case MotionEvent.ACTION_CANCEL: + cleanup(); + break; + } + + return false; + } + + private void cleanup() { + if (velocityTracker != null) { + velocityTracker.recycle(); + velocityTracker = null; + } + if (currentDownEvent != null) { + currentDownEvent.recycle(); + currentDownEvent = null; + } + } +} 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 index edb9c9f9da..8d00686157 100644 --- 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 @@ -1,11 +1,8 @@ package io.sentry.android.core.internal.gestures; import android.content.Context; -import android.os.Handler; -import android.os.Looper; import android.view.MotionEvent; import android.view.Window; -import androidx.core.view.GestureDetectorCompat; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanStatus; @@ -18,7 +15,7 @@ public final class SentryWindowCallback extends WindowCallbackAdapter { private final @NotNull Window.Callback delegate; private final @NotNull SentryGestureListener gestureListener; - private final @NotNull GestureDetectorCompat gestureDetector; + private final @NotNull SentryGestureDetector gestureDetector; private final @Nullable SentryOptions options; private final @NotNull MotionEventObtainer motionEventObtainer; @@ -29,7 +26,7 @@ public SentryWindowCallback( final @Nullable SentryOptions options) { this( delegate, - new GestureDetectorCompat(context, gestureListener, new Handler(Looper.getMainLooper())), + new SentryGestureDetector(context, gestureListener), gestureListener, options, new MotionEventObtainer() {}); @@ -37,7 +34,7 @@ public SentryWindowCallback( SentryWindowCallback( final @NotNull Window.Callback delegate, - final @NotNull GestureDetectorCompat gestureDetector, + final @NotNull SentryGestureDetector gestureDetector, final @NotNull SentryGestureListener gestureListener, final @Nullable SentryOptions options, final @NotNull MotionEventObtainer motionEventObtainer) { 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 index 4f1495a987..f558841e6f 100644 --- 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 @@ -39,16 +39,8 @@ class UserInteractionIntegrationTest { fun getSut( callback: Window.Callback? = null, - isAndroidXAvailable: Boolean = true, isLifecycleAvailable: Boolean = true, ): UserInteractionIntegration { - whenever( - loadClass.isClassAvailable( - eq("androidx.core.view.GestureDetectorCompat"), - anyOrNull(), - ) - ) - .thenReturn(isAndroidXAvailable) whenever( loadClass.isClassAvailable( eq("androidx.lifecycle.Lifecycle"), @@ -99,15 +91,6 @@ class UserInteractionIntegrationTest { verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) } - @Test - fun `when androidx is unavailable doesn't register a callback`() { - val sut = fixture.getSut(isAndroidXAvailable = false) - - sut.register(fixture.scopes, fixture.options) - - verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) - } - @Test fun `registers window callback on activity resumed`() { val sut = fixture.getSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt new file mode 100644 index 0000000000..9cc0552172 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt @@ -0,0 +1,272 @@ +package io.sentry.android.core.internal.gestures + +import android.os.SystemClock +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class SentryGestureDetectorTest { + + class Fixture { + val listener = mock() + val context = ApplicationProvider.getApplicationContext() + val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + + fun getSut(): SentryGestureDetector { + return SentryGestureDetector(context, listener) + } + } + + private val fixture = Fixture() + + @Test + fun `tap - DOWN followed by UP within touch slop fires onSingleTapUp`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val up = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 100f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(up) + + verify(fixture.listener).onDown(down) + verify(fixture.listener).onSingleTapUp(up) + verify(fixture.listener, never()).onScroll(any(), any(), any(), any()) + verify(fixture.listener, never()).onFling(anyOrNull(), any(), any(), any()) + + down.recycle() + up.recycle() + } + + @Test + fun `no tap - DOWN followed by MOVE beyond slop and UP does not fire onSingleTapUp`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + val beyondSlop = fixture.touchSlop + 10f + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val move = + MotionEvent.obtain( + downTime, + downTime + 16, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 100f, + 0, + ) + val up = + MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 100f + beyondSlop, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(move) + sut.onTouchEvent(up) + + verify(fixture.listener, never()).onSingleTapUp(any()) + + down.recycle() + move.recycle() + up.recycle() + } + + @Test + fun `scroll - DOWN followed by MOVE beyond slop fires onScroll with correct deltas`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + val beyondSlop = fixture.touchSlop + 10f + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + val move = + MotionEvent.obtain( + downTime, + downTime + 16, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 200f, + 0, + ) + + sut.onTouchEvent(down) + sut.onTouchEvent(move) + + // scrollX = lastX - currentX = 100 - (100 + beyondSlop) = -beyondSlop + verify(fixture.listener).onScroll(anyOrNull(), eq(move), eq(-beyondSlop), eq(0f)) + + down.recycle() + move.recycle() + } + + @Test + fun `fling - fast swipe fires onFling`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + val beyondSlop = fixture.touchSlop + 10f + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + // Move far and fast (large distance in short time = high velocity) + val move = + MotionEvent.obtain( + downTime, + downTime + 10, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 100f, + 0, + ) + val up = MotionEvent.obtain(downTime, downTime + 20, MotionEvent.ACTION_UP, 500f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(move) + sut.onTouchEvent(up) + + verify(fixture.listener).onFling(anyOrNull(), eq(up), any(), any()) + + down.recycle() + move.recycle() + up.recycle() + } + + @Test + fun `slow release - DOWN MOVE and slow UP does not fire onFling`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + val beyondSlop = fixture.touchSlop + 1f + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + // Move just beyond slop + val move = + MotionEvent.obtain( + downTime, + downTime + 100, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 100f, + 0, + ) + // Stay at the same position for a long time to ensure near-zero velocity + val moveStill = + MotionEvent.obtain( + downTime, + downTime + 10000, + MotionEvent.ACTION_MOVE, + 100f + beyondSlop, + 100f, + 0, + ) + val up = + MotionEvent.obtain( + downTime, + downTime + 10001, + MotionEvent.ACTION_UP, + 100f + beyondSlop, + 100f, + 0, + ) + + sut.onTouchEvent(down) + sut.onTouchEvent(move) + sut.onTouchEvent(moveStill) + sut.onTouchEvent(up) + + verify(fixture.listener, never()).onFling(anyOrNull(), any(), any(), any()) + + down.recycle() + move.recycle() + moveStill.recycle() + up.recycle() + } + + @Test + fun `cancel - DOWN followed by CANCEL does not fire tap or fling callbacks`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val cancel = + MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_CANCEL, 100f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(cancel) + + verify(fixture.listener).onDown(down) + verify(fixture.listener, never()).onSingleTapUp(any()) + verify(fixture.listener, never()).onScroll(any(), any(), any(), any()) + verify(fixture.listener, never()).onFling(anyOrNull(), any(), any(), any()) + + down.recycle() + cancel.recycle() + } + + @Test + fun `sequential gestures - state resets between tap and scroll`() { + val sut = fixture.getSut() + val beyondSlop = fixture.touchSlop + 10f + + // First gesture: tap + var downTime = SystemClock.uptimeMillis() + val down1 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val up1 = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 100f, 100f, 0) + + sut.onTouchEvent(down1) + sut.onTouchEvent(up1) + verify(fixture.listener).onSingleTapUp(up1) + + // Second gesture: scroll + downTime = SystemClock.uptimeMillis() + val down2 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 200f, 200f, 0) + val move2 = + MotionEvent.obtain( + downTime, + downTime + 16, + MotionEvent.ACTION_MOVE, + 200f + beyondSlop, + 200f, + 0, + ) + val up2 = + MotionEvent.obtain( + downTime, + downTime + 5000, + MotionEvent.ACTION_UP, + 200f + beyondSlop, + 200f, + 0, + ) + + sut.onTouchEvent(down2) + sut.onTouchEvent(move2) + sut.onTouchEvent(up2) + + verify(fixture.listener).onScroll(anyOrNull(), eq(move2), any(), any()) + // onSingleTapUp should NOT have been called again for the second gesture + verify(fixture.listener, never()).onSingleTapUp(up2) + + // Third gesture: another tap to verify clean reset + downTime = SystemClock.uptimeMillis() + val down3 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 300f, 300f, 0) + val up3 = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 300f, 300f, 0) + + sut.onTouchEvent(down3) + sut.onTouchEvent(up3) + verify(fixture.listener).onSingleTapUp(up3) + + down1.recycle() + up1.recycle() + down2.recycle() + move2.recycle() + up2.recycle() + down3.recycle() + up3.recycle() + } +} 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 index 856e6d0f15..8afc1b3930 100644 --- 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 @@ -2,7 +2,6 @@ package io.sentry.android.core.internal.gestures import android.view.MotionEvent import android.view.Window -import androidx.core.view.GestureDetectorCompat import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.internal.gestures.SentryWindowCallback.MotionEventObtainer import kotlin.test.Test @@ -18,7 +17,7 @@ class SentryWindowCallbackTest { class Fixture { val delegate = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } - val gestureDetector = mock() + val gestureDetector = mock() val gestureListener = mock() val motionEventCopy = mock()