diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9ef7db143..dea8322409 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,11 @@
## Unreleased
+### Features
+
+- Show feedback widget on device shake ([#5729](https://github.com/getsentry/sentry-react-native/pull/5729))
+ - Use `Sentry.showFeedbackOnShake()` / `Sentry.hideFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })`
+
### Dependencies
- Bump JavaScript SDK from v10.39.0 to v10.40.0 ([#5715](https://github.com/getsentry/sentry-react-native/pull/5715))
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
index 4a37c28827..f50b2ef158 100644
--- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
@@ -122,6 +122,10 @@ public class RNSentryModuleImpl {
private final @NotNull Runnable emitNewFrameEvent;
+ private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake";
+ private @Nullable RNSentryShakeDetector shakeDetector;
+ private int shakeListenerCount = 0;
+
/** Max trace file size in bytes. */
private long maxTraceFileSize = 5 * 1024 * 1024;
@@ -192,16 +196,50 @@ public void crash() {
}
public void addListener(String eventType) {
+ if (ON_SHAKE_EVENT.equals(eventType)) {
+ shakeListenerCount++;
+ if (shakeListenerCount == 1) {
+ startShakeDetection();
+ }
+ return;
+ }
// Is must be defined otherwise the generated interface from TS won't be
// fulfilled
logger.log(SentryLevel.ERROR, "addListener of NativeEventEmitter can't be used on Android!");
}
public void removeListeners(double id) {
- // Is must be defined otherwise the generated interface from TS won't be
- // fulfilled
- logger.log(
- SentryLevel.ERROR, "removeListeners of NativeEventEmitter can't be used on Android!");
+ shakeListenerCount = Math.max(0, shakeListenerCount - (int) id);
+ if (shakeListenerCount == 0) {
+ stopShakeDetection();
+ }
+ }
+
+ private void startShakeDetection() {
+ if (shakeDetector != null) {
+ return;
+ }
+
+ final ReactApplicationContext context = getReactApplicationContext();
+ shakeDetector = new RNSentryShakeDetector(logger);
+ shakeDetector.start(
+ context,
+ () -> {
+ final ReactApplicationContext ctx = getReactApplicationContext();
+ if (ctx.hasActiveReactInstance()) {
+ ctx.getJSModule(
+ com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
+ .class)
+ .emit(ON_SHAKE_EVENT, null);
+ }
+ });
+ }
+
+ private void stopShakeDetection() {
+ if (shakeDetector != null) {
+ shakeDetector.stop();
+ shakeDetector = null;
+ }
}
public void fetchModules(Promise promise) {
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryShakeDetector.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryShakeDetector.java
new file mode 100644
index 0000000000..0270bf07b2
--- /dev/null
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryShakeDetector.java
@@ -0,0 +1,92 @@
+package io.sentry.react;
+
+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 class RNSentryShakeDetector 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 ShakeListener listener;
+ private final @NotNull ILogger logger;
+
+ public interface ShakeListener {
+ void onShake();
+ }
+
+ public RNSentryShakeDetector(@NotNull ILogger logger) {
+ this.logger = logger;
+ }
+
+ public void start(@NotNull Context context, @NotNull ShakeListener 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);
+ logger.log(SentryLevel.DEBUG, "Shake detection started.");
+ }
+
+ public void stop() {
+ if (sensorManager != null) {
+ sensorManager.unregisterListener(this);
+ logger.log(SentryLevel.DEBUG, "Shake detection stopped.");
+ }
+ listener = null;
+ sensorManager = null;
+ }
+
+ @Override
+ public void onSensorChanged(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(Sensor sensor, int accuracy) {
+ // Not needed for shake detection
+ }
+}
diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm
index ec050bc56f..e33fb9fb5a 100644
--- a/packages/core/ios/RNSentry.mm
+++ b/packages/core/ios/RNSentry.mm
@@ -39,6 +39,7 @@
#import "RNSentryDependencyContainer.h"
#import "RNSentryEvents.h"
+#import "RNSentryShakeDetector.h"
#if SENTRY_TARGET_REPLAY_SUPPORTED
# import "RNSentryReplay.h"
@@ -284,17 +285,33 @@ - (void)initFramesTracking
- (void)startObserving
{
hasListeners = YES;
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(handleShakeDetected)
+ name:RNSentryShakeDetectedNotification
+ object:nil];
+ [RNSentryShakeDetector enable];
}
// Will be called when this module's last listener is removed, or on dealloc.
- (void)stopObserving
{
hasListeners = NO;
+ [RNSentryShakeDetector disable];
+ [[NSNotificationCenter defaultCenter] removeObserver:self
+ name:RNSentryShakeDetectedNotification
+ object:nil];
+}
+
+- (void)handleShakeDetected
+{
+ if (hasListeners) {
+ [self sendEventWithName:RNSentryOnShakeEvent body:@{}];
+ }
}
- (NSArray *)supportedEvents
{
- return @[ RNSentryNewFrameEvent ];
+ return @[ RNSentryNewFrameEvent, RNSentryOnShakeEvent ];
}
RCT_EXPORT_METHOD(
diff --git a/packages/core/ios/RNSentryEvents.h b/packages/core/ios/RNSentryEvents.h
index ee9f5e2088..0345915d16 100644
--- a/packages/core/ios/RNSentryEvents.h
+++ b/packages/core/ios/RNSentryEvents.h
@@ -1,3 +1,4 @@
#import
extern NSString *const RNSentryNewFrameEvent;
+extern NSString *const RNSentryOnShakeEvent;
diff --git a/packages/core/ios/RNSentryEvents.m b/packages/core/ios/RNSentryEvents.m
index 13e3669cdd..f028e62222 100644
--- a/packages/core/ios/RNSentryEvents.m
+++ b/packages/core/ios/RNSentryEvents.m
@@ -1,3 +1,4 @@
#import "RNSentryEvents.h"
NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame";
+NSString *const RNSentryOnShakeEvent = @"rn_sentry_on_shake";
diff --git a/packages/core/ios/RNSentryShakeDetector.h b/packages/core/ios/RNSentryShakeDetector.h
new file mode 100644
index 0000000000..00195cab0c
--- /dev/null
+++ b/packages/core/ios/RNSentryShakeDetector.h
@@ -0,0 +1,22 @@
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+extern NSNotificationName const RNSentryShakeDetectedNotification;
+
+/**
+ * Detects shake gestures by swizzling UIWindow's motionEnded:withEvent: method.
+ *
+ * This approach uses UIKit's built-in shake detection via the responder chain,
+ * which does NOT require NSMotionUsageDescription or any other permissions.
+ * (NSMotionUsageDescription is only needed for Core Motion / CMMotionManager.)
+ */
+@interface RNSentryShakeDetector : NSObject
+
++ (void)enable;
++ (void)disable;
++ (BOOL)isEnabled;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/core/ios/RNSentryShakeDetector.m b/packages/core/ios/RNSentryShakeDetector.m
new file mode 100644
index 0000000000..6a51938326
--- /dev/null
+++ b/packages/core/ios/RNSentryShakeDetector.m
@@ -0,0 +1,103 @@
+#import "RNSentryShakeDetector.h"
+
+#if SENTRY_HAS_UIKIT
+
+# import
+# import
+
+NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected";
+
+static BOOL _shakeDetectionEnabled = NO;
+static IMP _originalMotionEndedIMP = NULL;
+static BOOL _swizzled = NO;
+static NSTimeInterval _lastShakeTimestamp = 0;
+static const NSTimeInterval SHAKE_COOLDOWN_SECONDS = 1.0;
+
+// Intercepts UIWindow motion events before they continue up the responder chain.
+//
+// The iOS simulator routes shake (Cmd+Ctrl+Z) through UIWindow.motionEnded:withEvent:,
+// not through UIApplication.sendEvent:. React Native's dev menu also hooks UIWindow
+// via RCTSwapInstanceMethods. Because we swizzle from startObserving (which fires after
+// RN finishes loading), our IMP becomes the outermost layer: our code runs first,
+// then the saved original IMP (RN's dev menu handler) is called.
+static void
+sentry_motionEnded(UIWindow *self, SEL _cmd, UIEventSubtype motion, UIEvent *event)
+{
+ if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) {
+ NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
+ if (now - _lastShakeTimestamp > SHAKE_COOLDOWN_SECONDS) {
+ _lastShakeTimestamp = now;
+ [[NSNotificationCenter defaultCenter]
+ postNotificationName:RNSentryShakeDetectedNotification
+ object:nil];
+ }
+ }
+
+ if (_originalMotionEndedIMP) {
+ ((void (*)(id, SEL, UIEventSubtype, UIEvent *))_originalMotionEndedIMP)(
+ self, _cmd, motion, event);
+ }
+}
+
+@implementation RNSentryShakeDetector
+
++ (void)enable
+{
+ @synchronized(self) {
+ if (!_swizzled) {
+ // React Native's dev menu swizzles UIWindow.motionEnded:withEvent: at bridge
+ // load time, before any JS runs. Because enable is called from startObserving
+ // (triggered by componentDidMount via NativeEventEmitter.addListener), we always
+ // swizzle after RN — making our function the outermost wrapper that calls
+ // through to RN's handler via _originalMotionEndedIMP.
+ Class windowClass = [UIWindow class];
+ Method originalMethod
+ = class_getInstanceMethod(windowClass, @selector(motionEnded:withEvent:));
+ if (originalMethod) {
+ _originalMotionEndedIMP = method_getImplementation(originalMethod);
+ method_setImplementation(originalMethod, (IMP)sentry_motionEnded);
+ _swizzled = YES;
+ }
+ }
+ _shakeDetectionEnabled = YES;
+ }
+}
+
++ (void)disable
+{
+ @synchronized(self) {
+ _shakeDetectionEnabled = NO;
+ }
+}
+
++ (BOOL)isEnabled
+{
+ return _shakeDetectionEnabled;
+}
+
+@end
+
+#else
+
+NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected";
+
+@implementation RNSentryShakeDetector
+
++ (void)enable
+{
+ // No-op on non-UIKit platforms (macOS, tvOS)
+}
+
++ (void)disable
+{
+ // No-op
+}
+
++ (BOOL)isEnabled
+{
+ return NO;
+}
+
+@end
+
+#endif
diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx
index 505bf5e6da..56de6861c9 100644
--- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx
+++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx
@@ -1,6 +1,7 @@
import { debug } from '@sentry/core';
import { isWeb } from '../utils/environment';
import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy';
+import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
export const PULL_DOWN_CLOSE_THRESHOLD = 200;
export const SLIDE_ANIMATION_DURATION = 200;
@@ -132,4 +133,13 @@ const resetScreenshotButtonManager = (): void => {
ScreenshotButtonManager.reset();
};
-export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
+const showFeedbackOnShake = (): void => {
+ lazyLoadAutoInjectFeedbackIntegration();
+ startShakeListener(showFeedbackWidget);
+};
+
+const hideFeedbackOnShake = (): void => {
+ stopShakeListener();
+};
+
+export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showFeedbackOnShake, hideFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
diff --git a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx
index 426affd998..7ab2a08856 100644
--- a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx
+++ b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx
@@ -13,10 +13,12 @@ import {
FeedbackWidgetManager,
PULL_DOWN_CLOSE_THRESHOLD,
ScreenshotButtonManager,
+ showFeedbackWidget,
SLIDE_ANIMATION_DURATION,
} from './FeedbackWidgetManager';
-import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration';
+import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions, isShakeToReportEnabled } from './integration';
import { ScreenshotButton } from './ScreenshotButton';
+import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils';
const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
@@ -92,21 +94,27 @@ export class FeedbackWidgetProvider extends React.Component {
this.forceUpdate();
});
+
+ if (isShakeToReportEnabled()) {
+ startShakeListener(showFeedbackWidget);
+ }
}
/**
- * Clean up the theme listener.
+ * Clean up the theme listener and stop shake detection.
*/
public componentWillUnmount(): void {
if (this._themeListener) {
this._themeListener.remove();
}
+
+ stopShakeListener();
}
/**
diff --git a/packages/core/src/js/feedback/ShakeToReportBug.ts b/packages/core/src/js/feedback/ShakeToReportBug.ts
new file mode 100644
index 0000000000..a984a9a0e3
--- /dev/null
+++ b/packages/core/src/js/feedback/ShakeToReportBug.ts
@@ -0,0 +1,66 @@
+import { debug } from '@sentry/core';
+import type { EmitterSubscription, NativeModule } from 'react-native';
+import { NativeEventEmitter } from 'react-native';
+import { isWeb } from '../utils/environment';
+import { getRNSentryModule } from '../wrapper';
+
+export const OnShakeEventName = 'rn_sentry_on_shake';
+
+let _shakeSubscription: EmitterSubscription | null = null;
+
+/**
+ * Creates a NativeEventEmitter for the given module.
+ * Can be overridden in tests via the `createEmitter` parameter.
+ */
+type EmitterFactory = (nativeModule: NativeModule) => NativeEventEmitter;
+
+const defaultEmitterFactory: EmitterFactory = nativeModule => new NativeEventEmitter(nativeModule);
+
+/**
+ * Starts listening for device shake events and invokes the provided callback when a shake is detected.
+ *
+ * This starts native shake detection:
+ * - iOS: Uses UIKit's motion event detection (no permissions required)
+ * - Android: Uses the accelerometer sensor (no permissions required)
+ */
+export function startShakeListener(onShake: () => void, createEmitter: EmitterFactory = defaultEmitterFactory): void {
+ if (_shakeSubscription) {
+ debug.log('Shake listener is already active.');
+ return;
+ }
+
+ if (isWeb()) {
+ debug.warn('Shake detection is not supported on Web.');
+ return;
+ }
+
+ const nativeModule = getRNSentryModule() as NativeModule | undefined;
+ if (!nativeModule) {
+ debug.warn('Native module is not available. Shake detection will not work.');
+ return;
+ }
+
+ const emitter = createEmitter(nativeModule);
+ _shakeSubscription = emitter.addListener(OnShakeEventName, () => {
+ debug.log('Shake detected.');
+ onShake();
+ });
+}
+
+/**
+ * Stops listening for device shake events.
+ */
+export function stopShakeListener(): void {
+ if (_shakeSubscription) {
+ _shakeSubscription.remove();
+ _shakeSubscription = null;
+ }
+}
+
+/**
+ * Returns whether the shake listener is currently active.
+ * Exported for testing purposes.
+ */
+export function isShakeListenerActive(): boolean {
+ return _shakeSubscription !== null;
+}
diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts
index 895568f57d..ace02554e2 100644
--- a/packages/core/src/js/feedback/integration.ts
+++ b/packages/core/src/js/feedback/integration.ts
@@ -11,6 +11,7 @@ type FeedbackIntegration = Integration & {
colorScheme?: 'system' | 'light' | 'dark';
themeLight: Partial;
themeDark: Partial;
+ enableShakeToReport: boolean;
};
export const feedbackIntegration = (
@@ -20,6 +21,15 @@ export const feedbackIntegration = (
colorScheme?: 'system' | 'light' | 'dark';
themeLight?: Partial;
themeDark?: Partial;
+ /**
+ * Enable showing the feedback widget when the user shakes the device.
+ *
+ * - iOS: Uses UIKit's motion event detection (no permissions required)
+ * - Android: Uses the accelerometer sensor (no permissions required)
+ *
+ * @default false
+ */
+ enableShakeToReport?: boolean;
} = {},
): FeedbackIntegration => {
const {
@@ -28,6 +38,7 @@ export const feedbackIntegration = (
colorScheme,
themeLight: lightTheme,
themeDark: darkTheme,
+ enableShakeToReport: shakeToReport,
...widgetOptions
} = initOptions;
@@ -39,6 +50,7 @@ export const feedbackIntegration = (
colorScheme: colorScheme || 'system',
themeLight: lightTheme || {},
themeDark: darkTheme || {},
+ enableShakeToReport: shakeToReport || false,
};
};
@@ -99,3 +111,8 @@ export const getFeedbackDarkTheme = (): Partial => {
return integration.themeDark;
};
+
+export const isShakeToReportEnabled = (): boolean => {
+ const integration = _getClientIntegration();
+ return integration?.enableShakeToReport ?? false;
+};
diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts
index 19ba331003..74dfb1f60e 100644
--- a/packages/core/src/js/index.ts
+++ b/packages/core/src/js/index.ts
@@ -100,6 +100,12 @@ export { Mask, Unmask } from './replay/CustomMask';
export { FeedbackButton } from './feedback/FeedbackButton';
export { FeedbackWidget } from './feedback/FeedbackWidget';
-export { showFeedbackWidget, showFeedbackButton, hideFeedbackButton } from './feedback/FeedbackWidgetManager';
+export {
+ showFeedbackWidget,
+ showFeedbackButton,
+ hideFeedbackButton,
+ showFeedbackOnShake,
+ hideFeedbackOnShake,
+} from './feedback/FeedbackWidgetManager';
export { getDataFromUri } from './wrapper';
diff --git a/packages/core/test/feedback/ShakeToReportBug.test.tsx b/packages/core/test/feedback/ShakeToReportBug.test.tsx
new file mode 100644
index 0000000000..3b85c17960
--- /dev/null
+++ b/packages/core/test/feedback/ShakeToReportBug.test.tsx
@@ -0,0 +1,213 @@
+import { debug, setCurrentClient } from '@sentry/core';
+import { render } from '@testing-library/react-native';
+import * as React from 'react';
+import { Text } from 'react-native';
+import {
+ resetFeedbackWidgetManager,
+} from '../../src/js/feedback/FeedbackWidgetManager';
+import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider';
+import { feedbackIntegration } from '../../src/js/feedback/integration';
+import { isShakeListenerActive, startShakeListener, stopShakeListener } from '../../src/js/feedback/ShakeToReportBug';
+import { isModalSupported } from '../../src/js/feedback/utils';
+import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
+
+jest.mock('../../src/js/feedback/utils', () => ({
+ isModalSupported: jest.fn(),
+ isNativeDriverSupportedForColorAnimations: jest.fn().mockReturnValue(true),
+}));
+
+const mockedIsModalSupported = isModalSupported as jest.MockedFunction;
+
+jest.mock('../../src/js/wrapper', () => ({
+ getRNSentryModule: jest.fn(() => ({
+ addListener: jest.fn(),
+ removeListeners: jest.fn(),
+ })),
+}));
+
+let mockShakeCallback: (() => void) | undefined;
+const mockRemove = jest.fn();
+
+const createMockEmitter = () => {
+ return jest.fn().mockReturnValue({
+ addListener: jest.fn().mockImplementation((_eventType: string, listener: () => void) => {
+ mockShakeCallback = listener;
+ return { remove: mockRemove };
+ }),
+ });
+};
+
+let mockEmitterFactory: ReturnType;
+
+// Also mock the module-level NativeEventEmitter used by FeedbackWidgetProvider's auto-start
+jest.mock('../../src/js/feedback/ShakeToReportBug', () => {
+ const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug');
+ return {
+ ...actual,
+ startShakeListener: jest.fn(actual.startShakeListener),
+ stopShakeListener: jest.fn(actual.stopShakeListener),
+ isShakeListenerActive: jest.fn(actual.isShakeListenerActive),
+ };
+});
+
+beforeEach(() => {
+ debug.error = jest.fn();
+ debug.log = jest.fn() as typeof debug.log;
+ debug.warn = jest.fn() as typeof debug.warn;
+});
+
+describe('ShakeToReportBug', () => {
+ beforeEach(() => {
+ const client = new TestClient(getDefaultTestClientOptions());
+ setCurrentClient(client);
+ client.init();
+ resetFeedbackWidgetManager();
+
+ // Get the actual functions (unmocked)
+ const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug');
+ actual.stopShakeListener();
+
+ mockShakeCallback = undefined;
+ mockRemove.mockClear();
+ mockEmitterFactory = createMockEmitter();
+
+ (startShakeListener as jest.Mock).mockClear();
+ (stopShakeListener as jest.Mock).mockClear();
+ (isShakeListenerActive as jest.Mock).mockClear();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('startShakeListener / stopShakeListener', () => {
+ it('starts listening for shake events', () => {
+ const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug');
+ actual.startShakeListener(jest.fn(), mockEmitterFactory);
+
+ expect(actual.isShakeListenerActive()).toBe(true);
+ expect(mockEmitterFactory).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not start a second listener if already active', () => {
+ const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug');
+ actual.startShakeListener(jest.fn(), mockEmitterFactory);
+ actual.startShakeListener(jest.fn(), mockEmitterFactory);
+
+ expect(actual.isShakeListenerActive()).toBe(true);
+ expect(mockEmitterFactory).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops listening for shake events', () => {
+ const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug');
+ actual.startShakeListener(jest.fn(), mockEmitterFactory);
+ actual.stopShakeListener();
+
+ expect(actual.isShakeListenerActive()).toBe(false);
+ expect(mockRemove).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not throw when stopping without starting', () => {
+ const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug');
+ expect(() => actual.stopShakeListener()).not.toThrow();
+ });
+
+ it('invokes onShake callback when shake event is received', () => {
+ const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug');
+ const onShake = jest.fn();
+ actual.startShakeListener(onShake, mockEmitterFactory);
+
+ mockShakeCallback?.();
+
+ expect(onShake).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('feedbackIntegration with enableShakeToReport', () => {
+ it('auto-starts shake listener when enableShakeToReport is true', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+
+ const integration = feedbackIntegration({
+ enableShakeToReport: true,
+ });
+
+ const client = new TestClient(getDefaultTestClientOptions());
+ setCurrentClient(client);
+ client.init();
+ client.addIntegration(integration);
+
+ render(
+
+ App Components
+ ,
+ );
+
+ expect(startShakeListener).toHaveBeenCalled();
+ });
+
+ it('does not auto-start shake listener when enableShakeToReport is false', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+
+ const integration = feedbackIntegration({
+ enableShakeToReport: false,
+ });
+
+ const client = new TestClient(getDefaultTestClientOptions());
+ setCurrentClient(client);
+ client.init();
+ client.addIntegration(integration);
+
+ render(
+
+ App Components
+ ,
+ );
+
+ expect(startShakeListener).not.toHaveBeenCalled();
+ });
+
+ it('does not auto-start shake listener when enableShakeToReport is not set', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+
+ const integration = feedbackIntegration();
+
+ const client = new TestClient(getDefaultTestClientOptions());
+ setCurrentClient(client);
+ client.init();
+ client.addIntegration(integration);
+
+ render(
+
+ App Components
+ ,
+ );
+
+ expect(startShakeListener).not.toHaveBeenCalled();
+ });
+
+ it('stops shake listener when FeedbackWidgetProvider unmounts', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+
+ const integration = feedbackIntegration({
+ enableShakeToReport: true,
+ });
+
+ const client = new TestClient(getDefaultTestClientOptions());
+ setCurrentClient(client);
+ client.init();
+ client.addIntegration(integration);
+
+ const { unmount } = render(
+
+ App Components
+ ,
+ );
+
+ expect(startShakeListener).toHaveBeenCalled();
+
+ unmount();
+
+ expect(stopShakeListener).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx
index 814c675965..806e80c44a 100644
--- a/samples/react-native/src/App.tsx
+++ b/samples/react-native/src/App.tsx
@@ -112,6 +112,7 @@ Sentry.init({
imagePicker: ImagePicker,
enableScreenshot: true,
enableTakeScreenshot: true,
+ enableShakeToReport: true,
styles: {
submitButton: {
backgroundColor: '#6a1b9a',