From c04bebe17fa050763765b335472d6e1fcf6d7226 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 17:29:28 +0100 Subject: [PATCH 01/25] feat(feedback): implement shake gesture detection for user feedback form Adds SentryShakeDetector (accelerometer-based) and ShakeDetectionIntegration that shows the feedback dialog when a shake is detected. Controlled by SentryFeedbackOptions.useShakeGesture (default false). Co-Authored-By: Claude Opus 4.6 --- .../core/AndroidOptionsInitializer.java | 1 + .../android/core/SentryShakeDetector.java | 85 ++++++++++++++ .../core/ShakeDetectionIntegration.java | 104 ++++++++++++++++++ .../sentry/android/core/SentryAndroidTest.kt | 3 +- .../java/io/sentry/SentryFeedbackOptions.java | 24 ++++ 5 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index a189b30d07b..cf89be72f1f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -404,6 +404,7 @@ static void installDefaultIntegrations( (Application) context, buildInfoProvider, activityFramesTracker)); options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context)); options.addIntegration(new UserInteractionIntegration((Application) context, loadClass)); + options.addIntegration(new ShakeDetectionIntegration((Application) context)); if (isFragmentAvailable) { options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true)); } 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 new file mode 100644 index 00000000000..4756d6aab4d --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java @@ -0,0 +1,85 @@ +package io.sentry.android.core; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Detects shake gestures using the device's accelerometer. + * + *

