From 1e00dfe46e6a9fe705f6764d406ac19a2fa71979 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 25 Mar 2026 09:30:39 +0100 Subject: [PATCH] feat(feedback): add runtime toggle for shake-to-report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Sentry.enableFeedbackOnShake() and Sentry.disableFeedbackOnShake() for runtime control of shake gesture detection, matching the React Native API. The integration now always registers lifecycle callbacks and checks the useShakeGesture flag dynamically on each activity transition. When disabled, there is zero overhead — no sensor or thread allocation. Sensor/thread initialization is deferred to first use. The shake callback also checks the flag before showing the dialog, so disabling takes effect immediately for new shakes even before the next activity transition. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../core/FeedbackShakeIntegration.java | 28 ++++++++------- .../core/FeedbackShakeIntegrationTest.kt | 35 +++++++++++++++++-- sentry/api/sentry.api | 2 ++ sentry/src/main/java/io/sentry/Sentry.java | 18 ++++++++++ 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b59334d9..accfe2b8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Features +- Add `Sentry.enableFeedbackOnShake()` and `Sentry.disableFeedbackOnShake()` for runtime control of shake-to-report ([#5232](https://github.com/getsentry/sentry-java/pull/5232)) - Add configurable `IScopesStorageFactory` to `SentryOptions` for providing a custom `IScopesStorage`, e.g. when the default `ThreadLocal`-backed storage is incompatible with non-pinning thread models ([#5199](https://github.com/getsentry/sentry-java/pull/5199)) - Android: Add `beforeErrorSampling` callback to Session Replay ([#5214](https://github.com/getsentry/sentry-java/pull/5214)) - Allows filtering which errors trigger replay capture before the `onErrorSampleRate` is checked 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 b845b6ed8c..c7aa7f980f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java @@ -44,21 +44,17 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions : null, "SentryAndroidOptions is required"); - if (!this.options.getFeedbackOptions().isUseShakeGesture()) { - return; - } - - shakeDetector.init(application, options.getLogger()); - addIntegrationToSdkVersion("FeedbackShake"); application.registerActivityLifecycleCallbacks(this); 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(); - if (activity != null) { - currentActivityRef = new WeakReference<>(activity); - startShakeDetection(activity); + if (this.options.getFeedbackOptions().isUseShakeGesture()) { + // In case of a deferred init, hook into any already-resumed activity + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + if (activity != null) { + currentActivityRef = new WeakReference<>(activity); + startShakeDetection(activity); + } } } @@ -92,7 +88,12 @@ public void onActivityResumed(final @NotNull Activity activity) { previousOnFormClose = null; } currentActivityRef = new WeakReference<>(activity); - startShakeDetection(activity); + // Check dynamically so the flag can be toggled at runtime + if (options != null && options.getFeedbackOptions().isUseShakeGesture()) { + startShakeDetection(activity); + } else { + stopShakeDetection(); + } } @Override @@ -146,6 +147,8 @@ private void startShakeDetection(final @NotNull Activity activity) { if (options == null) { return; } + // Initialize sensor and thread if not already done (idempotent) + shakeDetector.init(application, options.getLogger()); // Stop any existing detection (e.g. when transitioning between activities) stopShakeDetection(); shakeDetector.start( @@ -156,6 +159,7 @@ private void startShakeDetection(final @NotNull Activity activity) { final Boolean inBackground = AppState.getInstance().isInBackground(); if (active != null && options != null + && options.getFeedbackOptions().isUseShakeGesture() && !isDialogShowing && !Boolean.TRUE.equals(inBackground)) { active.runOnUiThread( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt index cb940686c3..d83bbaf319 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.kt @@ -10,7 +10,6 @@ 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 @@ -50,11 +49,11 @@ class FeedbackShakeIntegrationTest { } @Test - fun `when useShakeGesture is disabled does not register activity lifecycle callbacks`() { + fun `when useShakeGesture is disabled still registers activity lifecycle callbacks for runtime toggle`() { val sut = fixture.getSut(useShakeGesture = false) sut.register(fixture.scopes, fixture.options) - verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) + verify(fixture.application).registerActivityLifecycleCallbacks(any()) } @Test @@ -103,4 +102,34 @@ class FeedbackShakeIntegrationTest { val sut = fixture.getSut() sut.close() } + + @Test + fun `enabling shake at runtime starts detection on next activity resume`() { + val sut = fixture.getSut(useShakeGesture = false) + sut.register(fixture.scopes, fixture.options) + + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + + // Shake is disabled, onActivityResumed should not crash + sut.onActivityResumed(fixture.activity) + + // Now enable at runtime and resume a new activity + fixture.options.feedbackOptions.isUseShakeGesture = true + sut.onActivityResumed(fixture.activity) + // Should not throw — shake detection attempted (fails gracefully with null SensorManager) + } + + @Test + fun `disabling shake at runtime stops detection on next activity resume`() { + val sut = fixture.getSut(useShakeGesture = true) + sut.register(fixture.scopes, fixture.options) + + whenever(fixture.activity.getSystemService(any())).thenReturn(null) + sut.onActivityResumed(fixture.activity) + + // Disable at runtime and resume + fixture.options.feedbackOptions.isUseShakeGesture = false + sut.onActivityResumed(fixture.activity) + // Should not throw — detection stopped gracefully + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 1d8ff4d3e0..1dc7f48d72 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2705,7 +2705,9 @@ public final class io/sentry/Sentry { public static fun configureScope (Lio/sentry/ScopeCallback;)V public static fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public static fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; + public static fun disableFeedbackOnShake ()V public static fun distribution ()Lio/sentry/IDistributionApi; + public static fun enableFeedbackOnShake ()V public static fun endSession ()V public static fun flush (J)V public static fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index fee19dc4d0..aed60ed6fb 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1371,6 +1371,24 @@ public static void showUserFeedbackDialog( options.getFeedbackOptions().getDialogHandler().showDialog(associatedEventId, configurator); } + /** + * Enables shake-to-report for user feedback. When enabled, shaking the device will show the + * feedback dialog. Takes effect on the next activity transition. This can be toggled at runtime. + */ + @ApiStatus.Experimental + public static void enableFeedbackOnShake() { + getCurrentScopes().getOptions().getFeedbackOptions().setUseShakeGesture(true); + } + + /** + * Disables shake-to-report for user feedback. Takes effect on the next activity transition. If a + * shake occurs before the transition, the dialog will not be shown. + */ + @ApiStatus.Experimental + public static void disableFeedbackOnShake() { + getCurrentScopes().getOptions().getFeedbackOptions().setUseShakeGesture(false); + } + /** * Sets an attribute on the scope. *