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. *