The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on + * Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors. + */ +public final class SentryShakeDetector implements SensorEventListener { + + private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f; + private static final int SHAKE_COOLDOWN_MS = 1000; + + private @Nullable SensorManager sensorManager; + private long lastShakeTimestamp = 0; + private @Nullable Listener listener; + private final @NotNull ILogger logger; + + public interface Listener { + void onShake(); + } + + public SentryShakeDetector(final @NotNull ILogger logger) { + this.logger = logger; + } + + public void start(final @NotNull Context context, final @NotNull Listener shakeListener) { + this.listener = shakeListener; + sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + if (sensorManager == null) { + logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled."); + return; + } + Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + if (accelerometer == null) { + logger.log( + SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled."); + return; + } + sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI); + } + + public void stop() { + if (sensorManager != null) { + sensorManager.unregisterListener(this); + sensorManager = null; + } + listener = null; + } + + @Override + public void onSensorChanged(final @NotNull SensorEvent event) { + if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) { + return; + } + float gX = event.values[0] / SensorManager.GRAVITY_EARTH; + float gY = event.values[1] / SensorManager.GRAVITY_EARTH; + float gZ = event.values[2] / SensorManager.GRAVITY_EARTH; + double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ); + if (gForce > SHAKE_THRESHOLD_GRAVITY) { + long now = System.currentTimeMillis(); + if (now - lastShakeTimestamp > SHAKE_COOLDOWN_MS) { + lastShakeTimestamp = now; + if (listener != null) { + listener.onShake(); + } + } + } + } + + @Override + public void onAccuracyChanged(final @NotNull Sensor sensor, final int accuracy) { + // Not needed for shake detection. + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java new file mode 100644 index 00000000000..96b21c9e864 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java @@ -0,0 +1,104 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import io.sentry.IScopes; +import io.sentry.Integration; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active + * when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}. + */ +public final class ShakeDetectionIntegration + implements Integration, Closeable, Application.ActivityLifecycleCallbacks { + + private final @NotNull Application application; + private @Nullable SentryShakeDetector shakeDetector; + private @Nullable SentryAndroidOptions options; + private @Nullable Activity currentActivity; + + public ShakeDetectionIntegration(final @NotNull Application application) { + this.application = Objects.requireNonNull(application, "Application is required"); + } + + @Override + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) { + this.options = (SentryAndroidOptions) sentryOptions; + + if (!this.options.getFeedbackOptions().isUseShakeGesture()) { + return; + } + + addIntegrationToSdkVersion("ShakeDetection"); + application.registerActivityLifecycleCallbacks(this); + options.getLogger().log(SentryLevel.DEBUG, "ShakeDetectionIntegration installed."); + } + + @Override + public void close() throws IOException { + application.unregisterActivityLifecycleCallbacks(this); + stopShakeDetection(); + } + + @Override + public void onActivityResumed(final @NotNull Activity activity) { + currentActivity = activity; + startShakeDetection(activity); + } + + @Override + public void onActivityPaused(final @NotNull Activity activity) { + stopShakeDetection(); + currentActivity = null; + } + + @Override + public void onActivityCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(final @NotNull Activity activity) {} + + @Override + public void onActivityStopped(final @NotNull Activity activity) {} + + @Override + public void onActivitySaveInstanceState( + final @NotNull Activity activity, final @NotNull Bundle outState) {} + + @Override + public void onActivityDestroyed(final @NotNull Activity activity) {} + + private void startShakeDetection(final @NotNull Activity activity) { + if (shakeDetector != null || options == null) { + return; + } + shakeDetector = new SentryShakeDetector(options.getLogger()); + shakeDetector.start( + activity, + () -> { + final Activity active = currentActivity; + if (active != null && options != null) { + active.runOnUiThread( + () -> options.getFeedbackOptions().getDialogHandler().showDialog(null, null)); + } + }); + } + + private void stopShakeDetection() { + if (shakeDetector != null) { + shakeDetector.stop(); + shakeDetector = null; + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index b7fad8abee2..a3414fd3acb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -476,7 +476,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(18, options.integrations.size) + assertEquals(19, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -488,6 +488,7 @@ class SentryAndroidTest { it is ActivityLifecycleIntegration || it is ActivityBreadcrumbsIntegration || it is UserInteractionIntegration || + it is ShakeDetectionIntegration || it is FragmentLifecycleIntegration || it is SentryTimberIntegration || it is AppComponentsBreadcrumbsIntegration || diff --git a/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java b/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java index 77e0741f8d6..2a0ead54234 100644 --- a/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java +++ b/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java @@ -35,6 +35,9 @@ public final class SentryFeedbackOptions { /** Displays the Sentry logo inside of the form. Defaults to true. */ private boolean showBranding = true; + /** Shows the feedback form when a shake gesture is detected. Defaults to {@code false}. */ + private boolean useShakeGesture = false; + // Text Customization /** The title of the feedback form. Defaults to "Report a Bug". */ private @NotNull CharSequence formTitle = "Report a Bug"; @@ -102,6 +105,7 @@ public SentryFeedbackOptions(final @NotNull SentryFeedbackOptions other) { this.showEmail = other.showEmail; this.useSentryUser = other.useSentryUser; this.showBranding = other.showBranding; + this.useShakeGesture = other.useShakeGesture; this.formTitle = other.formTitle; this.submitButtonLabel = other.submitButtonLabel; this.cancelButtonLabel = other.cancelButtonLabel; @@ -234,6 +238,24 @@ public void setShowBranding(final boolean showBranding) { this.showBranding = showBranding; } + /** + * Shows the feedback form when a shake gesture is detected. Defaults to {@code false}. + * + * @return true if shake gesture triggers the feedback form + */ + public boolean isUseShakeGesture() { + return useShakeGesture; + } + + /** + * Sets whether the feedback form is shown when a shake gesture is detected. + * + * @param useShakeGesture true to enable shake gesture triggering + */ + public void setUseShakeGesture(final boolean useShakeGesture) { + this.useShakeGesture = useShakeGesture; + } + /** * The title of the feedback form. Defaults to "Report a Bug". * @@ -547,6 +569,8 @@ public String toString() { + useSentryUser + ", showBranding=" + showBranding + + ", useShakeGesture=" + + useShakeGesture + ", formTitle='" + formTitle + '\'' From 177bb48ad20bcee12fe7e5542de2e066a390ce1a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 4 Mar 2026 13:08:48 +0100 Subject: [PATCH 02/25] fix(feedback): improve shake detection robustness and add tests - Add volatile/AtomicLong for thread-safe cross-thread field access - Use SystemClock.elapsedRealtime() instead of System.currentTimeMillis() - Use SENSOR_DELAY_NORMAL for better battery efficiency - Add multi-shake counting (2+ threshold crossings within 1.5s window) - Handle deferred init for already-resumed activities - Wrap showDialog() in try-catch to prevent app crashes - Improve activity transition handling in onActivityPaused - Mark SentryShakeDetector as @ApiStatus.Internal - Add unit tests for SentryShakeDetector and ShakeDetectionIntegration Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 + .../api/sentry-android-core.api | 25 +++ .../android/core/SentryShakeDetector.java | 43 ++++- .../core/ShakeDetectionIntegration.java | 32 +++- .../android/core/SentryShakeDetectorTest.kt | 154 ++++++++++++++++++ .../core/ShakeDetectionIntegrationTest.kt | 106 ++++++++++++ sentry/api/sentry.api | 2 + 7 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/ShakeDetectionIntegrationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e7485668864..44319b3ddd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features +- Show feedback form on device shake ([#5150](https://github.com/getsentry/sentry-java/pull/5150)) + - Enable via `options.getFeedbackOptions().setUseShakeGesture(true)` + - Uses the device's accelerometer — no special permissions required - Create `sentry-opentelemetry-otlp` and `sentry-opentelemetry-otlp-spring` modules for combining OpenTelemetry SDK OTLP export with Sentry SDK ([#5100](https://github.com/getsentry/sentry-java/pull/5100)) - OpenTelemetry is configured to send spans to Sentry directly using an OTLP endpoint. - Sentry only uses trace and span ID from OpenTelemetry (via `OpenTelemetryOtlpEventProcessor`) but will not send spans through OpenTelemetry nor use OpenTelemetry `Context` for `Scopes` propagation. diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 7e64d0bcf80..c7e8cebc1e8 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -457,6 +457,18 @@ public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/Se public fun trackCustomMasking ()V } +public final class io/sentry/android/core/SentryShakeDetector : android/hardware/SensorEventListener { + public fun (Lio/sentry/ILogger;)V + public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V + public fun onSensorChanged (Landroid/hardware/SensorEvent;)V + public fun start (Landroid/content/Context;Lio/sentry/android/core/SentryShakeDetector$Listener;)V + public fun stop ()V +} + +public abstract interface class io/sentry/android/core/SentryShakeDetector$Listener { + public abstract fun onShake ()V +} + public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V @@ -485,6 +497,19 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$ public abstract fun configure (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions;)V } +public final class io/sentry/android/core/ShakeDetectionIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { + public fun (Landroid/app/Application;)V + public fun close ()V + public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityResumed (Landroid/app/Activity;)V + public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener { protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V 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 4756d6aab4d..92f68b93e6e 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 @@ -5,8 +5,11 @@ import android.hardware.SensorEvent; import android.hardware.SensorEventListener; import android.hardware.SensorManager; +import android.os.SystemClock; import io.sentry.ILogger; import io.sentry.SentryLevel; +import java.util.concurrent.atomic.AtomicLong; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -15,17 +18,26 @@ * *

The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on * Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors. + * + *

Requires at least {@link #SHAKE_COUNT_THRESHOLD} accelerometer readings above {@link + * #SHAKE_THRESHOLD_GRAVITY} within {@link #SHAKE_WINDOW_MS} to trigger a shake event. */ +@ApiStatus.Internal public final class SentryShakeDetector implements SensorEventListener { private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f; + private static final int SHAKE_WINDOW_MS = 1500; + private static final int SHAKE_COUNT_THRESHOLD = 2; private static final int SHAKE_COOLDOWN_MS = 1000; private @Nullable SensorManager sensorManager; - private long lastShakeTimestamp = 0; - private @Nullable Listener listener; + private final @NotNull AtomicLong lastShakeTimestamp = new AtomicLong(0); + private volatile @Nullable Listener listener; private final @NotNull ILogger logger; + private int shakeCount = 0; + private long firstShakeTimestamp = 0; + public interface Listener { void onShake(); } @@ -47,7 +59,7 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled."); return; } - sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI); + sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL); } public void stop() { @@ -68,11 +80,26 @@ public void onSensorChanged(final @NotNull SensorEvent event) { float gZ = event.values[2] / SensorManager.GRAVITY_EARTH; double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ); if (gForce > SHAKE_THRESHOLD_GRAVITY) { - long now = System.currentTimeMillis(); - if (now - lastShakeTimestamp > SHAKE_COOLDOWN_MS) { - lastShakeTimestamp = now; - if (listener != null) { - listener.onShake(); + long now = SystemClock.elapsedRealtime(); + + // Reset counter if outside the detection window + if (now - firstShakeTimestamp > SHAKE_WINDOW_MS) { + shakeCount = 0; + firstShakeTimestamp = now; + } + + shakeCount++; + + if (shakeCount >= SHAKE_COUNT_THRESHOLD) { + // Enforce cooldown so we don't fire repeatedly + long lastShake = lastShakeTimestamp.get(); + if (now - lastShake > SHAKE_COOLDOWN_MS) { + lastShakeTimestamp.set(now); + shakeCount = 0; + final @Nullable Listener currentListener = listener; + if (currentListener != null) { + currentListener.onShake(); + } } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java index 96b21c9e864..f1b0f8d3679 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java @@ -25,7 +25,7 @@ public final class ShakeDetectionIntegration private final @NotNull Application application; private @Nullable SentryShakeDetector shakeDetector; private @Nullable SentryAndroidOptions options; - private @Nullable Activity currentActivity; + private volatile @Nullable Activity currentActivity; public ShakeDetectionIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); @@ -42,6 +42,13 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions addIntegrationToSdkVersion("ShakeDetection"); application.registerActivityLifecycleCallbacks(this); options.getLogger().log(SentryLevel.DEBUG, "ShakeDetectionIntegration installed."); + + // In case of a deferred init, hook into any already-resumed activity + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + if (activity != null) { + currentActivity = activity; + startShakeDetection(activity); + } } @Override @@ -58,8 +65,13 @@ public void onActivityResumed(final @NotNull Activity activity) { @Override public void onActivityPaused(final @NotNull Activity activity) { - stopShakeDetection(); - currentActivity = null; + // Only stop if this is the activity we're tracking. When transitioning between + // activities, B.onResume may fire before A.onPause — stopping unconditionally + // would kill shake detection for the new activity. + if (activity == currentActivity) { + stopShakeDetection(); + currentActivity = null; + } } @Override @@ -80,9 +92,11 @@ public void onActivitySaveInstanceState( public void onActivityDestroyed(final @NotNull Activity activity) {} private void startShakeDetection(final @NotNull Activity activity) { - if (shakeDetector != null || options == null) { + if (options == null) { return; } + // Stop any existing detector (e.g. when transitioning between activities) + stopShakeDetection(); shakeDetector = new SentryShakeDetector(options.getLogger()); shakeDetector.start( activity, @@ -90,7 +104,15 @@ private void startShakeDetection(final @NotNull Activity activity) { final Activity active = currentActivity; if (active != null && options != null) { active.runOnUiThread( - () -> options.getFeedbackOptions().getDialogHandler().showDialog(null, null)); + () -> { + try { + options.getFeedbackOptions().getDialogHandler().showDialog(null, null); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e); + } + }); } }); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt new file mode 100644 index 00000000000..3115b43e4ed --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt @@ -0,0 +1,154 @@ +package io.sentry.android.core + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorManager +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +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 +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class SentryShakeDetectorTest { + + private class Fixture { + val logger = mock() + val context = mock() + val sensorManager = mock() + val accelerometer = mock() + val listener = mock() + + init { + whenever(context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(sensorManager) + whenever(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)).thenReturn(accelerometer) + } + + fun getSut(): SentryShakeDetector { + return SentryShakeDetector(logger) + } + } + + private val fixture = Fixture() + + @Test + fun `registers sensor listener on start`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + verify(fixture.sensorManager) + .registerListener(eq(sut), eq(fixture.accelerometer), eq(SensorManager.SENSOR_DELAY_NORMAL)) + } + + @Test + fun `unregisters sensor listener on stop`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + sut.stop() + + verify(fixture.sensorManager).unregisterListener(sut) + } + + @Test + fun `does not crash when SensorManager is null`() { + whenever(fixture.context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(null) + + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + verify(fixture.sensorManager, never()).registerListener(any(), any(), any()) + } + + @Test + fun `does not crash when accelerometer is null`() { + whenever(fixture.sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)).thenReturn(null) + + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + verify(fixture.sensorManager, never()).registerListener(any(), any(), any()) + } + + @Test + fun `triggers listener when shake is detected`() { + // Advance clock so cooldown check (now - 0 > 1000) passes + SystemClock.setCurrentTimeMillis(2000) + + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + // Needs at least SHAKE_COUNT_THRESHOLD (2) readings above threshold + val event1 = createSensorEvent(floatArrayOf(30f, 0f, 0f)) + sut.onSensorChanged(event1) + val event2 = createSensorEvent(floatArrayOf(30f, 0f, 0f)) + sut.onSensorChanged(event2) + + verify(fixture.listener).onShake() + } + + @Test + fun `does not trigger listener on single shake`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + // A single threshold crossing should not trigger + val event = createSensorEvent(floatArrayOf(30f, 0f, 0f)) + sut.onSensorChanged(event) + + verify(fixture.listener, never()).onShake() + } + + @Test + fun `does not trigger listener below threshold`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + // Gravity only (1G) - no shake + val event = createSensorEvent(floatArrayOf(0f, 0f, SensorManager.GRAVITY_EARTH)) + sut.onSensorChanged(event) + + verify(fixture.listener, never()).onShake() + } + + @Test + fun `does not trigger listener for non-accelerometer events`() { + val sut = fixture.getSut() + sut.start(fixture.context, fixture.listener) + + val event = createSensorEvent(floatArrayOf(30f, 0f, 0f), sensorType = Sensor.TYPE_GYROSCOPE) + sut.onSensorChanged(event) + + verify(fixture.listener, never()).onShake() + } + + @Test + fun `stop without start does not crash`() { + val sut = fixture.getSut() + sut.stop() + } + + private fun createSensorEvent( + values: FloatArray, + sensorType: Int = Sensor.TYPE_ACCELEROMETER, + ): SensorEvent { + val sensor = mock() + whenever(sensor.type).thenReturn(sensorType) + + val constructor = SensorEvent::class.java.getDeclaredConstructor(Int::class.javaPrimitiveType) + constructor.isAccessible = true + val event = constructor.newInstance(values.size) + values.copyInto(event.values) + + val sensorField = SensorEvent::class.java.getField("sensor") + sensorField.set(event, sensor) + + return event + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ShakeDetectionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ShakeDetectionIntegrationTest.kt new file mode 100644 index 00000000000..04920be924d --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ShakeDetectionIntegrationTest.kt @@ -0,0 +1,106 @@ +package io.sentry.android.core + +import android.app.Activity +import android.app.Application +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Scopes +import io.sentry.SentryFeedbackOptions +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class ShakeDetectionIntegrationTest { + + private class Fixture { + val application = mock() + val scopes = mock() + val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } + val activity = mock() + val dialogHandler = mock() + + init { + options.feedbackOptions.setDialogHandler(dialogHandler) + } + + fun getSut(useShakeGesture: Boolean = true): ShakeDetectionIntegration { + options.feedbackOptions.isUseShakeGesture = useShakeGesture + return ShakeDetectionIntegration(application) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun setup() { + CurrentActivityHolder.getInstance().clearActivity() + } + + @Test + fun `when useShakeGesture is enabled registers activity lifecycle callbacks`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `when useShakeGesture is disabled does not register activity lifecycle callbacks`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `close unregisters activity lifecycle callbacks`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + sut.close() + + verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `hooks into already-resumed activity on deferred init`() { + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + // The integration should have attempted to start shake detection + // (it will fail gracefully because SensorManager is null in tests, + // but the important thing is it tried) + } + + @Test + fun `does not crash when no activity is available on deferred init`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + // Should not throw + } + + @Test + fun `onActivityPaused stops shake detection`() { + val sut = fixture.getSut() + sut.register(fixture.scopes, fixture.options) + + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + sut.onActivityResumed(fixture.activity) + sut.onActivityPaused(fixture.activity) + // Should not throw, shake detection stopped gracefully + } + + @Test + fun `close without register does not crash`() { + val sut = fixture.getSut() + sut.close() + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5554379644a..7a8a10f98d1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3096,6 +3096,7 @@ public final class io/sentry/SentryFeedbackOptions { public fun isShowEmail ()Z public fun isShowName ()Z public fun isUseSentryUser ()Z + public fun isUseShakeGesture ()Z public fun setCancelButtonLabel (Ljava/lang/CharSequence;)V public fun setDialogHandler (Lio/sentry/SentryFeedbackOptions$IDialogHandler;)V public fun setEmailLabel (Ljava/lang/CharSequence;)V @@ -3118,6 +3119,7 @@ public final class io/sentry/SentryFeedbackOptions { public fun setSubmitButtonLabel (Ljava/lang/CharSequence;)V public fun setSuccessMessageText (Ljava/lang/CharSequence;)V public fun setUseSentryUser (Z)V + public fun setUseShakeGesture (Z)V public fun toString ()Ljava/lang/String; } From 0cc2f3b37688d119fbb1f50fc3446f055b077afe Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 4 Mar 2026 14:24:09 +0100 Subject: [PATCH 03/25] fix(feedback): prevent stacking multiple feedback dialogs on repeated shakes Track dialog visibility with an isDialogShowing flag that is set before showing and cleared via the onFormClose callback when the dialog is dismissed. Double-checked on both sensor and UI threads to avoid races. Co-Authored-By: Claude Opus 4.6 --- .../core/ShakeDetectionIntegration.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java index f1b0f8d3679..365ebb184be 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java @@ -26,6 +26,7 @@ public final class ShakeDetectionIntegration private @Nullable SentryShakeDetector shakeDetector; private @Nullable SentryAndroidOptions options; private volatile @Nullable Activity currentActivity; + private volatile boolean isDialogShowing = false; public ShakeDetectionIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); @@ -102,12 +103,28 @@ private void startShakeDetection(final @NotNull Activity activity) { activity, () -> { final Activity active = currentActivity; - if (active != null && options != null) { + if (active != null && options != null && !isDialogShowing) { active.runOnUiThread( () -> { + if (isDialogShowing) { + return; + } try { + isDialogShowing = true; + final Runnable previousOnFormClose = + options.getFeedbackOptions().getOnFormClose(); + options + .getFeedbackOptions() + .setOnFormClose( + () -> { + isDialogShowing = false; + if (previousOnFormClose != null) { + previousOnFormClose.run(); + } + }); options.getFeedbackOptions().getDialogHandler().showDialog(null, null); } catch (Throwable e) { + isDialogShowing = false; options .getLogger() .log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e); From 83eed6d944e74e01188bfbe0a7ac447bad8cac35 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 4 Mar 2026 14:45:17 +0100 Subject: [PATCH 04/25] fix(feedback): restore original onFormClose to prevent callback chain growth Save the user's original onFormClose once during register() and restore it after each dialog dismiss, instead of wrapping it with a new lambda each time. Co-Authored-By: Claude Opus 4.6 --- .../sentry/android/core/ShakeDetectionIntegration.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java index 365ebb184be..9617b7b8ff5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java @@ -27,6 +27,7 @@ public final class ShakeDetectionIntegration private @Nullable SentryAndroidOptions options; private volatile @Nullable Activity currentActivity; private volatile boolean isDialogShowing = false; + private @Nullable Runnable originalOnFormClose; public ShakeDetectionIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); @@ -41,6 +42,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions } addIntegrationToSdkVersion("ShakeDetection"); + originalOnFormClose = this.options.getFeedbackOptions().getOnFormClose(); application.registerActivityLifecycleCallbacks(this); options.getLogger().log(SentryLevel.DEBUG, "ShakeDetectionIntegration installed."); @@ -111,15 +113,14 @@ private void startShakeDetection(final @NotNull Activity activity) { } try { isDialogShowing = true; - final Runnable previousOnFormClose = - options.getFeedbackOptions().getOnFormClose(); options .getFeedbackOptions() .setOnFormClose( () -> { isDialogShowing = false; - if (previousOnFormClose != null) { - previousOnFormClose.run(); + options.getFeedbackOptions().setOnFormClose(originalOnFormClose); + if (originalOnFormClose != null) { + originalOnFormClose.run(); } }); options.getFeedbackOptions().getDialogHandler().showDialog(null, null); From dbbd37e5f22ed55061b83ad4ea28566bd4373f3c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 4 Mar 2026 15:01:31 +0100 Subject: [PATCH 05/25] fix(feedback): reset isDialogShowing on activity pause to prevent stuck flag If showDialog silently fails (e.g. activity destroyed between post and execution), isDialogShowing would stay true forever, permanently disabling shake-to-feedback. Reset it in onActivityPaused since the dialog cannot outlive its host activity. Co-Authored-By: Claude Opus 4.6 --- .../io/sentry/android/core/ShakeDetectionIntegration.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java index 9617b7b8ff5..99dc306d053 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java @@ -74,6 +74,10 @@ public void onActivityPaused(final @NotNull Activity activity) { if (activity == currentActivity) { stopShakeDetection(); currentActivity = null; + // Reset dialog flag — the dialog cannot outlive the activity, so if + // showDialog silently failed or the activity is finishing, clear the flag + // to avoid permanently blocking shake-to-feedback. + isDialogShowing = false; } } From 527abb701faed99236a48ac4e10115463a29a488 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 4 Mar 2026 15:11:07 +0100 Subject: [PATCH 06/25] fix(feedback): move isDialogShowing reset from onActivityPaused to onActivityDestroyed AlertDialog survives pause/resume cycles (e.g. screen off/on), so resetting isDialogShowing in onActivityPaused allowed duplicate dialogs. Move the reset to onActivityDestroyed where the dialog truly cannot survive. Co-Authored-By: Claude Opus 4.6 --- .../android/core/ShakeDetectionIntegration.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java index 99dc306d053..aca78ead635 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java @@ -74,10 +74,6 @@ public void onActivityPaused(final @NotNull Activity activity) { if (activity == currentActivity) { stopShakeDetection(); currentActivity = null; - // Reset dialog flag — the dialog cannot outlive the activity, so if - // showDialog silently failed or the activity is finishing, clear the flag - // to avoid permanently blocking shake-to-feedback. - isDialogShowing = false; } } @@ -96,7 +92,12 @@ public void onActivitySaveInstanceState( final @NotNull Activity activity, final @NotNull Bundle outState) {} @Override - public void onActivityDestroyed(final @NotNull Activity activity) {} + public void onActivityDestroyed(final @NotNull Activity activity) { + // Reset dialog flag — the dialog cannot outlive the activity being destroyed, + // so clear the flag to avoid permanently blocking shake-to-feedback + // (e.g. if showDialog silently failed). + isDialogShowing = false; + } private void startShakeDetection(final @NotNull Activity activity) { if (options == null) { From 0b090bc9b6b2706544dc4f97de8486b9cf65a14e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 4 Mar 2026 15:21:21 +0100 Subject: [PATCH 07/25] fix(feedback): scope dialog flag to hosting activity and restore callback on error - Only reset isDialogShowing in onActivityDestroyed when it's the activity that hosts the dialog, not any unrelated activity. - Restore originalOnFormClose in the catch block when showDialog throws. Co-Authored-By: Claude Opus 4.6 --- .../android/core/ShakeDetectionIntegration.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java index aca78ead635..0933a895157 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java @@ -27,6 +27,7 @@ public final class ShakeDetectionIntegration private @Nullable SentryAndroidOptions options; private volatile @Nullable Activity currentActivity; private volatile boolean isDialogShowing = false; + private volatile @Nullable Activity dialogActivity; private @Nullable Runnable originalOnFormClose; public ShakeDetectionIntegration(final @NotNull Application application) { @@ -93,10 +94,12 @@ public void onActivitySaveInstanceState( @Override public void onActivityDestroyed(final @NotNull Activity activity) { - // Reset dialog flag — the dialog cannot outlive the activity being destroyed, - // so clear the flag to avoid permanently blocking shake-to-feedback - // (e.g. if showDialog silently failed). - isDialogShowing = false; + // Only reset if this is the activity that hosts the dialog — the dialog cannot + // outlive its host activity being destroyed. + if (activity == dialogActivity) { + isDialogShowing = false; + dialogActivity = null; + } } private void startShakeDetection(final @NotNull Activity activity) { @@ -118,11 +121,13 @@ private void startShakeDetection(final @NotNull Activity activity) { } try { isDialogShowing = true; + dialogActivity = active; options .getFeedbackOptions() .setOnFormClose( () -> { isDialogShowing = false; + dialogActivity = null; options.getFeedbackOptions().setOnFormClose(originalOnFormClose); if (originalOnFormClose != null) { originalOnFormClose.run(); @@ -131,6 +136,8 @@ private void startShakeDetection(final @NotNull Activity activity) { options.getFeedbackOptions().getDialogHandler().showDialog(null, null); } catch (Throwable e) { isDialogShowing = false; + dialogActivity = null; + options.getFeedbackOptions().setOnFormClose(originalOnFormClose); options .getLogger() .log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e); From d77d60dec33be61104b42f9aa3b3832b2b2aaebd Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 5 Mar 2026 10:11:43 +0100 Subject: [PATCH 08/25] Optimise comparison Co-authored-by: LucasZF --- .../main/java/io/sentry/android/core/SentryShakeDetector.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 92f68b93e6e..926169e0c47 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 @@ -78,8 +78,8 @@ public void onSensorChanged(final @NotNull SensorEvent event) { float gX = event.values[0] / SensorManager.GRAVITY_EARTH; float gY = event.values[1] / SensorManager.GRAVITY_EARTH; float gZ = event.values[2] / SensorManager.GRAVITY_EARTH; - double gForce = Math.sqrt(gX * gX + gY * gY + gZ * gZ); - if (gForce > SHAKE_THRESHOLD_GRAVITY) { + double gForceSquared = gX * gX + gY * gY + gZ * gZ; + if (gForceSquared > SHAKE_THRESHOLD_GRAVITY * SHAKE_THRESHOLD_GRAVITY) { long now = SystemClock.elapsedRealtime(); // Reset counter if outside the detection window From 1fd4f5b656fb9c6711f712621b58dd435a74fb51 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 5 Mar 2026 10:17:40 +0100 Subject: [PATCH 09/25] ref(feedback): address review feedback from lucas-zimerman - Rename ShakeDetectionIntegration to FeedbackShakeIntegration to clarify its purpose is feedback-specific (#1) - Avoid Math.sqrt by comparing squared gForce values (#3) - Null out listener before unregistering sensor to prevent in-flight callbacks during stop (#4) Co-Authored-By: Claude Opus 4.6 --- .../api/sentry-android-core.api | 26 +++++++++---------- .../core/AndroidOptionsInitializer.java | 2 +- ...ion.java => FeedbackShakeIntegration.java} | 8 +++--- .../android/core/SentryShakeDetector.java | 2 +- ...est.kt => FeedbackShakeIntegrationTest.kt} | 6 ++--- .../sentry/android/core/SentryAndroidTest.kt | 2 +- .../sentry/samples/android/MyApplication.java | 20 +++++--------- 7 files changed, 30 insertions(+), 36 deletions(-) rename sentry-android-core/src/main/java/io/sentry/android/core/{ShakeDetectionIntegration.java => FeedbackShakeIntegration.java} (95%) rename sentry-android-core/src/test/java/io/sentry/android/core/{ShakeDetectionIntegrationTest.kt => FeedbackShakeIntegrationTest.kt} (94%) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index c7e8cebc1e8..300c6a51432 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -269,6 +269,19 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } +public final class io/sentry/android/core/FeedbackShakeIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { + public fun (Landroid/app/Application;)V + public fun close ()V + public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityResumed (Landroid/app/Activity;)V + public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + public abstract interface class io/sentry/android/core/IDebugImagesLoader { public abstract fun clearDebugImages ()V public abstract fun loadDebugImages ()Ljava/util/List; @@ -497,19 +510,6 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$ public abstract fun configure (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions;)V } -public final class io/sentry/android/core/ShakeDetectionIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { - public fun (Landroid/app/Application;)V - public fun close ()V - public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V - public fun onActivityDestroyed (Landroid/app/Activity;)V - public fun onActivityPaused (Landroid/app/Activity;)V - public fun onActivityResumed (Landroid/app/Activity;)V - public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V - public fun onActivityStarted (Landroid/app/Activity;)V - public fun onActivityStopped (Landroid/app/Activity;)V - public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V -} - public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener { protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index cf89be72f1f..6286666e5fc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -404,7 +404,7 @@ static void installDefaultIntegrations( (Application) context, buildInfoProvider, activityFramesTracker)); options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context)); options.addIntegration(new UserInteractionIntegration((Application) context, loadClass)); - options.addIntegration(new ShakeDetectionIntegration((Application) context)); + options.addIntegration(new FeedbackShakeIntegration((Application) context)); if (isFragmentAvailable) { options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true)); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java similarity index 95% rename from sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java rename to sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java index 0933a895157..c38c36b687a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ShakeDetectionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java @@ -19,7 +19,7 @@ * Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active * when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}. */ -public final class ShakeDetectionIntegration +public final class FeedbackShakeIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { private final @NotNull Application application; @@ -30,7 +30,7 @@ public final class ShakeDetectionIntegration private volatile @Nullable Activity dialogActivity; private @Nullable Runnable originalOnFormClose; - public ShakeDetectionIntegration(final @NotNull Application application) { + public FeedbackShakeIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); } @@ -42,10 +42,10 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions return; } - addIntegrationToSdkVersion("ShakeDetection"); + addIntegrationToSdkVersion("FeedbackShake"); originalOnFormClose = this.options.getFeedbackOptions().getOnFormClose(); application.registerActivityLifecycleCallbacks(this); - options.getLogger().log(SentryLevel.DEBUG, "ShakeDetectionIntegration installed."); + options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed."); // In case of a deferred init, hook into any already-resumed activity final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); 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 926169e0c47..f2a47dfe418 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 @@ -63,11 +63,11 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL } public void stop() { + listener = null; if (sensorManager != null) { sensorManager.unregisterListener(this); sensorManager = null; } - listener = null; } @Override diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ShakeDetectionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt similarity index 94% rename from sentry-android-core/src/test/java/io/sentry/android/core/ShakeDetectionIntegrationTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt index 04920be924d..cb940686c30 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ShakeDetectionIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt @@ -15,7 +15,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) -class ShakeDetectionIntegrationTest { +class FeedbackShakeIntegrationTest { private class Fixture { val application = mock() @@ -28,9 +28,9 @@ class ShakeDetectionIntegrationTest { options.feedbackOptions.setDialogHandler(dialogHandler) } - fun getSut(useShakeGesture: Boolean = true): ShakeDetectionIntegration { + fun getSut(useShakeGesture: Boolean = true): FeedbackShakeIntegration { options.feedbackOptions.isUseShakeGesture = useShakeGesture - return ShakeDetectionIntegration(application) + return FeedbackShakeIntegration(application) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index a3414fd3acb..2e8c5a1e130 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -488,7 +488,7 @@ class SentryAndroidTest { it is ActivityLifecycleIntegration || it is ActivityBreadcrumbsIntegration || it is UserInteractionIntegration || - it is ShakeDetectionIntegration || + it is FeedbackShakeIntegration || it is FragmentLifecycleIntegration || it is SentryTimberIntegration || it is AppComponentsBreadcrumbsIntegration || diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java index 572c4cdba72..69d65bc67d9 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java @@ -3,6 +3,7 @@ import android.app.Application; import android.os.StrictMode; import io.sentry.Sentry; +import io.sentry.android.core.SentryAndroid; /** Apps. main Application. */ public class MyApplication extends Application { @@ -13,19 +14,12 @@ public void onCreate() { strictMode(); super.onCreate(); - // Example how to initialize the SDK manually which allows access to SentryOptions callbacks. - // Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false - // SentryAndroid.init( - // this, - // options -> { - // /* - // use options, for example, to add a beforeSend callback: - // - // options.setBeforeSend((event, hint) -> { - // process event - // }); - // */ - // }); + // Enable shake gesture to open feedback form (for testing) + SentryAndroid.init( + this, + options -> { + options.getFeedbackOptions().setUseShakeGesture(true); + }); } private void strictMode() { From 43b5ebce6963ae855b1c3b73d7f6be98836be21a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 5 Mar 2026 10:24:03 +0100 Subject: [PATCH 10/25] fix(feedback): capture onFormClose at shake time instead of registration Capture the current onFormClose callback just before showing the dialog rather than caching it during register(). This ensures callbacks set by the user after SDK init are preserved across shake-triggered dialogs. Co-Authored-By: Claude Opus 4.6 --- .../android/core/FeedbackShakeIntegration.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 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 c38c36b687a..181b373196c 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 @@ -28,7 +28,6 @@ public final class FeedbackShakeIntegration private volatile @Nullable Activity currentActivity; private volatile boolean isDialogShowing = false; private volatile @Nullable Activity dialogActivity; - private @Nullable Runnable originalOnFormClose; public FeedbackShakeIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); @@ -43,7 +42,6 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions } addIntegrationToSdkVersion("FeedbackShake"); - originalOnFormClose = this.options.getFeedbackOptions().getOnFormClose(); application.registerActivityLifecycleCallbacks(this); options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed."); @@ -119,6 +117,8 @@ private void startShakeDetection(final @NotNull Activity activity) { if (isDialogShowing) { return; } + final Runnable previousOnFormClose = + options.getFeedbackOptions().getOnFormClose(); try { isDialogShowing = true; dialogActivity = active; @@ -128,16 +128,16 @@ private void startShakeDetection(final @NotNull Activity activity) { () -> { isDialogShowing = false; dialogActivity = null; - options.getFeedbackOptions().setOnFormClose(originalOnFormClose); - if (originalOnFormClose != null) { - originalOnFormClose.run(); + options.getFeedbackOptions().setOnFormClose(previousOnFormClose); + if (previousOnFormClose != null) { + previousOnFormClose.run(); } }); options.getFeedbackOptions().getDialogHandler().showDialog(null, null); } catch (Throwable e) { isDialogShowing = false; dialogActivity = null; - options.getFeedbackOptions().setOnFormClose(originalOnFormClose); + options.getFeedbackOptions().setOnFormClose(previousOnFormClose); options .getLogger() .log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e); From 0cb8d0030087c87cdbb21920093f196544f7c7a9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 5 Mar 2026 10:26:31 +0100 Subject: [PATCH 11/25] Reverse sample changes --- .../sentry/samples/android/MyApplication.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java index 69d65bc67d9..572c4cdba72 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java @@ -3,7 +3,6 @@ import android.app.Application; import android.os.StrictMode; import io.sentry.Sentry; -import io.sentry.android.core.SentryAndroid; /** Apps. main Application. */ public class MyApplication extends Application { @@ -14,12 +13,19 @@ public void onCreate() { strictMode(); super.onCreate(); - // Enable shake gesture to open feedback form (for testing) - SentryAndroid.init( - this, - options -> { - options.getFeedbackOptions().setUseShakeGesture(true); - }); + // Example how to initialize the SDK manually which allows access to SentryOptions callbacks. + // Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false + // SentryAndroid.init( + // this, + // options -> { + // /* + // use options, for example, to add a beforeSend callback: + // + // options.setBeforeSend((event, hint) -> { + // process event + // }); + // */ + // }); } private void strictMode() { From 285afde78f571c41bc86c0774360818771ad4668 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 5 Mar 2026 10:29:57 +0100 Subject: [PATCH 12/25] fix(feedback): restore onFormClose in onActivityDestroyed fallback path When the dialog's host activity is destroyed and onDismiss doesn't fire, onActivityDestroyed now restores the previous onFormClose callback on global options, preventing a stale wrapper from affecting subsequent non-shake feedback dialogs. Co-Authored-By: Claude Opus 4.6 --- .../sentry/android/core/FeedbackShakeIntegration.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 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 181b373196c..5d3b9aac5b2 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 @@ -28,6 +28,7 @@ public final class FeedbackShakeIntegration private volatile @Nullable Activity currentActivity; private volatile boolean isDialogShowing = false; private volatile @Nullable Activity dialogActivity; + private @Nullable Runnable previousOnFormClose; public FeedbackShakeIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); @@ -97,6 +98,10 @@ public void onActivityDestroyed(final @NotNull Activity activity) { if (activity == dialogActivity) { isDialogShowing = false; dialogActivity = null; + if (options != null && previousOnFormClose != null) { + options.getFeedbackOptions().setOnFormClose(previousOnFormClose); + } + previousOnFormClose = null; } } @@ -117,11 +122,10 @@ private void startShakeDetection(final @NotNull Activity activity) { if (isDialogShowing) { return; } - final Runnable previousOnFormClose = - options.getFeedbackOptions().getOnFormClose(); try { isDialogShowing = true; dialogActivity = active; + previousOnFormClose = options.getFeedbackOptions().getOnFormClose(); options .getFeedbackOptions() .setOnFormClose( @@ -132,12 +136,14 @@ private void startShakeDetection(final @NotNull Activity activity) { if (previousOnFormClose != null) { previousOnFormClose.run(); } + previousOnFormClose = null; }); options.getFeedbackOptions().getDialogHandler().showDialog(null, null); } catch (Throwable e) { isDialogShowing = false; dialogActivity = null; options.getFeedbackOptions().setOnFormClose(previousOnFormClose); + previousOnFormClose = null; options .getLogger() .log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e); From 87d508a0d70f8b9f5b33555bc11c4bfc70fce0f6 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 5 Mar 2026 10:41:19 +0100 Subject: [PATCH 13/25] fix(feedback): make previousOnFormClose volatile for thread safety Co-Authored-By: Claude Opus 4.6 --- .../java/io/sentry/android/core/FeedbackShakeIntegration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5d3b9aac5b2..7b49b640a68 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 @@ -28,7 +28,7 @@ public final class FeedbackShakeIntegration private volatile @Nullable Activity currentActivity; private volatile boolean isDialogShowing = false; private volatile @Nullable Activity dialogActivity; - private @Nullable Runnable previousOnFormClose; + private volatile @Nullable Runnable previousOnFormClose; public FeedbackShakeIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); From 8e2dee373d9f64a0c065fe82403ef4c6a0cd0df1 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 5 Mar 2026 10:51:03 +0100 Subject: [PATCH 14/25] fix(feedback): always restore onFormClose in onActivityDestroyed even when null The previous null check on previousOnFormClose skipped restoration when no user callback was set, leaving a stale wrapper in global options. Co-Authored-By: Claude Opus 4.6 --- .../java/io/sentry/android/core/FeedbackShakeIntegration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7b49b640a68..00561766813 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 @@ -98,7 +98,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { if (activity == dialogActivity) { isDialogShowing = false; dialogActivity = null; - if (options != null && previousOnFormClose != null) { + if (options != null) { options.getFeedbackOptions().setOnFormClose(previousOnFormClose); } previousOnFormClose = null; From 6993267331680ff7df6866d1ac9721dbf4deeac8 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 14:25:18 +0100 Subject: [PATCH 15/25] Update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6beeb827b7..7c80414a4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 8.34.1 +## Unreleased ### Features @@ -8,6 +8,8 @@ - Enable via `options.getFeedbackOptions().setUseShakeGesture(true)` - Uses the device's accelerometer — no special permissions required +## 8.34.1 + ### Fixes - Common: Finalize previous session even when auto session tracking is disabled ([#5154](https://github.com/getsentry/sentry-java/pull/5154)) From 9aa138ecd4f759eef8b054ab361fea7e6d3a3a1f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 15:57:48 +0100 Subject: [PATCH 16/25] ref(feedback): address review feedback for shake gesture detection - Reuse single SentryShakeDetector instance across activity transitions instead of re-creating on every resume (reduces allocations) - Memoize SensorManager and Sensor lookups to avoid repeated binder calls - Use getDefaultSensor(TYPE_ACCELEROMETER, false) to avoid wakeup sensor - Deliver sensor events on a background HandlerThread instead of main thread - Use SentryUserFeedbackDialog.Builder directly with tracked activity instead of going through showDialog/CurrentActivityHolder - Merge dialogActivity into currentActivity, use AppState.isInBackground() to gate against background shakes - Fix integration count in SentryAndroidTest (19 -> 20) Tested manually on Pixel 8 Pro by enabling useShakeGesture in the sample app's SentryAndroid.init and verifying shake opens the feedback dialog. Co-Authored-By: Claude Opus 4.6 --- .../core/FeedbackShakeIntegration.java | 29 ++++++++-------- .../android/core/SentryShakeDetector.java | 34 ++++++++++++++++--- .../sentry/android/core/SentryAndroidTest.kt | 2 +- .../android/core/SentryShakeDetectorTest.kt | 12 ++++--- 4 files changed, 52 insertions(+), 25 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 00561766813..ee23b23ab74 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 @@ -23,15 +23,15 @@ public final class FeedbackShakeIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { private final @NotNull Application application; - private @Nullable SentryShakeDetector shakeDetector; + private final @NotNull SentryShakeDetector shakeDetector; private @Nullable SentryAndroidOptions options; private volatile @Nullable Activity currentActivity; private volatile boolean isDialogShowing = false; - private volatile @Nullable Activity dialogActivity; private volatile @Nullable Runnable previousOnFormClose; public FeedbackShakeIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); + this.shakeDetector = new SentryShakeDetector(io.sentry.NoOpLogger.getInstance()); } @Override @@ -42,6 +42,9 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions return; } + // Re-assign a properly configured detector logger now that options are available + shakeDetector.init(application); + addIntegrationToSdkVersion("FeedbackShake"); application.registerActivityLifecycleCallbacks(this); options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed."); @@ -95,9 +98,8 @@ public void onActivitySaveInstanceState( public void onActivityDestroyed(final @NotNull Activity activity) { // Only reset if this is the activity that hosts the dialog — the dialog cannot // outlive its host activity being destroyed. - if (activity == dialogActivity) { + if (isDialogShowing && activity == currentActivity) { isDialogShowing = false; - dialogActivity = null; if (options != null) { options.getFeedbackOptions().setOnFormClose(previousOnFormClose); } @@ -109,14 +111,17 @@ private void startShakeDetection(final @NotNull Activity activity) { if (options == null) { return; } - // Stop any existing detector (e.g. when transitioning between activities) + // Stop any existing detection (e.g. when transitioning between activities) stopShakeDetection(); - shakeDetector = new SentryShakeDetector(options.getLogger()); shakeDetector.start( activity, () -> { final Activity active = currentActivity; - if (active != null && options != null && !isDialogShowing) { + final Boolean inBackground = AppState.getInstance().isInBackground(); + if (active != null + && options != null + && !isDialogShowing + && !Boolean.TRUE.equals(inBackground)) { active.runOnUiThread( () -> { if (isDialogShowing) { @@ -124,24 +129,21 @@ private void startShakeDetection(final @NotNull Activity activity) { } try { isDialogShowing = true; - dialogActivity = active; previousOnFormClose = options.getFeedbackOptions().getOnFormClose(); options .getFeedbackOptions() .setOnFormClose( () -> { isDialogShowing = false; - dialogActivity = null; options.getFeedbackOptions().setOnFormClose(previousOnFormClose); if (previousOnFormClose != null) { previousOnFormClose.run(); } previousOnFormClose = null; }); - options.getFeedbackOptions().getDialogHandler().showDialog(null, null); + new SentryUserFeedbackDialog.Builder(active).create().show(); } catch (Throwable e) { isDialogShowing = false; - dialogActivity = null; options.getFeedbackOptions().setOnFormClose(previousOnFormClose); previousOnFormClose = null; options @@ -154,9 +156,6 @@ private void startShakeDetection(final @NotNull Activity activity) { } private void stopShakeDetection() { - if (shakeDetector != null) { - shakeDetector.stop(); - shakeDetector = null; - } + shakeDetector.stop(); } } 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 f2a47dfe418..bbd6fee011b 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 @@ -5,6 +5,8 @@ import android.hardware.SensorEvent; import android.hardware.SensorEventListener; import android.hardware.SensorManager; +import android.os.Handler; +import android.os.HandlerThread; import android.os.SystemClock; import io.sentry.ILogger; import io.sentry.SentryLevel; @@ -21,6 +23,9 @@ * *

Requires at least {@link #SHAKE_COUNT_THRESHOLD} accelerometer readings above {@link * #SHAKE_THRESHOLD_GRAVITY} within {@link #SHAKE_WINDOW_MS} to trigger a shake event. + * + *

Sensor events are delivered on a background {@link HandlerThread} to avoid polluting the main + * thread. */ @ApiStatus.Internal public final class SentryShakeDetector implements SensorEventListener { @@ -31,6 +36,8 @@ public final class SentryShakeDetector implements SensorEventListener { private static final int SHAKE_COOLDOWN_MS = 1000; private @Nullable SensorManager sensorManager; + private @Nullable Sensor accelerometer; + private @Nullable HandlerThread handlerThread; private final @NotNull AtomicLong lastShakeTimestamp = new AtomicLong(0); private volatile @Nullable Listener listener; private final @NotNull ILogger logger; @@ -46,27 +53,46 @@ public SentryShakeDetector(final @NotNull ILogger logger) { this.logger = 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) { + if (sensorManager == null) { + sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + } + if (sensorManager != null && accelerometer == null) { + accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false); + } + } + public void start(final @NotNull Context context, final @NotNull Listener shakeListener) { this.listener = shakeListener; - sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + init(context); if (sensorManager == null) { logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled."); return; } - Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); if (accelerometer == null) { logger.log( SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled."); return; } - sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL); + handlerThread = new HandlerThread("sentry-shake"); + handlerThread.start(); + final Handler handler = new Handler(handlerThread.getLooper()); + sensorManager.registerListener( + this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL, handler); } public void stop() { listener = null; if (sensorManager != null) { sensorManager.unregisterListener(this); - sensorManager = null; + } + if (handlerThread != null) { + handlerThread.quitSafely(); + handlerThread = null; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index b2f3ef00738..9c0f68c3f98 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -477,7 +477,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(19, options.integrations.size) + assertEquals(20, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt index 3115b43e4ed..17ae0ff022a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorManager +import android.os.Handler import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger @@ -11,6 +12,7 @@ import kotlin.test.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq +import org.mockito.kotlin.isA import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -28,7 +30,7 @@ class SentryShakeDetectorTest { init { whenever(context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(sensorManager) - whenever(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)).thenReturn(accelerometer) + whenever(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)).thenReturn(accelerometer) } fun getSut(): SentryShakeDetector { @@ -44,7 +46,7 @@ class SentryShakeDetectorTest { sut.start(fixture.context, fixture.listener) verify(fixture.sensorManager) - .registerListener(eq(sut), eq(fixture.accelerometer), eq(SensorManager.SENSOR_DELAY_NORMAL)) + .registerListener(eq(sut), eq(fixture.accelerometer), eq(SensorManager.SENSOR_DELAY_NORMAL), isA()) } @Test @@ -63,17 +65,17 @@ class SentryShakeDetectorTest { val sut = fixture.getSut() sut.start(fixture.context, fixture.listener) - verify(fixture.sensorManager, never()).registerListener(any(), any(), any()) + verify(fixture.sensorManager, never()).registerListener(any(), any(), any(), any()) } @Test fun `does not crash when accelerometer is null`() { - whenever(fixture.sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)).thenReturn(null) + whenever(fixture.sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)).thenReturn(null) val sut = fixture.getSut() sut.start(fixture.context, fixture.listener) - verify(fixture.sensorManager, never()).registerListener(any(), any(), any()) + verify(fixture.sensorManager, never()).registerListener(any(), any(), any(), any()) } @Test From 60b8a661a706c1648469aee5088708df6f928f54 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 9 Mar 2026 15:01:31 +0000 Subject: [PATCH 17/25] Format code --- .../android/core/SentryShakeDetector.java | 3 +-- .../android/core/SentryShakeDetectorTest.kt | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) 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 bbd6fee011b..84cfbe7bb9a 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 @@ -81,8 +81,7 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL handlerThread = new HandlerThread("sentry-shake"); handlerThread.start(); final Handler handler = new Handler(handlerThread.getLooper()); - sensorManager.registerListener( - this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL, handler); + sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL, handler); } public void stop() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt index 17ae0ff022a..98441e48a8d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShakeDetectorTest.kt @@ -30,7 +30,8 @@ class SentryShakeDetectorTest { init { whenever(context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(sensorManager) - whenever(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)).thenReturn(accelerometer) + whenever(sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)) + .thenReturn(accelerometer) } fun getSut(): SentryShakeDetector { @@ -46,7 +47,12 @@ class SentryShakeDetectorTest { sut.start(fixture.context, fixture.listener) verify(fixture.sensorManager) - .registerListener(eq(sut), eq(fixture.accelerometer), eq(SensorManager.SENSOR_DELAY_NORMAL), isA()) + .registerListener( + eq(sut), + eq(fixture.accelerometer), + eq(SensorManager.SENSOR_DELAY_NORMAL), + isA(), + ) } @Test @@ -65,17 +71,20 @@ class SentryShakeDetectorTest { val sut = fixture.getSut() sut.start(fixture.context, fixture.listener) - verify(fixture.sensorManager, never()).registerListener(any(), any(), any(), any()) + verify(fixture.sensorManager, never()) + .registerListener(any(), any(), any(), any()) } @Test fun `does not crash when accelerometer is null`() { - whenever(fixture.sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)).thenReturn(null) + whenever(fixture.sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false)) + .thenReturn(null) val sut = fixture.getSut() sut.start(fixture.context, fixture.listener) - verify(fixture.sensorManager, never()).registerListener(any(), any(), any(), any()) + verify(fixture.sensorManager, never()) + .registerListener(any(), any(), any(), any()) } @Test From 65122ca91a69fc6455726e61fbe5c973f67488e1 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 16:04:51 +0100 Subject: [PATCH 18/25] feat(feedback): add manifest meta-data support for useShakeGesture Allow enabling shake gesture via AndroidManifest.xml: Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- .../android/core/ManifestMetadataReader.java | 8 ++++++ .../core/ManifestMetadataReaderTest.kt | 25 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c80414a4f3..112f9327563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features - Show feedback form on device shake ([#5150](https://github.com/getsentry/sentry-java/pull/5150)) - - Enable via `options.getFeedbackOptions().setUseShakeGesture(true)` + - Enable via `options.getFeedbackOptions().setUseShakeGesture(true)` or manifest meta-data `io.sentry.feedback.use-shake-gesture` - Uses the device's accelerometer — no special permissions required ## 8.34.1 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 940fe8f4362..cef525f9023 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -167,6 +167,8 @@ final class ManifestMetadataReader { static final String FEEDBACK_SHOW_BRANDING = "io.sentry.feedback.show-branding"; + static final String FEEDBACK_USE_SHAKE_GESTURE = "io.sentry.feedback.use-shake-gesture"; + static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable"; static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url"; @@ -661,6 +663,12 @@ static void applyMetadata( metadata, logger, FEEDBACK_USE_SENTRY_USER, feedbackOptions.isUseSentryUser())); feedbackOptions.setShowBranding( readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding())); + feedbackOptions.setUseShakeGesture( + readBool( + metadata, + logger, + FEEDBACK_USE_SHAKE_GESTURE, + feedbackOptions.isUseShakeGesture())); options.setEnableSpotlight( readBool(metadata, logger, SPOTLIGHT_ENABLE, options.isEnableSpotlight())); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index b9b7d40e48a..ba01a9ecf7a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1950,6 +1950,31 @@ class ManifestMetadataReaderTest { assertFalse(fixture.options.feedbackOptions.isShowBranding) } + @Test + fun `applyMetadata reads feedback use shake gesture and keep default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.feedbackOptions.isUseShakeGesture) + } + + @Test + fun `applyMetadata reads feedback use shake gesture to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.FEEDBACK_USE_SHAKE_GESTURE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.feedbackOptions.isUseShakeGesture) + } + @Test fun `applyMetadata reads screenshot strategy canvas to options`() { // Arrange From 59cfc0af3579ad02e1b27f349794a8c51080db7f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 16:06:50 +0100 Subject: [PATCH 19/25] fix(feedback): preserve currentActivity in onActivityPaused when dialog is showing onActivityPaused always fires before onActivityDestroyed. Without this fix, currentActivity was set to null in onPause, making the cleanup condition in onActivityDestroyed (activity == currentActivity) always false. This left isDialogShowing permanently stuck as true, disabling shake-to-feedback for the rest of the session. Co-Authored-By: Claude Opus 4.6 --- .../io/sentry/android/core/FeedbackShakeIntegration.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 ee23b23ab74..0be706f7b6e 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 @@ -76,7 +76,13 @@ public void onActivityPaused(final @NotNull Activity activity) { // would kill shake detection for the new activity. if (activity == currentActivity) { stopShakeDetection(); - currentActivity = null; + // Keep currentActivity set when a dialog is showing so onActivityDestroyed + // can still match and clean up. Otherwise the cleanup condition + // (activity == currentActivity) would always be false since onPause fires + // before onDestroy. + if (!isDialogShowing) { + currentActivity = null; + } } } From 69ee93145cb1b21dbde87fab38429ce1fb166c33 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 16:09:44 +0100 Subject: [PATCH 20/25] fix(feedback): pass real logger to SentryShakeDetector on init The detector was constructed with NoOpLogger and the logger field was final, so all diagnostic messages (sensor unavailable warnings) were silently swallowed. Now init(context, logger) updates the logger from SentryOptions. Co-Authored-By: Claude Opus 4.6 --- .../io/sentry/android/core/FeedbackShakeIntegration.java | 3 +-- .../java/io/sentry/android/core/SentryShakeDetector.java | 9 +++++++-- 2 files changed, 8 insertions(+), 4 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 0be706f7b6e..c443f6a2775 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 @@ -42,8 +42,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions return; } - // Re-assign a properly configured detector logger now that options are available - shakeDetector.init(application); + shakeDetector.init(application, options.getLogger()); 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 84cfbe7bb9a..39fd45436b2 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,7 +40,7 @@ public final class SentryShakeDetector implements SensorEventListener { private @Nullable HandlerThread handlerThread; private final @NotNull AtomicLong lastShakeTimestamp = new AtomicLong(0); private volatile @Nullable Listener listener; - private final @NotNull ILogger logger; + private @NotNull ILogger logger; private int shakeCount = 0; private long firstShakeTimestamp = 0; @@ -57,7 +57,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) { + void init(final @NotNull Context context, final @NotNull ILogger logger) { + this.logger = logger; + init(context); + } + + private void init(final @NotNull Context context) { if (sensorManager == null) { sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); } From 7ba04475ec7d467f3e42f423674069590d029e73 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 9 Mar 2026 15:10:44 +0000 Subject: [PATCH 21/25] Format code --- .../java/io/sentry/android/core/ManifestMetadataReader.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index cef525f9023..822d7fbbe08 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -665,10 +665,7 @@ static void applyMetadata( readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding())); feedbackOptions.setUseShakeGesture( readBool( - metadata, - logger, - FEEDBACK_USE_SHAKE_GESTURE, - feedbackOptions.isUseShakeGesture())); + metadata, logger, FEEDBACK_USE_SHAKE_GESTURE, feedbackOptions.isUseShakeGesture())); options.setEnableSpotlight( readBool(metadata, logger, SPOTLIGHT_ENABLE, options.isEnableSpotlight())); From a734533545e6f28d446f1c6b428477fb23833a8c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 16:22:55 +0100 Subject: [PATCH 22/25] fix(feedback): clear stale activity ref and reset shake state on stop - Clear currentActivity in onActivityDestroyed to prevent holding a stale reference to a destroyed activity context - Reset shakeCount and firstShakeTimestamp in stop() to prevent cross-session false triggers across pause/resume cycles Co-Authored-By: Claude Opus 4.6 --- .../java/io/sentry/android/core/FeedbackShakeIntegration.java | 1 + .../main/java/io/sentry/android/core/SentryShakeDetector.java | 2 ++ 2 files changed, 3 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 c443f6a2775..38dc1a02e79 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 @@ -105,6 +105,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // outlive its host activity being destroyed. if (isDialogShowing && activity == currentActivity) { isDialogShowing = false; + currentActivity = null; if (options != null) { options.getFeedbackOptions().setOnFormClose(previousOnFormClose); } 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 39fd45436b2..ad6c58dd415 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 @@ -91,6 +91,8 @@ public void start(final @NotNull Context context, final @NotNull Listener shakeL public void stop() { listener = null; + shakeCount = 0; + firstShakeTimestamp = 0; if (sensorManager != null) { sensorManager.unregisterListener(this); } From 8b9bab2ba9ebc5bca5e398cdc7f3c394aa1c2309 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 16:35:43 +0100 Subject: [PATCH 23/25] fix(feedback): clean up dialog state when a different activity resumes When a dialog is showing on Activity A and the user navigates to Activity B (e.g. via notification), onActivityResumed(B) overwrites currentActivity. Later onActivityDestroyed(A) can't match and cleanup never runs, leaving isDialogShowing permanently stuck. Now we detect this in onActivityResumed and clean up proactively. Co-Authored-By: Claude Opus 4.6 --- .../sentry/android/core/FeedbackShakeIntegration.java | 10 ++++++++++ 1 file changed, 10 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 38dc1a02e79..c2778cfad77 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 @@ -64,6 +64,16 @@ public void close() throws IOException { @Override public void onActivityResumed(final @NotNull Activity activity) { + // If a dialog is showing on a different activity (e.g. user navigated via notification), + // clean up since the dialog's host activity is going away and onActivityDestroyed + // won't match currentActivity anymore. + if (isDialogShowing && currentActivity != null && currentActivity != activity) { + isDialogShowing = false; + if (options != null) { + options.getFeedbackOptions().setOnFormClose(previousOnFormClose); + } + previousOnFormClose = null; + } currentActivity = activity; startShakeDetection(activity); } From 301cc33b74c9e291e1f1d55f633bac8cfe1e53c7 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 16:51:33 +0100 Subject: [PATCH 24/25] fix(feedback): capture onFormClose as local variable in lambda The onFormClose lambda was reading previousOnFormClose field at dismiss time. If onActivityResumed or onActivityDestroyed already restored and nulled the field, the lambda would overwrite onFormClose with null. Now captured as a local variable at dialog creation time. Co-Authored-By: Claude Opus 4.6 --- .../io/sentry/android/core/FeedbackShakeIntegration.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 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 c2778cfad77..a17a91b103a 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 @@ -145,15 +145,16 @@ private void startShakeDetection(final @NotNull Activity activity) { } try { isDialogShowing = true; - previousOnFormClose = options.getFeedbackOptions().getOnFormClose(); + final Runnable captured = options.getFeedbackOptions().getOnFormClose(); + previousOnFormClose = captured; options .getFeedbackOptions() .setOnFormClose( () -> { isDialogShowing = false; - options.getFeedbackOptions().setOnFormClose(previousOnFormClose); - if (previousOnFormClose != null) { - previousOnFormClose.run(); + options.getFeedbackOptions().setOnFormClose(captured); + if (captured != null) { + captured.run(); } previousOnFormClose = null; }); From 1eb6c230bae9cafa9e66a614e40e11e882f760c6 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Mar 2026 17:11:41 +0100 Subject: [PATCH 25/25] fix(feedback): restore onFormClose in close() when dialog is showing When close() is called while a dialog is showing, lifecycle callbacks are unregistered so onActivityDestroyed cleanup won't fire. Restore previousOnFormClose and reset dialog state in close() to prevent the callback from being permanently overwritten. Co-Authored-By: Claude Opus 4.6 --- .../sentry/android/core/FeedbackShakeIntegration.java | 10 ++++++++++ 1 file changed, 10 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 a17a91b103a..af18be27778 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 @@ -60,6 +60,16 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); stopShakeDetection(); + // Restore onFormClose if a dialog is still showing, since lifecycle callbacks + // are now unregistered and onActivityDestroyed cleanup won't fire. + if (isDialogShowing) { + isDialogShowing = false; + if (options != null) { + options.getFeedbackOptions().setOnFormClose(previousOnFormClose); + } + previousOnFormClose = null; + } + currentActivity = null; } @Override