diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da8060b6b2..2f1b3db7fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### 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 + ### Fixes - Android: Add proguard rules to prevent error about missing Replay classes ([#5153](https://github.com/getsentry/sentry-java/pull/5153)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 7e64d0bcf80..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; @@ -457,6 +470,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 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..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,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 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/FeedbackShakeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java new file mode 100644 index 00000000000..00561766813 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java @@ -0,0 +1,162 @@ +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 FeedbackShakeIntegration + implements Integration, Closeable, Application.ActivityLifecycleCallbacks { + + private final @NotNull Application application; + private @Nullable 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"); + } + + @Override + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) { + this.options = (SentryAndroidOptions) sentryOptions; + + if (!this.options.getFeedbackOptions().isUseShakeGesture()) { + return; + } + + 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) { + currentActivity = activity; + startShakeDetection(activity); + } + } + + @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) { + // 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 + 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) { + // 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; + if (options != null) { + options.getFeedbackOptions().setOnFormClose(previousOnFormClose); + } + previousOnFormClose = null; + } + } + + private void startShakeDetection(final @NotNull Activity activity) { + if (options == null) { + return; + } + // Stop any existing detector (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) { + active.runOnUiThread( + () -> { + if (isDialogShowing) { + return; + } + 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); + } 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); + } + }); + } + }); + } + + private void stopShakeDetection() { + if (shakeDetector != null) { + shakeDetector.stop(); + shakeDetector = null; + } + } +} 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..f2a47dfe418 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryShakeDetector.java @@ -0,0 +1,112 @@ +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 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; + +/** + * 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. + * + *

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 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(); + } + + 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_NORMAL); + } + + public void stop() { + listener = null; + if (sensorManager != null) { + sensorManager.unregisterListener(this); + sensorManager = 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 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 + 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(); + } + } + } + } + } + + @Override + public void onAccuracyChanged(final @NotNull Sensor sensor, final int accuracy) { + // Not needed for shake detection. + } +} 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 new file mode 100644 index 00000000000..cb940686c30 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/FeedbackShakeIntegrationTest.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 FeedbackShakeIntegrationTest { + + 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): FeedbackShakeIntegration { + options.feedbackOptions.isUseShakeGesture = useShakeGesture + return FeedbackShakeIntegration(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-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..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 @@ -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 FeedbackShakeIntegration || it is FragmentLifecycleIntegration || it is SentryTimberIntegration || it is AppComponentsBreadcrumbsIntegration || 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/api/sentry.api b/sentry/api/sentry.api index 73eb9c61446..8c0ce1411c5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3158,6 +3158,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 @@ -3180,6 +3181,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; } 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 + '\''