Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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);
Comment on lines +86 to +96
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The SentryGestureDetector doesn't handle multi-touch events, causing it to incorrectly fire onSingleTapUp callbacks during gestures like pinch-to-zoom.
Severity: MEDIUM

Suggested Fix

In the onTouchEvent method, add cases for ACTION_POINTER_DOWN and ACTION_POINTER_UP. In these cases, cancel the tap detection by setting the isInTapRegion flag to false. This will prevent onSingleTapUp from being called when a multi-touch gesture is in progress.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java#L60-L96

Potential issue: The `SentryGestureDetector.onTouchEvent` method does not handle
multi-touch events like `ACTION_POINTER_DOWN` or `ACTION_POINTER_UP`. When a user
performs a multi-touch gesture (e.g., pinch-to-zoom), the `isInTapRegion` flag remains
`true` after the first pointer goes down. When any pointer is lifted, an `ACTION_UP`
event is processed while `isInTapRegion` is still true, incorrectly triggering an
`onSingleTapUp` callback. This results in spurious click breadcrumbs being generated for
multi-touch gestures, leading to inaccurate user interaction telemetry.

} 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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -29,15 +26,15 @@ public SentryWindowCallback(
final @Nullable SentryOptions options) {
this(
delegate,
new GestureDetectorCompat(context, gestureListener, new Handler(Looper.getMainLooper())),
new SentryGestureDetector(context, gestureListener),
gestureListener,
options,
new MotionEventObtainer() {});
}

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) {
Expand Down
Loading
Loading