From e008d8cefd8c14043e463fd3ec8541c9c8145288 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 2 Mar 2026 20:51:45 +0100 Subject: [PATCH 01/13] fix(android): Replace GestureDetectorCompat with lightweight SentryGestureDetector to fix ANR GestureDetectorCompat internally uses Handler.sendMessage/removeMessages which acquires a synchronized lock on the main thread MessageQueue, plus recordGestureClassification triggers IPC calls. This caused ANRs under load (SDK-CRASHES-JAVA-596, 175K+ occurrences). Replace with a minimal custom detector that only detects click, scroll, and fling without any Handler scheduling, MessageQueue contention, or IPC overhead. Co-Authored-By: Claude Opus 4.6 --- .../gestures/SentryGestureDetector.java | 125 ++++++++++++++++++ .../gestures/SentryWindowCallback.java | 9 +- .../gestures/SentryWindowCallbackTest.kt | 3 +- 3 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java 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 00000000000..34826d109ee --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureDetector.java @@ -0,0 +1,125 @@ +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(); + } + 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 edb9c9f9daa..8d00686157b 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/internal/gestures/SentryWindowCallbackTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryWindowCallbackTest.kt index 856e6d0f156..8afc1b39304 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() From 06a26ed935027baea1f57d7fb3402afb3452a9d6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 2 Mar 2026 20:59:09 +0100 Subject: [PATCH 02/13] test(android): Add unit tests for SentryGestureDetector Co-Authored-By: Claude Opus 4.6 --- .../gestures/SentryGestureDetectorTest.kt | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureDetectorTest.kt 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 00000000000..9cc0552172d --- /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() + } +} From 7ef8b550e8e89ce8b3b46216d6b4ff69734af80e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 2 Mar 2026 21:05:54 +0100 Subject: [PATCH 03/13] changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8357f0699df..1a85997ca26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,6 +171,7 @@ - Fix attribute type detection for `Long`, `Short`, `Byte`, `BigInteger`, `AtomicInteger`, and `AtomicLong` being incorrectly inferred as `double` instead of `integer` ([#5122](https://github.com/getsentry/sentry-java/pull/5122)) - Remove `AndroidRuntimeManager` StrictMode relaxation to prevent ANRs during SDK init ([#5127](https://github.com/getsentry/sentry-java/pull/5127)) - **IMPORTANT:** StrictMode violations may appear again in debug builds. This is intentional to prevent ANRs in production releases. +- 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)) - Use `peekDecorView` instead of `getDecorView` in `SentryGestureListener` to avoid forcing view hierarchy construction ([#5134](https://github.com/getsentry/sentry-java/pull/5134)) - Log an actionable error message when Relay returns HTTP 413 (Content Too Large) ([#5115](https://github.com/getsentry/sentry-java/pull/5115)) From bf76e0dcf5fc157e01fa79692b1183e93a50d597 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 2 Mar 2026 22:50:54 +0100 Subject: [PATCH 04/13] fix(gestures): Clear VelocityTracker on ACTION_DOWN to prevent stale velocity data Matches GestureDetector behavior: if consecutive ACTION_DOWN events arrive without an intervening ACTION_UP/ACTION_CANCEL, stale motion data could bleed into fling detection. Co-Authored-By: Claude Opus 4.6 --- .../android/core/internal/gestures/SentryGestureDetector.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index 34826d109ee..228ecaa8507 100644 --- 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 @@ -50,6 +50,10 @@ boolean onTouchEvent(final @NotNull MotionEvent event) { if (velocityTracker == null) { velocityTracker = VelocityTracker.obtain(); } + + if (action == MotionEvent.ACTION_DOWN) { + velocityTracker.clear(); + } velocityTracker.addMovement(event); switch (action) { From 505917f3bc060adc460d824d5c3c23cbd76bb733 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 2 Mar 2026 23:15:18 +0100 Subject: [PATCH 05/13] fix(gestures): Remove stale GestureDetectorCompat class availability check UserInteractionIntegration gated itself on GestureDetectorCompat being available via classloader check, but SentryGestureDetector only uses Android SDK classes. Remove the check so the integration works without androidx.core. Also remove the stale proguard -keep rule. Co-Authored-By: Claude Opus 4.6 --- sentry-android-core/proguard-rules.pro | 1 - .../core/UserInteractionIntegration.java | 33 +++++++------------ .../core/UserInteractionIntegrationTest.kt | 17 ---------- 3 files changed, 11 insertions(+), 40 deletions(-) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index aca674442bf..4cd76f9a20d 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 9f47fc8666c..c0dd3f9eb71 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/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt index 4f1495a9875..f558841e6f5 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() From 47117f4902243e2601ef9cd9da006daaca7a4bf7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 15 Apr 2026 12:24:31 +0200 Subject: [PATCH 06/13] Move changelog entry --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a85997ca26..74219c17d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Fix ANR caused by `GestureDetectorCompat` Handler/MessageQueue lock contention in `SentryWindowCallback` ([#5138](https://github.com/getsentry/sentry-java/pull/5138)) + ### Internal - Bump AGP version from v8.6.0 to v8.13.1 ([#5063](https://github.com/getsentry/sentry-java/pull/5063)) @@ -171,7 +175,6 @@ - Fix attribute type detection for `Long`, `Short`, `Byte`, `BigInteger`, `AtomicInteger`, and `AtomicLong` being incorrectly inferred as `double` instead of `integer` ([#5122](https://github.com/getsentry/sentry-java/pull/5122)) - Remove `AndroidRuntimeManager` StrictMode relaxation to prevent ANRs during SDK init ([#5127](https://github.com/getsentry/sentry-java/pull/5127)) - **IMPORTANT:** StrictMode violations may appear again in debug builds. This is intentional to prevent ANRs in production releases. -- 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)) - Use `peekDecorView` instead of `getDecorView` in `SentryGestureListener` to avoid forcing view hierarchy construction ([#5134](https://github.com/getsentry/sentry-java/pull/5134)) - Log an actionable error message when Relay returns HTTP 413 (Content Too Large) ([#5115](https://github.com/getsentry/sentry-java/pull/5115)) From e9db3cb5003b1b41f115a61aaf1357b9a1a14181 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 15 Apr 2026 13:06:24 +0200 Subject: [PATCH 07/13] ref(gestures): Simplify SentryGestureDetector resource lifecycle Keep VelocityTracker alive across gesture cycles instead of obtain/recycle churn on every gesture. Add release() method called from SentryWindowCallback.stopTracking() to prevent native resource leaks when activity is destroyed mid-gesture. Also fix broken Javadoc @link to removed dependency, change onTouchEvent return to void, and remove redundant null check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gestures/SentryGestureDetector.java | 23 +++++++++++-------- .../gestures/SentryWindowCallback.java | 1 + 2 files changed, 14 insertions(+), 10 deletions(-) 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 index 228ecaa8507..1e69b4f696d 100644 --- 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 @@ -10,9 +10,9 @@ 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). + * A lightweight gesture detector that replaces {@code 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 @@ -44,7 +44,7 @@ public final class SentryGestureDetector { this.maximumFlingVelocity = config.getScaledMaximumFlingVelocity(); } - boolean onTouchEvent(final @NotNull MotionEvent event) { + void onTouchEvent(final @NotNull MotionEvent event) { final int action = event.getActionMasked(); if (velocityTracker == null) { @@ -94,7 +94,7 @@ boolean onTouchEvent(final @NotNull MotionEvent event) { case MotionEvent.ACTION_UP: if (isInTapRegion) { listener.onSingleTapUp(event); - } else if (velocityTracker != null) { + } else { final int pointerId = event.getPointerId(0); velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); final float velocityX = velocityTracker.getXVelocity(pointerId); @@ -105,22 +105,25 @@ boolean onTouchEvent(final @NotNull MotionEvent event) { listener.onFling(currentDownEvent, event, velocityX, velocityY); } } - cleanup(); + endGesture(); break; case MotionEvent.ACTION_CANCEL: - cleanup(); + endGesture(); break; } - - return false; } - private void cleanup() { + /** Releases native resources. Call when the detector is no longer needed. */ + void release() { + endGesture(); if (velocityTracker != null) { velocityTracker.recycle(); velocityTracker = null; } + } + + private void endGesture() { 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 8d00686157b..557cd4e7a29 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 @@ -73,6 +73,7 @@ private void handleTouchEvent(final @NotNull MotionEvent motionEvent) { public void stopTracking() { gestureListener.stopTracing(SpanStatus.CANCELLED); + gestureDetector.release(); } public @NotNull Window.Callback getDelegate() { From cbe998cff3c9a5f1f9504a136db12b1fd5c021e3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 15 Apr 2026 21:08:48 +0200 Subject: [PATCH 08/13] fix(gestures): Handle multi-touch to prevent spurious tap detection When a second finger touches the screen (ACTION_POINTER_DOWN), cancel tap detection by setting isInTapRegion to false. Previously, pinch-to-zoom gestures where the first finger stayed still would incorrectly trigger onSingleTapUp, producing false click breadcrumbs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gestures/SentryGestureDetector.java | 5 ++++ .../gestures/SentryGestureDetectorTest.kt | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+) 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 index 1e69b4f696d..18d8972394b 100644 --- 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 @@ -91,6 +91,11 @@ void onTouchEvent(final @NotNull MotionEvent event) { break; } + case MotionEvent.ACTION_POINTER_DOWN: + // A second finger means this is not a single tap (e.g. pinch-to-zoom). + isInTapRegion = false; + break; + case MotionEvent.ACTION_UP: if (isInTapRegion) { listener.onSingleTapUp(event); 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 index 9cc0552172d..58c79cb2bb4 100644 --- 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 @@ -208,6 +208,36 @@ class SentryGestureDetectorTest { cancel.recycle() } + @Test + fun `multi-touch - POINTER_DOWN cancels tap so UP does not fire onSingleTapUp`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + // Second finger touches — encoded as ACTION_POINTER_DOWN with pointer index 1 + val pointerDown = + MotionEvent.obtain( + downTime, + downTime + 20, + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) or MotionEvent.ACTION_POINTER_DOWN, + 100f, + 100f, + 0, + ) + val up = MotionEvent.obtain(downTime, downTime + 100, MotionEvent.ACTION_UP, 100f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(pointerDown) + sut.onTouchEvent(up) + + verify(fixture.listener).onDown(down) + verify(fixture.listener, never()).onSingleTapUp(any()) + + down.recycle() + pointerDown.recycle() + up.recycle() + } + @Test fun `sequential gestures - state resets between tap and scroll`() { val sut = fixture.getSut() From 5726ea7b8b6cd0393076423f81060640db8d64ab Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Apr 2026 12:30:39 +0200 Subject: [PATCH 09/13] chore: Register btrace-perfetto skill in agents.toml Add local btrace-perfetto skill for capturing and comparing Perfetto traces on Android devices using btrace 3.0. Co-Authored-By: Claude Opus 4.6 (1M context) --- agents.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agents.toml b/agents.toml index b2347f8e7e6..b4c9e091b70 100644 --- a/agents.toml +++ b/agents.toml @@ -31,3 +31,7 @@ source = "path:.agents/skills/create-java-pr" [[skills]] name = "test" source = "path:.agents/skills/test" + +[[skills]] +name = "btrace-perfetto" +source = "path:.agents/skills/btrace-perfetto" From 76e9b72a139c673ab22799911e5a9409b3851dd9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Apr 2026 12:32:01 +0200 Subject: [PATCH 10/13] chore: Add btrace-perfetto skill for Perfetto trace capture Workflow skill that automates capturing and comparing Perfetto traces using btrace 3.0 on Android devices. Includes a Perfetto UI viewer template with SQL query deep-linking via postMessage API. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/.gitignore | 2 + .claude/skills/btrace-perfetto/SKILL.md | 174 ++++++++++++++++++ .../assets/viewer-template.html | 49 +++++ 3 files changed, 225 insertions(+) create mode 100644 .claude/skills/btrace-perfetto/SKILL.md create mode 100644 .claude/skills/btrace-perfetto/assets/viewer-template.html diff --git a/.claude/skills/.gitignore b/.claude/skills/.gitignore index 08243027e52..229f4495ee3 100644 --- a/.claude/skills/.gitignore +++ b/.claude/skills/.gitignore @@ -6,3 +6,5 @@ !create-java-pr/** !test/ !test/** +!btrace-perfetto/ +!btrace-perfetto/** diff --git a/.claude/skills/btrace-perfetto/SKILL.md b/.claude/skills/btrace-perfetto/SKILL.md new file mode 100644 index 00000000000..ec909138135 --- /dev/null +++ b/.claude/skills/btrace-perfetto/SKILL.md @@ -0,0 +1,174 @@ +--- +name: btrace-perfetto +description: Capture and compare Perfetto traces using btrace 3.0 on an Android device. Use when asked to "profile", "capture trace", "perfetto trace", "btrace", "compare traces", "record perfetto", "trace touch events", "measure performance on device", or benchmark Android SDK changes between branches. +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, AskUserQuestion +argument-hint: "[branch1] [branch2] [duration] [sql-query]" +--- + +# btrace Perfetto Trace Capture + +Capture Perfetto traces with btrace 3.0 on a connected Android device, optionally comparing two branches. Opens results in Perfetto UI with a prefilled SQL query. + +## Prerequisites + +Before starting, verify: + +1. **Connected device**: `adb devices` shows a device (Android 8.0+, 64-bit) +2. **btrace CLI jar**: Check if `tools/btrace/rhea-trace-shell.jar` exists. If not, download it: + ```bash + mkdir -p tools/btrace/traces + curl -sL "https://repo1.maven.org/maven2/com/bytedance/btrace/rhea-trace-processor/3.0.0/rhea-trace-processor-3.0.0.jar" \ + -o tools/btrace/rhea-trace-shell.jar + ``` +3. **Device ABI**: Run `adb shell getprop ro.product.cpu.abi` — btrace only supports arm64-v8a and armeabi-v7a (no x86/x86_64) + +## Step 1: Parse Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| branch1 | current branch | First branch to trace | +| branch2 | `main` | Second branch to compare against | +| duration | `20` | Trace duration in seconds | +| sql-query | see below | SQL query to prefill in Perfetto UI | + +If no arguments are provided, ask the user what they want to trace and which branches to compare. If only one branch is given, capture only that branch (no comparison). + +## Step 2: Integrate btrace into Sample App + +The sample app is at `sentry-samples/sentry-samples-android/`. + +### 2a: Add btrace dependency + +In `sentry-samples/sentry-samples-android/build.gradle.kts`, add to the `dependencies` block: + +```kotlin +implementation("com.bytedance.btrace:rhea-inhouse:3.0.0") +``` + +### 2b: Restrict ABI to device architecture + +The btrace native library (shadowhook) does not support x86/x86_64. Replace the `ndk` abiFilters line in `defaultConfig` to match the connected device: + +```kotlin +ndk { abiFilters.addAll(listOf("arm64-v8a")) } +``` + +Adjust if the device reports a different ABI. + +### 2c: Initialize btrace in Application + +In `MyApplication.java`, add `attachBaseContext`: + +```java +import android.content.Context; +import com.bytedance.rheatrace.RheaTrace3; + +// Add before onCreate: +@Override +protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + RheaTrace3.init(base); +} +``` + +**Important**: The package is `com.bytedance.rheatrace`, not `com.bytedance.btrace`. + +## Step 3: Build and Install + +```bash +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +## Step 4: Capture Trace + +For each branch to trace: + +### 4a: Set btrace properties and launch app + +```bash +adb shell setprop debug.rhea3.startWhenAppLaunch 1 +adb shell setprop debug.rhea3.waitTraceTimeout 60 +adb shell am force-stop io.sentry.samples.android +sleep 1 +adb shell am start -n io.sentry.samples.android/.MainActivity +``` + +The app must be started AFTER `debug.rhea3.startWhenAppLaunch` is set, otherwise the trace server won't initialize. + +### 4b: Tell the user to interact with the app, then capture + +```bash +java -jar tools/btrace/rhea-trace-shell.jar \ + -a io.sentry.samples.android \ + -t ${duration} \ + -waitTraceTimeout 60 \ + -o tools/btrace/traces/${branch_name}.pb \ + sched +``` + +Do NOT use the `-r` flag — it fails to resolve the launcher activity because LeakCanary registers a second one. Launch the app manually in step 4a instead. + +### 4c: Switch branches for comparison + +When capturing a second branch: + +1. Stash the btrace integration changes: + ```bash + git stash push -m "btrace integration" -- \ + sentry-samples/sentry-samples-android/build.gradle.kts \ + sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java + ``` +2. Checkout the other branch +3. Pop the stash: `git stash pop` +4. Rebuild and install: `./gradlew :sentry-samples:sentry-samples-android:installDebug` +5. Repeat steps 4a and 4b with a different output filename +6. Switch back to the original branch and restore files + +## Step 5: Open in Perfetto UI + +Generate a viewer HTML and serve it locally. Use the template at `assets/viewer-template.html` as a base — copy it to `tools/btrace/traces/viewer.html` and replace the placeholder values: + +- `TRACE_FILES`: array of `{file, title}` objects for each captured trace +- `SQL_QUERY`: the SQL query to prefill + +The SQL query is passed via the URL hash parameter: `https://ui.perfetto.dev/#!/?query=...` + +The trace data is sent via the postMessage API (required for local files — URL deep-linking does not work with `file://`). + +Start a local HTTP server and open the viewer: + +```bash +cd tools/btrace/traces && python3 -m http.server 8008 & +open http://localhost:8008/viewer.html +``` + +### Default SQL Query + +If no custom query is provided, use: + +```sql +SELECT + s.name AS slice_name, + s.dur / 1e6 AS dur_ms, + s.ts, + t.name AS track_name +FROM slice s +JOIN thread_track t ON s.track_id = t.id +WHERE s.name GLOB '*SentryWindowCallback.dispatch*' +ORDER BY s.ts +``` + +## Cleanup + +After tracing is complete, remind the user that the btrace integration changes to the sample app should NOT be committed. The `tools/btrace/` directory is gitignored. + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `No compatible library found [shadowhook]` | Restrict `ndk.abiFilters` to arm64-v8a only | +| `package com.bytedance.btrace does not exist` | Use `com.bytedance.rheatrace` (not `btrace`) | +| `ResolverActivity does not exist` with `-r` flag | Don't use `-r`; launch the app manually before capturing | +| `wait for trace ready timeout` on download | Set `debug.rhea3.startWhenAppLaunch=1` BEFORE launching the app, and use `-waitTraceTimeout 60` | +| Empty jar file (0 bytes) | Download from Maven Central (`repo1.maven.org`), not `oss.sonatype.org` | +| `FileNotFoundException` on sampling download | App was already running when properties were set; force-stop and relaunch | diff --git a/.claude/skills/btrace-perfetto/assets/viewer-template.html b/.claude/skills/btrace-perfetto/assets/viewer-template.html new file mode 100644 index 00000000000..4c31a24c342 --- /dev/null +++ b/.claude/skills/btrace-perfetto/assets/viewer-template.html @@ -0,0 +1,49 @@ + + +btrace Trace Viewer + +

Perfetto Trace Viewer

+
+

+ + + From 9d1f94fe57e3ad6ba24f9c4417f2e5be838a6af7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Apr 2026 13:06:55 +0200 Subject: [PATCH 11/13] chore: Update btrace skill with release builds and sound cues Use release builds for accurate profiling, add ProGuard keep rules for btrace and Sentry class names, increase trace duration to 30s, and play a Ping sound synced to the actual trace start. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/btrace-perfetto/SKILL.md | 38 +++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/.claude/skills/btrace-perfetto/SKILL.md b/.claude/skills/btrace-perfetto/SKILL.md index ec909138135..d7c308847d3 100644 --- a/.claude/skills/btrace-perfetto/SKILL.md +++ b/.claude/skills/btrace-perfetto/SKILL.md @@ -28,7 +28,7 @@ Before starting, verify: |----------|---------|-------------| | branch1 | current branch | First branch to trace | | branch2 | `main` | Second branch to compare against | -| duration | `20` | Trace duration in seconds | +| duration | `30` | Trace duration in seconds | | sql-query | see below | SQL query to prefill in Perfetto UI | If no arguments are provided, ask the user what they want to trace and which branches to compare. If only one branch is given, capture only that branch (no comparison). @@ -73,10 +73,23 @@ protected void attachBaseContext(Context base) { **Important**: The package is `com.bytedance.rheatrace`, not `com.bytedance.btrace`. +### 2d: Add ProGuard keep rule for release builds + +In `sentry-samples/sentry-samples-android/proguard-rules.pro`, add: + +``` +-keep class com.bytedance.rheatrace.** { *; } +-keepnames class io.sentry.** { *; } +``` + +The first rule prevents R8 from stripping btrace's HTTP server classes (fails with `SocketException` otherwise). The second preserves Sentry class and method names so they appear readable in the Perfetto trace instead of obfuscated single-letter names. + ## Step 3: Build and Install +Always use release builds for tracing — debug builds have StrictMode, debuggable overhead, and no R8 optimizations, which skew results. + ```bash -./gradlew :sentry-samples:sentry-samples-android:installDebug +./gradlew :sentry-samples:sentry-samples-android:installRelease ``` ## Step 4: Capture Trace @@ -95,7 +108,20 @@ adb shell am start -n io.sentry.samples.android/.MainActivity The app must be started AFTER `debug.rhea3.startWhenAppLaunch` is set, otherwise the trace server won't initialize. -### 4b: Tell the user to interact with the app, then capture +### 4b: Play a sound to signal the user, then capture + +Play a sound when tracing actually starts so the user knows to begin interacting. Pipe btrace output through a loop that triggers the sound on the "start tracing" line: + +```bash +java -jar tools/btrace/rhea-trace-shell.jar ... 2>&1 | while IFS= read -r line; do + echo "$line" + if [[ "$line" == *"start tracing"* ]]; then + afplay -v 1.5 /System/Library/Sounds/Ping.aiff & + fi +done +``` + +This ensures the sound is synchronized with the actual trace start, not an estimated delay. ```bash java -jar tools/btrace/rhea-trace-shell.jar \ @@ -116,11 +142,12 @@ When capturing a second branch: ```bash git stash push -m "btrace integration" -- \ sentry-samples/sentry-samples-android/build.gradle.kts \ - sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java + sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java \ + sentry-samples/sentry-samples-android/proguard-rules.pro ``` 2. Checkout the other branch 3. Pop the stash: `git stash pop` -4. Rebuild and install: `./gradlew :sentry-samples:sentry-samples-android:installDebug` +4. Rebuild and install: `./gradlew :sentry-samples:sentry-samples-android:installRelease` 5. Repeat steps 4a and 4b with a different output filename 6. Switch back to the original branch and restore files @@ -172,3 +199,4 @@ After tracing is complete, remind the user that the btrace integration changes t | `wait for trace ready timeout` on download | Set `debug.rhea3.startWhenAppLaunch=1` BEFORE launching the app, and use `-waitTraceTimeout 60` | | Empty jar file (0 bytes) | Download from Maven Central (`repo1.maven.org`), not `oss.sonatype.org` | | `FileNotFoundException` on sampling download | App was already running when properties were set; force-stop and relaunch | +| `SocketException: Unexpected end of file` in release builds | R8 stripped btrace classes; add `-keep class com.bytedance.rheatrace.** { *; }` to proguard-rules.pro | From e2f3e359ec9e52ed43bab213ff75ef4af412e1c3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Apr 2026 13:43:33 +0200 Subject: [PATCH 12/13] chore: Update btrace-perfetto skill with debug builds and trace comparison Prefer debug builds for richer tracing instrumentation (Handler, MessageQueue, Lock slices). Add trace_processor prerequisite for local querying. Add Step 6 with SQL queries and comparison table generation. Include sampling rate reference and additional troubleshooting entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/btrace-perfetto/SKILL.md | 122 ++++++++++++++++++++---- 1 file changed, 102 insertions(+), 20 deletions(-) diff --git a/.claude/skills/btrace-perfetto/SKILL.md b/.claude/skills/btrace-perfetto/SKILL.md index d7c308847d3..7cbea4841ee 100644 --- a/.claude/skills/btrace-perfetto/SKILL.md +++ b/.claude/skills/btrace-perfetto/SKILL.md @@ -7,7 +7,7 @@ argument-hint: "[branch1] [branch2] [duration] [sql-query]" # btrace Perfetto Trace Capture -Capture Perfetto traces with btrace 3.0 on a connected Android device, optionally comparing two branches. Opens results in Perfetto UI with a prefilled SQL query. +Capture Perfetto traces with btrace 3.0 on a connected Android device, optionally comparing two branches. Opens results in Perfetto UI with a prefilled SQL query. After capture, query traces locally with `trace_processor` to compute comparison stats. ## Prerequisites @@ -20,7 +20,11 @@ Before starting, verify: curl -sL "https://repo1.maven.org/maven2/com/bytedance/btrace/rhea-trace-processor/3.0.0/rhea-trace-processor-3.0.0.jar" \ -o tools/btrace/rhea-trace-shell.jar ``` -3. **Device ABI**: Run `adb shell getprop ro.product.cpu.abi` — btrace only supports arm64-v8a and armeabi-v7a (no x86/x86_64) +3. **Perfetto trace_processor**: Check if `/tmp/trace_processor` exists. If not, download it: + ```bash + curl -sL "https://get.perfetto.dev/trace_processor" -o /tmp/trace_processor && chmod +x /tmp/trace_processor + ``` +4. **Device ABI**: Run `adb shell getprop ro.product.cpu.abi` — btrace only supports arm64-v8a and armeabi-v7a (no x86/x86_64) ## Step 1: Parse Arguments @@ -73,9 +77,9 @@ protected void attachBaseContext(Context base) { **Important**: The package is `com.bytedance.rheatrace`, not `com.bytedance.btrace`. -### 2d: Add ProGuard keep rule for release builds +### 2d: Add ProGuard keep rules (release builds only) -In `sentry-samples/sentry-samples-android/proguard-rules.pro`, add: +Only needed when building release. In `sentry-samples/sentry-samples-android/proguard-rules.pro`, add: ``` -keep class com.bytedance.rheatrace.** { *; } @@ -86,7 +90,13 @@ The first rule prevents R8 from stripping btrace's HTTP server classes (fails wi ## Step 3: Build and Install -Always use release builds for tracing — debug builds have StrictMode, debuggable overhead, and no R8 optimizations, which skew results. +Prefer **debug builds** — they provide richer tracing instrumentation (Handler, MessageQueue, Monitor:Lock slices visible) which is essential for comparing internal SDK behavior. Use the default 1kHz btrace sampling rate for debug builds. + +```bash +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +**Release builds** are useful when you need to measure real-world performance without StrictMode/debuggable overhead or with R8 optimizations. Require the ProGuard keep rules from step 2d. Use `-sampleInterval 333000` (333μs / 3kHz) for finer granularity since release code runs faster. ```bash ./gradlew :sentry-samples:sentry-samples-android:installRelease @@ -98,40 +108,40 @@ For each branch to trace: ### 4a: Set btrace properties and launch app +Clear any stale port files, set properties, and launch: + ```bash +adb shell "rm -rf /storage/emulated/0/Android/data/io.sentry.samples.android/files/rhea-port" adb shell setprop debug.rhea3.startWhenAppLaunch 1 adb shell setprop debug.rhea3.waitTraceTimeout 60 adb shell am force-stop io.sentry.samples.android -sleep 1 +sleep 2 adb shell am start -n io.sentry.samples.android/.MainActivity +sleep 5 ``` -The app must be started AFTER `debug.rhea3.startWhenAppLaunch` is set, otherwise the trace server won't initialize. +The app must be started AFTER `debug.rhea3.startWhenAppLaunch` is set, otherwise the trace server won't initialize. The 5s sleep after launch gives the btrace HTTP server time to start. ### 4b: Play a sound to signal the user, then capture Play a sound when tracing actually starts so the user knows to begin interacting. Pipe btrace output through a loop that triggers the sound on the "start tracing" line: -```bash -java -jar tools/btrace/rhea-trace-shell.jar ... 2>&1 | while IFS= read -r line; do - echo "$line" - if [[ "$line" == *"start tracing"* ]]; then - afplay -v 1.5 /System/Library/Sounds/Ping.aiff & - fi -done -``` - -This ensures the sound is synchronized with the actual trace start, not an estimated delay. - ```bash java -jar tools/btrace/rhea-trace-shell.jar \ -a io.sentry.samples.android \ -t ${duration} \ -waitTraceTimeout 60 \ -o tools/btrace/traces/${branch_name}.pb \ - sched + sched 2>&1 | while IFS= read -r line; do + echo "$line" + if [[ "$line" == *"start tracing"* ]]; then + afplay -v 1.5 /System/Library/Sounds/Ping.aiff & + fi + done ``` +For release builds with finer sampling, add `-sampleInterval 333000`. + Do NOT use the `-r` flag — it fails to resolve the launcher activity because LeakCanary registers a second one. Launch the app manually in step 4a instead. ### 4c: Switch branches for comparison @@ -147,7 +157,7 @@ When capturing a second branch: ``` 2. Checkout the other branch 3. Pop the stash: `git stash pop` -4. Rebuild and install: `./gradlew :sentry-samples:sentry-samples-android:installRelease` +4. Rebuild and install (same variant — debug or release — as the first branch) 5. Repeat steps 4a and 4b with a different output filename 6. Switch back to the original branch and restore files @@ -185,6 +195,76 @@ WHERE s.name GLOB '*SentryWindowCallback.dispatch*' ORDER BY s.ts ``` +## Step 6: Query and Compare Traces + +After capturing both branches, use `trace_processor` to compute comparison stats locally. + +### Basic stats query + +For each trace file, run: + +```bash +/tmp/trace_processor -Q " +WITH events AS ( + SELECT s.dur / 1e6 as dur_ms FROM slice s + WHERE s.name GLOB '*${METHOD_GLOB}*' AND s.dur > 0 + ORDER BY s.dur +) +SELECT COUNT(*) as count, + ROUND(AVG(dur_ms), 4) as avg_ms, + ROUND((SELECT dur_ms FROM events LIMIT 1 OFFSET (SELECT COUNT(*)/2 FROM events)), 4) as median_ms, + ROUND(MIN(dur_ms), 4) as min_ms, + ROUND(MAX(dur_ms), 4) as max_ms +FROM events +" tools/btrace/traces/${trace_file}.pb +``` + +Replace `${METHOD_GLOB}` with the method pattern to compare (e.g. `SentryGestureDetector.onTouchEvent`, `SentryWindowCallback.dispatchTouchEvent`). + +### Finding child calls (debug builds) + +To find what happens inside a method (e.g. Handler calls, lock acquisitions): + +```bash +/tmp/trace_processor -Q " +WITH RECURSIVE descendants(id, depth) AS ( + SELECT s.id, 0 FROM slice s WHERE s.name GLOB '*${PARENT_METHOD}*' + UNION ALL + SELECT s.id, d.depth + 1 FROM slice s JOIN descendants d ON s.parent_id = d.id WHERE d.depth < 10 +) +SELECT s.name, COUNT(*) as count, ROUND(AVG(s.dur / 1e6), 3) as avg_ms +FROM slice s JOIN descendants d ON s.id = d.id +WHERE d.depth > 0 +GROUP BY s.name ORDER BY count DESC +LIMIT 20 +" tools/btrace/traces/${trace_file}.pb +``` + +### Build the comparison table + +Run the stats query on both trace files, then present a markdown table: + +``` +| Metric | Branch A | Branch B | Delta | +|--------|----------|----------|-------| +| Count | ... | ... | | +| Average| ... | ... | -X% | +| Median | ... | ... | -X% | +| Max | ... | ... | -X% | +``` + +Compute delta as `(branchA - branchB) / branchB * 100`. Negative means branch A is faster. + +### Sampling rate reference + +| Rate | Interval | `-sampleInterval` | Use case | +|------|----------|-------------------|----------| +| 1 kHz | 1ms | `1000000` (default) | Debug builds, general profiling | +| 3 kHz | 333μs | `333000` | Release builds, finer granularity | +| 10 kHz | 100μs | `100000` | Maximum detail, higher overhead | + +Higher sampling rates capture shorter method calls but add CPU overhead which can skew results. For most comparisons, the default 1kHz is sufficient. + ## Cleanup After tracing is complete, remind the user that the btrace integration changes to the sample app should NOT be committed. The `tools/btrace/` directory is gitignored. @@ -200,3 +280,5 @@ After tracing is complete, remind the user that the btrace integration changes t | Empty jar file (0 bytes) | Download from Maven Central (`repo1.maven.org`), not `oss.sonatype.org` | | `FileNotFoundException` on sampling download | App was already running when properties were set; force-stop and relaunch | | `SocketException: Unexpected end of file` in release builds | R8 stripped btrace classes; add `-keep class com.bytedance.rheatrace.** { *; }` to proguard-rules.pro | +| Stale port from previous session | Run `adb shell "rm -rf /storage/emulated/0/Android/data/io.sentry.samples.android/files/rhea-port"` before launching | +| Most `onTouchEvent` durations are 0ms | Increase sampling rate with `-sampleInterval 333000` (3kHz) | From 7b389ecf12d994346838d55d839099a050b11a26 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Apr 2026 13:51:20 +0200 Subject: [PATCH 13/13] fix(gestures): Suppress fling after multi-touch to prevent misclassified swipes The ACTION_POINTER_DOWN handler suppressed taps but not flings. When the last finger lifts quickly after a pinch-to-zoom, the velocity check in ACTION_UP could fire onFling, causing SentryGestureListener to misclassify the gesture as a swipe breadcrumb. Add ignoreUpEvent flag mirroring GestureDetector's mIgnoreNextUpEvent to skip the entire UP handler after multi-touch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gestures/SentryGestureDetector.java | 10 +++ .../gestures/SentryGestureDetectorTest.kt | 84 +++++++++++++++++++ 2 files changed, 94 insertions(+) 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 index 18d8972394b..3196ae0189e 100644 --- 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 @@ -27,6 +27,7 @@ public final class SentryGestureDetector { private final int maximumFlingVelocity; private boolean isInTapRegion; + private boolean ignoreUpEvent; private float downX; private float downY; private float lastX; @@ -63,6 +64,7 @@ void onTouchEvent(final @NotNull MotionEvent event) { lastX = downX; lastY = downY; isInTapRegion = true; + ignoreUpEvent = false; if (currentDownEvent != null) { currentDownEvent.recycle(); @@ -93,10 +95,18 @@ void onTouchEvent(final @NotNull MotionEvent event) { case MotionEvent.ACTION_POINTER_DOWN: // A second finger means this is not a single tap (e.g. pinch-to-zoom). + // Also suppress the UP handler to avoid spurious fling detection when the + // last finger lifts quickly after a pinch — mirrors GestureDetector's + // mIgnoreNextUpEvent / cancelTaps() behavior. isInTapRegion = false; + ignoreUpEvent = true; break; case MotionEvent.ACTION_UP: + if (ignoreUpEvent) { + endGesture(); + break; + } if (isInTapRegion) { listener.onSingleTapUp(event); } else { 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 index 58c79cb2bb4..be15f9c578b 100644 --- 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 @@ -238,6 +238,90 @@ class SentryGestureDetectorTest { up.recycle() } + @Test + fun `multi-touch - POINTER_DOWN suppresses fling on fast UP`() { + val sut = fixture.getSut() + val downTime = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + // Second finger touches + val pointerDown = + MotionEvent.obtain( + downTime, + downTime + 20, + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) or MotionEvent.ACTION_POINTER_DOWN, + 100f, + 100f, + 0, + ) + // Second finger lifts + val pointerUp = + MotionEvent.obtain( + downTime, + downTime + 40, + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) or MotionEvent.ACTION_POINTER_UP, + 100f, + 100f, + 0, + ) + // Last finger lifts quickly and far — would normally trigger fling + val up = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 500f, 100f, 0) + + sut.onTouchEvent(down) + sut.onTouchEvent(pointerDown) + sut.onTouchEvent(pointerUp) + sut.onTouchEvent(up) + + verify(fixture.listener, never()).onSingleTapUp(any()) + verify(fixture.listener, never()).onFling(anyOrNull(), any(), any(), any()) + + down.recycle() + pointerDown.recycle() + pointerUp.recycle() + up.recycle() + } + + @Test + fun `multi-touch - tap works again after multi-touch gesture ends`() { + val sut = fixture.getSut() + var downTime = SystemClock.uptimeMillis() + + // First gesture: multi-touch (suppressed) + val down1 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + val pointerDown = + MotionEvent.obtain( + downTime, + downTime + 20, + (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) or MotionEvent.ACTION_POINTER_DOWN, + 100f, + 100f, + 0, + ) + val up1 = MotionEvent.obtain(downTime, downTime + 100, MotionEvent.ACTION_UP, 100f, 100f, 0) + + sut.onTouchEvent(down1) + sut.onTouchEvent(pointerDown) + sut.onTouchEvent(up1) + + verify(fixture.listener, never()).onSingleTapUp(any()) + + // Second gesture: normal tap — ignoreUpEvent should have been reset + downTime = SystemClock.uptimeMillis() + val down2 = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 200f, 200f, 0) + val up2 = MotionEvent.obtain(downTime, downTime + 50, MotionEvent.ACTION_UP, 200f, 200f, 0) + + sut.onTouchEvent(down2) + sut.onTouchEvent(up2) + + verify(fixture.listener).onSingleTapUp(up2) + + down1.recycle() + pointerDown.recycle() + up1.recycle() + down2.recycle() + up2.recycle() + } + @Test fun `sequential gestures - state resets between tap and scroll`() { val sut = fixture.getSut()