From 947f2583e91f0a4617f6c675ebb0a03a0c744974 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 17 Jul 2026 17:14:06 +0200 Subject: [PATCH 1/4] perf(android): Init shake detector off the main thread (JAVA-618) FeedbackShakeIntegration.register() resolved the accelerometer via SensorManager synchronously on the calling thread, which under auto-init is the main thread. On a Pixel 10 this first SensorManager access measured ~1.75ms, making it the single most expensive integration in the Sentry.init register loop. Submit the pre-warm init() to the executor service instead. start() already re-runs the idempotent init() on demand, so shake detection still works if an activity resumes before the warm-up completes. SentryShakeDetector's lifecycle methods are now synchronized so the executor warm-up and a main-thread start() cannot race on the sensor fields. Co-Authored-By: Claude Opus 4.8 --- .../core/FeedbackShakeIntegration.java | 17 +++++++++-- .../android/core/SentryShakeDetector.java | 11 ++++---- .../core/FeedbackShakeIntegrationTest.kt | 28 ++++++++++++++++++- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java index fc34f18152f..79d88ebae56 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java @@ -44,11 +44,24 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions : null, "SentryAndroidOptions is required"); - if (!this.options.getFeedbackOptions().isUseShakeGesture()) { + final @NotNull SentryAndroidOptions options = this.options; + + if (!options.getFeedbackOptions().isUseShakeGesture()) { return; } - shakeDetector.init(application, options.getLogger()); + // Resolving the accelerometer is the most expensive part of init (the first SensorManager + // access), so warm it up off the main thread. start() re-runs init() on demand, so shake + // detection still works if an activity resumes before this completes. + try { + options + .getExecutorService() + .submit(() -> shakeDetector.init(application, options.getLogger())); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to submit shake detector initialization.", t); + } addIntegrationToSdkVersion("FeedbackShake"); application.registerActivityLifecycleCallbacks(this); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java index a4c4ae0c4f5..32b5bc55a4a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java @@ -55,12 +55,12 @@ public SentryShakeDetector(final @NotNull ILogger logger) { * Initializes the sensor manager and accelerometer sensor. This is separated from start() so the * values can be resolved once and reused across activity transitions. */ - void init(final @NotNull Context context, final @NotNull ILogger logger) { + synchronized void init(final @NotNull Context context, final @NotNull ILogger logger) { this.logger = logger; init(context); } - private void init(final @NotNull Context context) { + private synchronized void init(final @NotNull Context context) { if (sensorManager == null) { sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); } @@ -74,7 +74,8 @@ private void init(final @NotNull Context context) { } } - public void start(final @NotNull Context context, final @NotNull Listener shakeListener) { + public synchronized void start( + final @NotNull Context context, final @NotNull Listener shakeListener) { this.listener = shakeListener; init(context); if (sensorManager == null) { @@ -89,7 +90,7 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL, handler); } - public void stop() { + public synchronized void stop() { listener = null; if (sensorManager != null) { sensorManager.unregisterListener(this); @@ -105,7 +106,7 @@ public void stop() { } /** Stops detection and releases the background thread. */ - public void close() { + public synchronized void close() { stop(); if (handlerThread != null) { // quitSafely drains pending messages (including the clear posted by stop) before exiting diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt index bddc9395c0d..163ade492dc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt @@ -2,13 +2,17 @@ package io.sentry.android.core import android.app.Activity import android.app.Application +import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Scopes import io.sentry.SentryFeedbackOptions +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService import kotlin.test.BeforeTest import kotlin.test.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -20,7 +24,11 @@ class FeedbackShakeIntegrationTest { private class Fixture { val application = mock() val scopes = mock() - val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + executorService = ImmediateExecutorService() + } val activity = mock() val formHandler = mock() @@ -49,6 +57,24 @@ class FeedbackShakeIntegrationTest { verify(fixture.application).registerActivityLifecycleCallbacks(any()) } + @Test + fun `resolves the accelerometer sensor off the main thread`() { + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + // Callback registration stays synchronous, but the expensive SensorManager lookup is deferred. + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + + deferredExecutor.runAll() + + verify(fixture.application).getSystemService(eq(Context.SENSOR_SERVICE)) + } + @Test fun `when useShakeGesture is disabled does not register activity lifecycle callbacks`() { val sut = fixture.getSut(useShakeGesture = false) From 8d17137cb14bbdb94a296c8042e8eec1d2225aea Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 17 Jul 2026 17:14:38 +0200 Subject: [PATCH 2/4] docs: Add changelog for shake-detector init off main thread (JAVA-618) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44231ff4eed..a936f34b5be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Reduce main-thread work during `Sentry.init` by resolving the shake-detector accelerometer off the main thread (~1.75ms on a Pixel 10) ([#5784](https://github.com/getsentry/sentry-java/pull/5784)) - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) ## 8.49.0 From 6b4dae5e82a873744440fbaf2b7fb2e0f17e2ff1 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 21 Jul 2026 14:03:38 +0200 Subject: [PATCH 3/4] fix(android): Guard shake detector against warm-up after close (JAVA-618) A warm-up init submitted to the executor could be drained after the integration's close() ran, since integrations shut down before the executor. That re-resolved the sensor and leaked a HandlerThread. Guard init()/start() with a closed latch so a late warm-up is a no-op. Co-Authored-By: Claude Opus 4.8 --- .../android/core/SentryShakeDetector.java | 10 ++++++++++ .../core/FeedbackShakeIntegrationTest.kt | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java index 32b5bc55a4a..1be91b6079f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java @@ -40,6 +40,7 @@ public final class SentryShakeDetector implements SensorEventListener { private @Nullable Handler handler; private volatile @Nullable Listener listener; private @NotNull ILogger logger; + private boolean closed; private final @NotNull SampleQueue queue = new SampleQueue(); @@ -61,6 +62,11 @@ synchronized void init(final @NotNull Context context, final @NotNull ILogger lo } private synchronized void init(final @NotNull Context context) { + // A warm-up submitted to the executor can be drained after close() (integrations are closed + // before the executor shuts down), so bail out instead of spinning up a new HandlerThread. + if (closed) { + return; + } if (sensorManager == null) { sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); } @@ -76,6 +82,9 @@ private synchronized void init(final @NotNull Context context) { public synchronized void start( final @NotNull Context context, final @NotNull Listener shakeListener) { + if (closed) { + return; + } this.listener = shakeListener; init(context); if (sensorManager == null) { @@ -107,6 +116,7 @@ public synchronized void stop() { /** Stops detection and releases the background thread. */ public synchronized void close() { + closed = true; stop(); if (handlerThread != null) { // quitSafely drains pending messages (including the clear posted by stop) before exiting diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt index 163ade492dc..525337ba17c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt @@ -75,6 +75,23 @@ class FeedbackShakeIntegrationTest { verify(fixture.application).getSystemService(eq(Context.SENSOR_SERVICE)) } + @Test + fun `warm-up drained after close does not resolve the sensor`() { + // Integrations are closed before the executor drains, so a queued warm-up can run after + // close(). It must be a no-op rather than resolving the sensor and spinning up a HandlerThread. + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.close() + + deferredExecutor.runAll() + + verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + @Test fun `when useShakeGesture is disabled does not register activity lifecycle callbacks`() { val sut = fixture.getSut(useShakeGesture = false) From fddc6d006b82727fdd7cf2aca2e5060987965bcb Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 21 Jul 2026 14:10:38 +0200 Subject: [PATCH 4/4] fix(android): Re-arm shake detector on re-register (JAVA-618) The closed latch added to neutralize a warm-up drained after close() was permanent, so re-registering the same integration (e.g. a second Sentry.init reusing the same options) left shake detection off with no recovery. register() now re-arms the detector via reopen(), which the stale-warm-up path (init()) deliberately does not, preserving the drain-after-close guard. Co-Authored-By: Claude Opus 4.8 --- .../core/FeedbackShakeIntegration.java | 5 +++++ .../android/core/SentryShakeDetector.java | 8 ++++++++ .../core/FeedbackShakeIntegrationTest.kt | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java index 79d88ebae56..b059b0104da 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java @@ -50,6 +50,11 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions return; } + // Re-arm the detector in case this integration is being re-registered after a previous close() + // (e.g. a second Sentry.init reusing the same options), otherwise the closed latch would keep + // shake detection off permanently. + shakeDetector.reopen(); + // Resolving the accelerometer is the most expensive part of init (the first SensorManager // access), so warm it up off the main thread. start() re-runs init() on demand, so shake // detection still works if an activity resumes before this completes. diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java index 1be91b6079f..9f4f73d10f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java @@ -52,6 +52,14 @@ public SentryShakeDetector(final @NotNull ILogger logger) { this.logger = logger; } + /** + * Re-arms the detector after a previous {@link #close()} so it can be reused when the owning + * integration is registered again (e.g. a second {@code Sentry.init}). + */ + synchronized void reopen() { + closed = false; + } + /** * Initializes the sensor manager and accelerometer sensor. This is separated from start() so the * values can be resolved once and reused across activity transitions. diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt index 525337ba17c..170211abf55 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt @@ -12,6 +12,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -92,6 +93,24 @@ class FeedbackShakeIntegrationTest { verify(fixture.application, never()).getSystemService(eq(Context.SENSOR_SERVICE)) } + @Test + fun `re-registering after close re-arms shake detection`() { + // A second Sentry.init reusing the same integration must revive shake detection rather than + // stay off because of the closed latch. + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + whenever(fixture.application.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + sut.close() + sut.register(fixture.scopes, fixture.options) + + deferredExecutor.runAll() + + verify(fixture.application, atLeastOnce()).getSystemService(eq(Context.SENSOR_SERVICE)) + } + @Test fun `when useShakeGesture is disabled does not register activity lifecycle callbacks`() { val sut = fixture.getSut(useShakeGesture = false)