From c04bebe17fa050763765b335472d6e1fcf6d7226 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 17:29:28 +0100 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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);