From 9bc0c17d3ac7cb2b0ffe0567999a42de52dca2ba Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 26 Feb 2026 16:57:45 +0100 Subject: [PATCH 01/28] feat(feedback): Show feedback widget on device shake Implement device shake detection to trigger the feedback widget. No permissions are required on either platform: - iOS: Uses UIKit's motionEnded:withEvent: via UIWindow swizzle - Android: Uses SensorManager accelerometer (TYPE_ACCELEROMETER) Public API: - showFeedbackOnShake() / hideFeedbackOnShake() imperative APIs - feedbackIntegration({ enableShakeToReport: true }) declarative option Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 + .../io/sentry/react/RNSentryModuleImpl.java | 46 +++- .../sentry/react/RNSentryShakeDetector.java | 92 ++++++++ packages/core/ios/RNSentry.mm | 19 +- packages/core/ios/RNSentryEvents.h | 1 + packages/core/ios/RNSentryEvents.m | 1 + packages/core/ios/RNSentryShakeDetector.h | 22 ++ packages/core/ios/RNSentryShakeDetector.m | 83 +++++++ .../src/js/feedback/FeedbackWidgetManager.tsx | 12 +- .../js/feedback/FeedbackWidgetProvider.tsx | 14 +- .../core/src/js/feedback/ShakeToReportBug.ts | 66 ++++++ packages/core/src/js/feedback/integration.ts | 17 ++ packages/core/src/js/index.ts | 8 +- .../test/feedback/ShakeToReportBug.test.tsx | 213 ++++++++++++++++++ 14 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryShakeDetector.java create mode 100644 packages/core/ios/RNSentryShakeDetector.h create mode 100644 packages/core/ios/RNSentryShakeDetector.m create mode 100644 packages/core/src/js/feedback/ShakeToReportBug.ts create mode 100644 packages/core/test/feedback/ShakeToReportBug.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 77fd7a2dbf..021c9c7ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## 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 })` + ## 8.2.0 ### Fixes 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..19a1e4da25 --- /dev/null +++ b/packages/core/ios/RNSentryShakeDetector.m @@ -0,0 +1,83 @@ +#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 void +sentry_motionEnded(id self, SEL _cmd, UIEventSubtype motion, UIEvent *event) +{ + if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) { + [[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) { + Method originalMethod + = class_getInstanceMethod([UIWindow class], @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(); + }); + }); +}); From c5d398e674e6a668538a9d5081d8edfde4eee3a0 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 26 Feb 2026 17:04:42 +0100 Subject: [PATCH 02/28] Add to sample app --- samples/react-native/src/App.tsx | 1 + 1 file changed, 1 insertion(+) 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', From 96664e861b1188140fe45b1dbf0e47575e77cd65 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Feb 2026 13:33:39 +0100 Subject: [PATCH 03/28] fix(ios): fix shake detection in iOS simulator by swizzling UIApplication.sendEvent: The previous implementation swizzled UIWindow.motionEnded:withEvent: which was intercepted by React Native's dev menu before our handler could fire. Switching to UIApplication.sendEvent: intercepts events before the responder chain, so shake events are detected even when RN dev menu or another responder consumes the motion event without calling super. Added a 1-second cooldown to prevent double-firing since both motionBegan and motionEnded trigger UIEventSubtypeMotionShake. --- packages/core/ios/RNSentryShakeDetector.m | 35 +++++++++++++++-------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/core/ios/RNSentryShakeDetector.m b/packages/core/ios/RNSentryShakeDetector.m index 19a1e4da25..9bad32f950 100644 --- a/packages/core/ios/RNSentryShakeDetector.m +++ b/packages/core/ios/RNSentryShakeDetector.m @@ -8,20 +8,30 @@ NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected"; static BOOL _shakeDetectionEnabled = NO; -static IMP _originalMotionEndedIMP = NULL; +static IMP _originalSendEventIMP = NULL; static BOOL _swizzled = NO; +static NSTimeInterval _lastShakeTimestamp = 0; +static const NSTimeInterval SHAKE_COOLDOWN_SECONDS = 1.0; +// Intercepts all UIApplication events before they enter the responder chain. +// This ensures shake events are detected even when React Native's dev menu +// or another responder consumes the motion event without calling super. static void -sentry_motionEnded(id self, SEL _cmd, UIEventSubtype motion, UIEvent *event) +sentry_sendEvent(UIApplication *self, SEL _cmd, UIEvent *event) { - if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) { - [[NSNotificationCenter defaultCenter] postNotificationName:RNSentryShakeDetectedNotification - object:nil]; + if (_shakeDetectionEnabled && event.type == UIEventTypeMotion + && event.subtype == 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); + if (_originalSendEventIMP) { + ((void (*)(id, SEL, UIEvent *))_originalSendEventIMP)(self, _cmd, event); } } @@ -31,11 +41,12 @@ + (void)enable { @synchronized(self) { if (!_swizzled) { - Method originalMethod - = class_getInstanceMethod([UIWindow class], @selector(motionEnded:withEvent:)); + // Use the actual class of the shared application to handle UIApplication subclasses + Class appClass = [[UIApplication sharedApplication] class]; + Method originalMethod = class_getInstanceMethod(appClass, @selector(sendEvent:)); if (originalMethod) { - _originalMotionEndedIMP = method_getImplementation(originalMethod); - method_setImplementation(originalMethod, (IMP)sentry_motionEnded); + _originalSendEventIMP = method_getImplementation(originalMethod); + method_setImplementation(originalMethod, (IMP)sentry_sendEvent); _swizzled = YES; } } From a027843f83c01f3ad4905242a90f7bdee56114be Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Feb 2026 15:00:23 +0100 Subject: [PATCH 04/28] fix(ios): switch shake detection to UIWindow.motionEnded:withEvent: swizzle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIApplication.sendEvent: is not invoked by the iOS simulator for the simulated shake (Cmd+Ctrl+Z); it goes directly through UIWindow.motionEnded:withEvent: instead. React Native's dev menu swizzles UIWindow.motionEnded:withEvent: at bridge load time. Because we swizzle from startObserving (triggered by componentDidMount via NativeEventEmitter.addListener), our swizzle always runs after RN's — making sentry_motionEnded the outermost layer that calls through to RN's dev-menu handler via the stored original IMP. This approach works on both real devices and the iOS simulator. --- packages/core/ios/RNSentryShakeDetector.m | 37 ++++++++++++++--------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/core/ios/RNSentryShakeDetector.m b/packages/core/ios/RNSentryShakeDetector.m index 9bad32f950..6a51938326 100644 --- a/packages/core/ios/RNSentryShakeDetector.m +++ b/packages/core/ios/RNSentryShakeDetector.m @@ -8,19 +8,22 @@ NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected"; static BOOL _shakeDetectionEnabled = NO; -static IMP _originalSendEventIMP = NULL; +static IMP _originalMotionEndedIMP = NULL; static BOOL _swizzled = NO; static NSTimeInterval _lastShakeTimestamp = 0; static const NSTimeInterval SHAKE_COOLDOWN_SECONDS = 1.0; -// Intercepts all UIApplication events before they enter the responder chain. -// This ensures shake events are detected even when React Native's dev menu -// or another responder consumes the motion event without calling super. +// 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_sendEvent(UIApplication *self, SEL _cmd, UIEvent *event) +sentry_motionEnded(UIWindow *self, SEL _cmd, UIEventSubtype motion, UIEvent *event) { - if (_shakeDetectionEnabled && event.type == UIEventTypeMotion - && event.subtype == UIEventSubtypeMotionShake) { + if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) { NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; if (now - _lastShakeTimestamp > SHAKE_COOLDOWN_SECONDS) { _lastShakeTimestamp = now; @@ -30,8 +33,9 @@ } } - if (_originalSendEventIMP) { - ((void (*)(id, SEL, UIEvent *))_originalSendEventIMP)(self, _cmd, event); + if (_originalMotionEndedIMP) { + ((void (*)(id, SEL, UIEventSubtype, UIEvent *))_originalMotionEndedIMP)( + self, _cmd, motion, event); } } @@ -41,12 +45,17 @@ + (void)enable { @synchronized(self) { if (!_swizzled) { - // Use the actual class of the shared application to handle UIApplication subclasses - Class appClass = [[UIApplication sharedApplication] class]; - Method originalMethod = class_getInstanceMethod(appClass, @selector(sendEvent:)); + // 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) { - _originalSendEventIMP = method_getImplementation(originalMethod); - method_setImplementation(originalMethod, (IMP)sentry_sendEvent); + _originalMotionEndedIMP = method_getImplementation(originalMethod); + method_setImplementation(originalMethod, (IMP)sentry_motionEnded); _swizzled = YES; } } From 6054e19eee49ac4b2669a51e9c98845e1b71fe71 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Feb 2026 15:38:31 +0100 Subject: [PATCH 05/28] test(sample): add FeedbackWidgetProvider to React Native sample app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without FeedbackWidgetProvider rendered in the tree, componentDidMount never fires, startShakeListener is never called, and the native swizzle is never set up — so shake-to-report has no effect despite enableShakeToReport: true being configured on the integration. --- samples/react-native/src/App.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 806e80c44a..74fac010f6 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -10,6 +10,7 @@ import { import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createStackNavigator } from '@react-navigation/stack'; import * as Sentry from '@sentry/react-native'; +import { FeedbackWidgetProvider } from '@sentry/react-native'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; import { LogBox, Platform } from 'react-native'; import * as ImagePicker from 'react-native-image-picker'; @@ -259,10 +260,10 @@ function RootNavigationContainer() { function App() { return ( - <> + - + ); } From 6d13f6add8228c3d0f5751dab4de00c229958571 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Feb 2026 16:03:37 +0100 Subject: [PATCH 06/28] Revert "test(sample): add FeedbackWidgetProvider to React Native sample app" This reverts commit 6054e19eee49ac4b2669a51e9c98845e1b71fe71. --- samples/react-native/src/App.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 74fac010f6..806e80c44a 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -10,7 +10,6 @@ import { import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createStackNavigator } from '@react-navigation/stack'; import * as Sentry from '@sentry/react-native'; -import { FeedbackWidgetProvider } from '@sentry/react-native'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; import { LogBox, Platform } from 'react-native'; import * as ImagePicker from 'react-native-image-picker'; @@ -260,10 +259,10 @@ function RootNavigationContainer() { function App() { return ( - + <> - + ); } From bf89f61dd91cee855e2f850bef2a0dfd45571ddc Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Feb 2026 16:13:20 +0100 Subject: [PATCH 07/28] fix(ios): explicitly enable shake detection in addListener like Android Instead of relying on startObserving (which fires for any event type on the module's first listener), mirror the Android approach: override addListener and explicitly call [RNSentryShakeDetector enable] when the shake event is subscribed to. This ensures the UIWindow swizzle is set up reliably regardless of listener ordering or TurboModule event-emitter behaviour. --- packages/core/ios/RNSentry.mm | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index e33fb9fb5a..27b1b909e6 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -281,15 +281,27 @@ - (void)initFramesTracking #endif } +// Override addListener to explicitly enable shake detection when the shake event is +// subscribed to. This mirrors the Android addListener override and is more reliable +// than relying solely on startObserving, which only fires for the module's first +// listener regardless of event type. +- (void)addListener:(NSString *)eventName +{ + [super addListener:eventName]; + if ([eventName isEqualToString:RNSentryOnShakeEvent]) { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleShakeDetected) + name:RNSentryShakeDetectedNotification + object:nil]; + [RNSentryShakeDetector enable]; + hasListeners = YES; + } +} + // Will be called when this module's first listener is added. - (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. From 128bd3ed07a6ba8175420811f643f51080123952 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Feb 2026 16:20:00 +0100 Subject: [PATCH 08/28] debug(ios): add NSLog tracing to shake detection chain --- packages/core/ios/RNSentry.mm | 2 ++ packages/core/ios/RNSentryShakeDetector.m | 13 ++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 27b1b909e6..4bc0ed7487 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -289,6 +289,7 @@ - (void)addListener:(NSString *)eventName { [super addListener:eventName]; if ([eventName isEqualToString:RNSentryOnShakeEvent]) { + NSLog(@"[Sentry] addListener called for shake event, setting up detector"); [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleShakeDetected) name:RNSentryShakeDetectedNotification @@ -316,6 +317,7 @@ - (void)stopObserving - (void)handleShakeDetected { + NSLog(@"[Sentry] handleShakeDetected called, hasListeners=%d", hasListeners); if (hasListeners) { [self sendEventWithName:RNSentryOnShakeEvent body:@{}]; } diff --git a/packages/core/ios/RNSentryShakeDetector.m b/packages/core/ios/RNSentryShakeDetector.m index 6a51938326..9060985214 100644 --- a/packages/core/ios/RNSentryShakeDetector.m +++ b/packages/core/ios/RNSentryShakeDetector.m @@ -23,10 +23,13 @@ static void sentry_motionEnded(UIWindow *self, SEL _cmd, UIEventSubtype motion, UIEvent *event) { + NSLog(@"[Sentry] sentry_motionEnded called: enabled=%d motion=%ld shake=%ld", + _shakeDetectionEnabled, (long)motion, (long)UIEventSubtypeMotionShake); if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) { NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; if (now - _lastShakeTimestamp > SHAKE_COOLDOWN_SECONDS) { _lastShakeTimestamp = now; + NSLog(@"[Sentry] posting RNSentryShakeDetectedNotification"); [[NSNotificationCenter defaultCenter] postNotificationName:RNSentryShakeDetectedNotification object:nil]; @@ -44,22 +47,22 @@ @implementation RNSentryShakeDetector + (void)enable { @synchronized(self) { + NSLog(@"[Sentry] RNSentryShakeDetector enable called, swizzled=%d", _swizzled); 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:)); + NSLog( + @"[Sentry] motionEnded:withEvent: method found: %s", originalMethod ? "YES" : "NO"); if (originalMethod) { _originalMotionEndedIMP = method_getImplementation(originalMethod); method_setImplementation(originalMethod, (IMP)sentry_motionEnded); _swizzled = YES; + NSLog(@"[Sentry] UIWindow.motionEnded:withEvent: swizzled successfully"); } } _shakeDetectionEnabled = YES; + NSLog(@"[Sentry] shake detection enabled"); } } From 5cb90339a6e1eb12564235dc8fd72443f1f48910 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 2 Mar 2026 14:44:24 +0100 Subject: [PATCH 09/28] fix(ios): add @import Sentry so SENTRY_HAS_UIKIT is defined Without this import SentryDefines.h is never included, SENTRY_HAS_UIKIT evaluates to 0, and the entire shake detector implementation is compiled out leaving only the no-op stubs. All other files in the module that use SENTRY_HAS_UIKIT (RNSentryOnDrawReporter.m, RNSentryDependencyContainer.m, etc.) include @import Sentry for exactly this reason. --- packages/core/ios/RNSentryShakeDetector.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/ios/RNSentryShakeDetector.m b/packages/core/ios/RNSentryShakeDetector.m index 9060985214..60021c1a98 100644 --- a/packages/core/ios/RNSentryShakeDetector.m +++ b/packages/core/ios/RNSentryShakeDetector.m @@ -1,5 +1,7 @@ #import "RNSentryShakeDetector.h" +@import Sentry; + #if SENTRY_HAS_UIKIT # import From 023bab7c8c68197c63f38338b64d2fa4803a01c1 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 2 Mar 2026 14:57:28 +0100 Subject: [PATCH 10/28] fix(ios): use TARGET_OS_IOS instead of SENTRY_HAS_UIKIT @import Sentry caused a startup crash. Replace both the module import and SENTRY_HAS_UIKIT guard with TARGET_OS_IOS which has identical semantics for shake detection (iOS only) and needs no external import. --- packages/core/ios/RNSentryShakeDetector.m | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/ios/RNSentryShakeDetector.m b/packages/core/ios/RNSentryShakeDetector.m index 60021c1a98..bde8d7fac6 100644 --- a/packages/core/ios/RNSentryShakeDetector.m +++ b/packages/core/ios/RNSentryShakeDetector.m @@ -1,11 +1,9 @@ #import "RNSentryShakeDetector.h" -@import Sentry; +#import +#import -#if SENTRY_HAS_UIKIT - -# import -# import +#if TARGET_OS_IOS NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected"; From e844405c45cd130473545e5dd14a5ad4ce67bc6e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 10:55:23 +0100 Subject: [PATCH 11/28] fix(ios): use explicit enableShakeDetection method for iOS shake-to-report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS with New Architecture (TurboModules), NativeEventEmitter.addListener does not dispatch to native addListener:, so the UIWindow swizzle for shake detection was never enabled. Adds explicit enableShakeDetection/disableShakeDetection RCT_EXPORT_METHODs on iOS and no-op stubs on Android. JS startShakeListener now calls enableShakeDetection directly after subscribing to the event, bypassing the unreliable NativeEventEmitter → native dispatch path on iOS. Co-Authored-By: Claude Sonnet 4.6 --- .../io/sentry/react/RNSentryModuleImpl.java | 11 +++++ .../java/io/sentry/react/RNSentryModule.java | 10 +++++ .../java/io/sentry/react/RNSentryModule.java | 10 +++++ packages/core/ios/RNSentry.mm | 45 +++++++++---------- packages/core/ios/RNSentryShakeDetector.m | 10 +---- packages/core/src/js/NativeRNSentry.ts | 2 + .../core/src/js/feedback/ShakeToReportBug.ts | 8 ++++ 7 files changed, 64 insertions(+), 32 deletions(-) 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 f50b2ef158..68ab67a28a 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 @@ -242,6 +242,17 @@ private void stopShakeDetection() { } } + public void enableShakeDetection() { + // On Android, shake detection is started via addListener. This method is a no-op + // because it exists to satisfy the cross-platform spec (on iOS, the NativeEventEmitter + // addListener does not reliably dispatch to native, so an explicit call is needed). + } + + public void disableShakeDetection() { + // On Android, shake detection is stopped via removeListeners. This method is a no-op + // for the same reason as enableShakeDetection. + } + public void fetchModules(Promise promise) { final AssetManager assets = this.getReactApplicationContext().getResources().getAssets(); try (InputStream stream = new BufferedInputStream(assets.open(modulesPath))) { diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index b928d2d9c4..fe2a341844 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -212,4 +212,14 @@ public void popTimeToDisplayFor(String key, Promise promise) { public boolean setActiveSpanId(String spanId) { return this.impl.setActiveSpanId(spanId); } + + @Override + public void enableShakeDetection() { + this.impl.enableShakeDetection(); + } + + @Override + public void disableShakeDetection() { + this.impl.disableShakeDetection(); + } } diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 0488e143c9..499ef37f39 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -212,4 +212,14 @@ public void popTimeToDisplayFor(String key, Promise promise) { public boolean setActiveSpanId(String spanId) { return this.impl.setActiveSpanId(spanId); } + + @ReactMethod + public void enableShakeDetection() { + this.impl.enableShakeDetection(); + } + + @ReactMethod + public void disableShakeDetection() { + this.impl.disableShakeDetection(); + } } diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 4bc0ed7487..7b9eb38d31 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -281,24 +281,6 @@ - (void)initFramesTracking #endif } -// Override addListener to explicitly enable shake detection when the shake event is -// subscribed to. This mirrors the Android addListener override and is more reliable -// than relying solely on startObserving, which only fires for the module's first -// listener regardless of event type. -- (void)addListener:(NSString *)eventName -{ - [super addListener:eventName]; - if ([eventName isEqualToString:RNSentryOnShakeEvent]) { - NSLog(@"[Sentry] addListener called for shake event, setting up detector"); - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleShakeDetected) - name:RNSentryShakeDetectedNotification - object:nil]; - [RNSentryShakeDetector enable]; - hasListeners = YES; - } -} - // Will be called when this module's first listener is added. - (void)startObserving { @@ -309,20 +291,37 @@ - (void)startObserving - (void)stopObserving { hasListeners = NO; - [RNSentryShakeDetector disable]; - [[NSNotificationCenter defaultCenter] removeObserver:self - name:RNSentryShakeDetectedNotification - object:nil]; } - (void)handleShakeDetected { - NSLog(@"[Sentry] handleShakeDetected called, hasListeners=%d", hasListeners); if (hasListeners) { [self sendEventWithName:RNSentryOnShakeEvent body:@{}]; } } +// Explicit method to start shake detection. +// NativeEventEmitter.addListener does not reliably dispatch to native addListener: on iOS +// with New Architecture (TurboModules), so we expose explicit enable/disable methods +// that JS calls directly from startShakeListener/stopShakeListener. +RCT_EXPORT_METHOD(enableShakeDetection) +{ + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleShakeDetected) + name:RNSentryShakeDetectedNotification + object:nil]; + [RNSentryShakeDetector enable]; + hasListeners = YES; +} + +RCT_EXPORT_METHOD(disableShakeDetection) +{ + [RNSentryShakeDetector disable]; + [[NSNotificationCenter defaultCenter] removeObserver:self + name:RNSentryShakeDetectedNotification + object:nil]; +} + - (NSArray *)supportedEvents { return @[ RNSentryNewFrameEvent, RNSentryOnShakeEvent ]; diff --git a/packages/core/ios/RNSentryShakeDetector.m b/packages/core/ios/RNSentryShakeDetector.m index bde8d7fac6..da54dc4bd3 100644 --- a/packages/core/ios/RNSentryShakeDetector.m +++ b/packages/core/ios/RNSentryShakeDetector.m @@ -17,19 +17,16 @@ // // 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 +// via RCTSwapInstanceMethods. Because we swizzle from enableShakeDetection (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) { - NSLog(@"[Sentry] sentry_motionEnded called: enabled=%d motion=%ld shake=%ld", - _shakeDetectionEnabled, (long)motion, (long)UIEventSubtypeMotionShake); if (_shakeDetectionEnabled && motion == UIEventSubtypeMotionShake) { NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; if (now - _lastShakeTimestamp > SHAKE_COOLDOWN_SECONDS) { _lastShakeTimestamp = now; - NSLog(@"[Sentry] posting RNSentryShakeDetectedNotification"); [[NSNotificationCenter defaultCenter] postNotificationName:RNSentryShakeDetectedNotification object:nil]; @@ -47,22 +44,17 @@ @implementation RNSentryShakeDetector + (void)enable { @synchronized(self) { - NSLog(@"[Sentry] RNSentryShakeDetector enable called, swizzled=%d", _swizzled); if (!_swizzled) { Class windowClass = [UIWindow class]; Method originalMethod = class_getInstanceMethod(windowClass, @selector(motionEnded:withEvent:)); - NSLog( - @"[Sentry] motionEnded:withEvent: method found: %s", originalMethod ? "YES" : "NO"); if (originalMethod) { _originalMotionEndedIMP = method_getImplementation(originalMethod); method_setImplementation(originalMethod, (IMP)sentry_motionEnded); _swizzled = YES; - NSLog(@"[Sentry] UIWindow.motionEnded:withEvent: swizzled successfully"); } } _shakeDetectionEnabled = YES; - NSLog(@"[Sentry] shake detection enabled"); } } diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index afd8fba03d..ee753e71f0 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -54,6 +54,8 @@ export interface Spec extends TurboModule { popTimeToDisplayFor(key: string): Promise; setActiveSpanId(spanId: string): boolean; encodeToBase64(data: number[]): Promise; + enableShakeDetection(): void; + disableShakeDetection(): void; } export type NativeStackFrame = { diff --git a/packages/core/src/js/feedback/ShakeToReportBug.ts b/packages/core/src/js/feedback/ShakeToReportBug.ts index a984a9a0e3..0ad1a2b699 100644 --- a/packages/core/src/js/feedback/ShakeToReportBug.ts +++ b/packages/core/src/js/feedback/ShakeToReportBug.ts @@ -45,6 +45,11 @@ export function startShakeListener(onShake: () => void, createEmitter: EmitterFa debug.log('Shake detected.'); onShake(); }); + + // Explicitly enable native shake detection. On iOS with New Architecture (TurboModules), + // NativeEventEmitter.addListener does not dispatch to native addListener:, so the + // native shake listener would never start without this explicit call. + (nativeModule as { enableShakeDetection?: () => void }).enableShakeDetection?.(); } /** @@ -54,6 +59,9 @@ export function stopShakeListener(): void { if (_shakeSubscription) { _shakeSubscription.remove(); _shakeSubscription = null; + + const nativeModule = getRNSentryModule() as { disableShakeDetection?: () => void } | undefined; + nativeModule?.disableShakeDetection?.(); } } From d725799dcf8d9a07a95bfb29df5129f4689cb306 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 15:17:38 +0100 Subject: [PATCH 12/28] fix(ios): fix shake detection crash and swizzle safety UIWindow inherits motionEnded:withEvent: from UIResponder and may not have its own implementation. Using method_setImplementation directly on the inherited Method would modify UIResponder, affecting all subclasses and causing a doesNotRecognizeSelector crash. Fix by calling class_addMethod first to ensure UIWindow has its own method before replacing the IMP. Also prevent duplicate NSNotification observers on component remount, and clean up debug logging. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/ios/RNSentry.mm | 4 ++ packages/core/ios/RNSentryShakeDetector.m | 42 ++++++++++++------- .../core/src/js/feedback/ShakeToReportBug.ts | 9 ++-- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 7b9eb38d31..f5928bb9bb 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -306,6 +306,10 @@ - (void)handleShakeDetected // that JS calls directly from startShakeListener/stopShakeListener. RCT_EXPORT_METHOD(enableShakeDetection) { + // Remove any existing observer first to avoid duplicate notifications + [[NSNotificationCenter defaultCenter] removeObserver:self + name:RNSentryShakeDetectedNotification + object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleShakeDetected) name:RNSentryShakeDetectedNotification diff --git a/packages/core/ios/RNSentryShakeDetector.m b/packages/core/ios/RNSentryShakeDetector.m index da54dc4bd3..77cdf44342 100644 --- a/packages/core/ios/RNSentryShakeDetector.m +++ b/packages/core/ios/RNSentryShakeDetector.m @@ -8,18 +8,15 @@ NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected"; static BOOL _shakeDetectionEnabled = NO; -static IMP _originalMotionEndedIMP = NULL; static BOOL _swizzled = NO; +static IMP _originalMotionEndedIMP = NULL; 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 enableShakeDetection (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. +// C function that replaces UIWindow's motionEnded:withEvent: IMP. +// Uses method_setImplementation to install itself and saves the original IMP +// to call afterwards, preserving the responder chain and composing with other +// swizzles (e.g. RCTDevMenu in debug builds). static void sentry_motionEnded(UIWindow *self, SEL _cmd, UIEventSubtype motion, UIEvent *event) { @@ -46,13 +43,30 @@ + (void)enable @synchronized(self) { if (!_swizzled) { 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; + SEL sel = @selector(motionEnded:withEvent:); + + // UIWindow may not have its own motionEnded:withEvent: — it can inherit from + // UIResponder. We must ensure the method exists directly on UIWindow before + // replacing its IMP, otherwise the inherited method on UIResponder would be + // modified, affecting all UIResponder subclasses. + Method inheritedMethod = class_getInstanceMethod(windowClass, sel); + if (!inheritedMethod) { + return; } + + // class_addMethod only succeeds if UIWindow does NOT already have its own + // implementation of motionEnded:withEvent:. In that case, we add a direct + // implementation to UIWindow that just calls super (the inherited IMP). + IMP inheritedIMP = method_getImplementation(inheritedMethod); + const char *types = method_getTypeEncoding(inheritedMethod); + class_addMethod(windowClass, sel, inheritedIMP, types); + + // Now UIWindow definitely has its own motionEnded:withEvent:. Get its Method + // (which may be the one we just added, or a pre-existing one from e.g. RCTDevMenu) + // and replace the IMP with our interceptor. + Method ownMethod = class_getInstanceMethod(windowClass, sel); + _originalMotionEndedIMP = method_setImplementation(ownMethod, (IMP)sentry_motionEnded); + _swizzled = YES; } _shakeDetectionEnabled = YES; } diff --git a/packages/core/src/js/feedback/ShakeToReportBug.ts b/packages/core/src/js/feedback/ShakeToReportBug.ts index 0ad1a2b699..d263d54f58 100644 --- a/packages/core/src/js/feedback/ShakeToReportBug.ts +++ b/packages/core/src/js/feedback/ShakeToReportBug.ts @@ -25,7 +25,6 @@ const defaultEmitterFactory: EmitterFactory = nativeModule => new NativeEventEmi */ export function startShakeListener(onShake: () => void, createEmitter: EmitterFactory = defaultEmitterFactory): void { if (_shakeSubscription) { - debug.log('Shake listener is already active.'); return; } @@ -42,14 +41,18 @@ export function startShakeListener(onShake: () => void, createEmitter: EmitterFa const emitter = createEmitter(nativeModule); _shakeSubscription = emitter.addListener(OnShakeEventName, () => { - debug.log('Shake detected.'); onShake(); }); // Explicitly enable native shake detection. On iOS with New Architecture (TurboModules), // NativeEventEmitter.addListener does not dispatch to native addListener:, so the // native shake listener would never start without this explicit call. - (nativeModule as { enableShakeDetection?: () => void }).enableShakeDetection?.(); + const module = nativeModule as { enableShakeDetection?: () => void }; + if (module.enableShakeDetection) { + module.enableShakeDetection(); + } else { + debug.warn('enableShakeDetection is not available on the native module.'); + } } /** From 27867aaf6f1ad930fc68cb802f6233ba968a284a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 17:55:43 +0100 Subject: [PATCH 13/28] refactor: replace RNSentryShakeDetector with SentryShakeDetector from native SDKs Remove the RN-specific shake detector implementations and delegate to SentryShakeDetector (iOS: sentry-cocoa, Android: sentry-android-core) so the implementation is shared with other SDKs that have feedback UI. Co-Authored-By: Claude Sonnet 4.6 --- .../RNSentryCocoaTesterTests/RNSentryTests.m | 8 +- .../RNSentryUserTests.m | 18 +-- .../io/sentry/react/RNSentryModuleImpl.java | 5 +- .../sentry/react/RNSentryShakeDetector.java | 92 -------------- packages/core/ios/RNSentry.mm | 14 +-- packages/core/ios/RNSentryReplay.mm | 2 +- packages/core/ios/RNSentrySDK.m | 2 +- packages/core/ios/RNSentryShakeDetector.h | 22 ---- packages/core/ios/RNSentryShakeDetector.m | 112 ------------------ .../AppDelegate.mm | 2 +- .../sentryreactnativesample/AppDelegate.mm | 2 +- 11 files changed, 27 insertions(+), 252 deletions(-) delete mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryShakeDetector.java delete mode 100644 packages/core/ios/RNSentryShakeDetector.h delete mode 100644 packages/core/ios/RNSentryShakeDetector.m diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m index e8c04115ba..76c41d3c66 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m @@ -1014,7 +1014,7 @@ - (void)testCreateUserWithPartialGeoDataCreatesSentryGeoObject NSDictionary *userKeys = @{ @"id" : @"456", @"geo" : @ { @"city" : @"New York", @"country_code" : @"US" } }; - NSDictionary *userDataKeys = @{}; + NSDictionary *userDataKeys = @{ }; SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; @@ -1031,9 +1031,9 @@ - (void)testCreateUserWithPartialGeoDataCreatesSentryGeoObject - (void)testCreateUserWithEmptyGeoDataCreatesSentryGeoObject { - NSDictionary *userKeys = @{ @"id" : @"789", @"geo" : @ {} }; + NSDictionary *userKeys = @{ @"id" : @"789", @"geo" : @ { } }; - NSDictionary *userDataKeys = @{}; + NSDictionary *userDataKeys = @{ }; SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; @@ -1052,7 +1052,7 @@ - (void)testCreateUserWithoutGeoDataDoesNotCreateGeoObject { NSDictionary *userKeys = @{ @"id" : @"999", @"email" : @"test@example.com" }; - NSDictionary *userDataKeys = @{}; + NSDictionary *userDataKeys = @{ }; SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m index 542904cbb5..9c603940a3 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m @@ -51,9 +51,9 @@ - (void)testNullUser - (void)testEmptyUser { SentryUser *expected = [[SentryUser alloc] init]; - [expected setData:@{}]; + [expected setData:@{ }]; - SentryUser *actual = [RNSentry userFrom:@{} otherUserKeys:@{}]; + SentryUser *actual = [RNSentry userFrom:@{ } otherUserKeys:@{ }]; XCTAssertTrue([actual isEqualToUser:expected]); } @@ -63,9 +63,9 @@ - (void)testInvalidUser SentryUser *actual = [RNSentry userFrom:@{ @"id" : @123, - @"ip_address" : @ {}, - @"email" : @ {}, - @"username" : @ {}, + @"ip_address" : @ { }, + @"email" : @ { }, + @"username" : @ { }, } otherUserKeys:nil]; @@ -79,9 +79,9 @@ - (void)testPartiallyInvalidUser SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", - @"ip_address" : @ {}, - @"email" : @ {}, - @"username" : @ {}, + @"ip_address" : @ { }, + @"email" : @ { }, + @"username" : @ { }, } otherUserKeys:nil]; @@ -156,7 +156,7 @@ - (void)testUserWithEmptyGeo SentryGeo *expectedGeo = [SentryGeo alloc]; [expected setGeo:expectedGeo]; - SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", @"geo" : @ {} } otherUserKeys:nil]; + SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", @"geo" : @ { } } otherUserKeys:nil]; XCTAssertTrue([actual isEqualToUser:expected]); } 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 68ab67a28a..94ce2dc832 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 @@ -46,6 +46,7 @@ import io.sentry.android.core.InternalSentrySdk; import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.SentryShakeDetector; import io.sentry.android.core.ViewHierarchyEventProcessor; import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; @@ -123,7 +124,7 @@ public class RNSentryModuleImpl { private final @NotNull Runnable emitNewFrameEvent; private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake"; - private @Nullable RNSentryShakeDetector shakeDetector; + private @Nullable SentryShakeDetector shakeDetector; private int shakeListenerCount = 0; /** Max trace file size in bytes. */ @@ -221,7 +222,7 @@ private void startShakeDetection() { } final ReactApplicationContext context = getReactApplicationContext(); - shakeDetector = new RNSentryShakeDetector(logger); + shakeDetector = new SentryShakeDetector(logger); shakeDetector.start( context, () -> { 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 deleted file mode 100644 index 0270bf07b2..0000000000 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryShakeDetector.java +++ /dev/null @@ -1,92 +0,0 @@ -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 f5928bb9bb..42f5f7451d 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -39,7 +39,7 @@ #import "RNSentryDependencyContainer.h" #import "RNSentryEvents.h" -#import "RNSentryShakeDetector.h" +#import #if SENTRY_TARGET_REPLAY_SUPPORTED # import "RNSentryReplay.h" @@ -296,7 +296,7 @@ - (void)stopObserving - (void)handleShakeDetected { if (hasListeners) { - [self sendEventWithName:RNSentryOnShakeEvent body:@{}]; + [self sendEventWithName:RNSentryOnShakeEvent body:@{ }]; } } @@ -308,21 +308,21 @@ - (void)handleShakeDetected { // Remove any existing observer first to avoid duplicate notifications [[NSNotificationCenter defaultCenter] removeObserver:self - name:RNSentryShakeDetectedNotification + name:SentryShakeDetectedNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleShakeDetected) - name:RNSentryShakeDetectedNotification + name:SentryShakeDetectedNotification object:nil]; - [RNSentryShakeDetector enable]; + [SentryShakeDetector enable]; hasListeners = YES; } RCT_EXPORT_METHOD(disableShakeDetection) { - [RNSentryShakeDetector disable]; + [SentryShakeDetector disable]; [[NSNotificationCenter defaultCenter] removeObserver:self - name:RNSentryShakeDetectedNotification + name:SentryShakeDetectedNotification object:nil]; } diff --git a/packages/core/ios/RNSentryReplay.mm b/packages/core/ios/RNSentryReplay.mm index 40575a9e4c..47bac6cdf5 100644 --- a/packages/core/ios/RNSentryReplay.mm +++ b/packages/core/ios/RNSentryReplay.mm @@ -23,7 +23,7 @@ + (BOOL)updateOptions:(NSMutableDictionary *)options } NSLog(@"Setting up session replay"); - NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{}; + NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{ }; NSString *qualityString = options[@"replaysSessionQuality"]; diff --git a/packages/core/ios/RNSentrySDK.m b/packages/core/ios/RNSentrySDK.m index 705b706de8..0f38cf6c7b 100644 --- a/packages/core/ios/RNSentrySDK.m +++ b/packages/core/ios/RNSentrySDK.m @@ -60,7 +60,7 @@ + (void)start:(NSString *)path configureOptions:(void (^)(SentryOptions *options if (options == nil) { // Fallback in case that options file could not be parsed. NSError *fallbackError = nil; - options = [PrivateSentrySDKOnly optionsWithDictionary:@{} didFailWithError:&fallbackError]; + options = [PrivateSentrySDKOnly optionsWithDictionary:@{ } didFailWithError:&fallbackError]; if (fallbackError != nil) { NSLog(@"[RNSentry] Failed to create fallback options with error: %@", fallbackError.localizedDescription); diff --git a/packages/core/ios/RNSentryShakeDetector.h b/packages/core/ios/RNSentryShakeDetector.h deleted file mode 100644 index 00195cab0c..0000000000 --- a/packages/core/ios/RNSentryShakeDetector.h +++ /dev/null @@ -1,22 +0,0 @@ -#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 deleted file mode 100644 index 77cdf44342..0000000000 --- a/packages/core/ios/RNSentryShakeDetector.m +++ /dev/null @@ -1,112 +0,0 @@ -#import "RNSentryShakeDetector.h" - -#import -#import - -#if TARGET_OS_IOS - -NSNotificationName const RNSentryShakeDetectedNotification = @"RNSentryShakeDetected"; - -static BOOL _shakeDetectionEnabled = NO; -static BOOL _swizzled = NO; -static IMP _originalMotionEndedIMP = NULL; -static NSTimeInterval _lastShakeTimestamp = 0; -static const NSTimeInterval SHAKE_COOLDOWN_SECONDS = 1.0; - -// C function that replaces UIWindow's motionEnded:withEvent: IMP. -// Uses method_setImplementation to install itself and saves the original IMP -// to call afterwards, preserving the responder chain and composing with other -// swizzles (e.g. RCTDevMenu in debug builds). -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) { - Class windowClass = [UIWindow class]; - SEL sel = @selector(motionEnded:withEvent:); - - // UIWindow may not have its own motionEnded:withEvent: — it can inherit from - // UIResponder. We must ensure the method exists directly on UIWindow before - // replacing its IMP, otherwise the inherited method on UIResponder would be - // modified, affecting all UIResponder subclasses. - Method inheritedMethod = class_getInstanceMethod(windowClass, sel); - if (!inheritedMethod) { - return; - } - - // class_addMethod only succeeds if UIWindow does NOT already have its own - // implementation of motionEnded:withEvent:. In that case, we add a direct - // implementation to UIWindow that just calls super (the inherited IMP). - IMP inheritedIMP = method_getImplementation(inheritedMethod); - const char *types = method_getTypeEncoding(inheritedMethod); - class_addMethod(windowClass, sel, inheritedIMP, types); - - // Now UIWindow definitely has its own motionEnded:withEvent:. Get its Method - // (which may be the one we just added, or a pre-existing one from e.g. RCTDevMenu) - // and replace the IMP with our interceptor. - Method ownMethod = class_getInstanceMethod(windowClass, sel); - _originalMotionEndedIMP = method_setImplementation(ownMethod, (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/samples/react-native-macos/macos/sentry-react-native-sample-macOS/AppDelegate.mm b/samples/react-native-macos/macos/sentry-react-native-sample-macOS/AppDelegate.mm index 3cb5dff1a5..75b9d1c7b7 100644 --- a/samples/react-native-macos/macos/sentry-react-native-sample-macOS/AppDelegate.mm +++ b/samples/react-native-macos/macos/sentry-react-native-sample-macOS/AppDelegate.mm @@ -9,7 +9,7 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification self.moduleName = @"sentry-react-native-sample"; // You can add your custom initial props in the dictionary below. // They will be passed down to the ViewController used by React Native. - self.initialProps = @{}; + self.initialProps = @{ }; return [super applicationDidFinishLaunching:notification]; } diff --git a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm index d08d16acdd..616456ca75 100644 --- a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm +++ b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm @@ -47,7 +47,7 @@ - (BOOL)application:(UIApplication *)application [self.reactNativeFactory startReactNativeWithModuleName:@"sentry-react-native-sample" inWindow:self.window - initialProperties:@{} + initialProperties:@{ } launchOptions:launchOptions]; [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self]; From 725ceaf74f862cd2a742348447215ed29a88f44d Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 5 Mar 2026 14:51:42 +0100 Subject: [PATCH 14/28] fix(ios): adapt to SentryShakeDetector refactored to Swift in sentry-cocoa SentryShakeDetector was rewritten from ObjC to Swift in sentry-cocoa, removing the public ObjC header. Since RNSentry.mm cannot use @import Sentry, we now use NSClassFromString + performSelector to call enable/disable and the raw notification string "SentryShakeDetected". Co-Authored-By: Claude Opus 4.6 --- packages/core/ios/RNSentry.mm | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 30e5b085bb..96a15d56f1 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -39,7 +39,6 @@ #import "RNSentryDependencyContainer.h" #import "RNSentryEvents.h" -#import #import "RNSentryNativeLogsForwarder.h" #if SENTRY_TARGET_REPLAY_SUPPORTED @@ -303,29 +302,40 @@ - (void)handleShakeDetected } } -// Explicit method to start shake detection. -// NativeEventEmitter.addListener does not reliably dispatch to native addListener: on iOS -// with New Architecture (TurboModules), so we expose explicit enable/disable methods -// that JS calls directly from startShakeListener/stopShakeListener. +// SentryShakeDetector is a Swift class; its notification name and methods are accessed +// via the raw string / NSClassFromString to avoid requiring @import Sentry in this .mm file. +static NSNotificationName const RNSentryShakeNotification = @"SentryShakeDetected"; + RCT_EXPORT_METHOD(enableShakeDetection) { - // Remove any existing observer first to avoid duplicate notifications [[NSNotificationCenter defaultCenter] removeObserver:self - name:SentryShakeDetectedNotification + name:RNSentryShakeNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleShakeDetected) - name:SentryShakeDetectedNotification + name:RNSentryShakeNotification object:nil]; - [SentryShakeDetector enable]; + Class shakeDetector = NSClassFromString(@"SentryShakeDetector"); + if (shakeDetector) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [shakeDetector performSelector:@selector(enable)]; +#pragma clang diagnostic pop + } hasListeners = YES; } RCT_EXPORT_METHOD(disableShakeDetection) { - [SentryShakeDetector disable]; + Class shakeDetector = NSClassFromString(@"SentryShakeDetector"); + if (shakeDetector) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [shakeDetector performSelector:@selector(disable)]; +#pragma clang diagnostic pop + } [[NSNotificationCenter defaultCenter] removeObserver:self - name:SentryShakeDetectedNotification + name:RNSentryShakeNotification object:nil]; } From 8c44ee544c04074a7446c43bd10ac2d9fdcfbc08 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 09:32:13 +0100 Subject: [PATCH 15/28] Reverse unneeded objc changes --- .../RNSentryCocoaTesterTests/RNSentryTests.m | 8 ++++---- .../RNSentryUserTests.m | 18 +++++++++--------- packages/core/ios/RNSentryReplay.mm | 2 +- packages/core/ios/RNSentrySDK.m | 2 +- .../AppDelegate.mm | 2 +- .../ios/sentryreactnativesample/AppDelegate.mm | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m index 76c41d3c66..e8c04115ba 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m @@ -1014,7 +1014,7 @@ - (void)testCreateUserWithPartialGeoDataCreatesSentryGeoObject NSDictionary *userKeys = @{ @"id" : @"456", @"geo" : @ { @"city" : @"New York", @"country_code" : @"US" } }; - NSDictionary *userDataKeys = @{ }; + NSDictionary *userDataKeys = @{}; SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; @@ -1031,9 +1031,9 @@ - (void)testCreateUserWithPartialGeoDataCreatesSentryGeoObject - (void)testCreateUserWithEmptyGeoDataCreatesSentryGeoObject { - NSDictionary *userKeys = @{ @"id" : @"789", @"geo" : @ { } }; + NSDictionary *userKeys = @{ @"id" : @"789", @"geo" : @ {} }; - NSDictionary *userDataKeys = @{ }; + NSDictionary *userDataKeys = @{}; SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; @@ -1052,7 +1052,7 @@ - (void)testCreateUserWithoutGeoDataDoesNotCreateGeoObject { NSDictionary *userKeys = @{ @"id" : @"999", @"email" : @"test@example.com" }; - NSDictionary *userDataKeys = @{ }; + NSDictionary *userDataKeys = @{}; SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m index 9c603940a3..542904cbb5 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m @@ -51,9 +51,9 @@ - (void)testNullUser - (void)testEmptyUser { SentryUser *expected = [[SentryUser alloc] init]; - [expected setData:@{ }]; + [expected setData:@{}]; - SentryUser *actual = [RNSentry userFrom:@{ } otherUserKeys:@{ }]; + SentryUser *actual = [RNSentry userFrom:@{} otherUserKeys:@{}]; XCTAssertTrue([actual isEqualToUser:expected]); } @@ -63,9 +63,9 @@ - (void)testInvalidUser SentryUser *actual = [RNSentry userFrom:@{ @"id" : @123, - @"ip_address" : @ { }, - @"email" : @ { }, - @"username" : @ { }, + @"ip_address" : @ {}, + @"email" : @ {}, + @"username" : @ {}, } otherUserKeys:nil]; @@ -79,9 +79,9 @@ - (void)testPartiallyInvalidUser SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", - @"ip_address" : @ { }, - @"email" : @ { }, - @"username" : @ { }, + @"ip_address" : @ {}, + @"email" : @ {}, + @"username" : @ {}, } otherUserKeys:nil]; @@ -156,7 +156,7 @@ - (void)testUserWithEmptyGeo SentryGeo *expectedGeo = [SentryGeo alloc]; [expected setGeo:expectedGeo]; - SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", @"geo" : @ { } } otherUserKeys:nil]; + SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", @"geo" : @ {} } otherUserKeys:nil]; XCTAssertTrue([actual isEqualToUser:expected]); } diff --git a/packages/core/ios/RNSentryReplay.mm b/packages/core/ios/RNSentryReplay.mm index 47bac6cdf5..40575a9e4c 100644 --- a/packages/core/ios/RNSentryReplay.mm +++ b/packages/core/ios/RNSentryReplay.mm @@ -23,7 +23,7 @@ + (BOOL)updateOptions:(NSMutableDictionary *)options } NSLog(@"Setting up session replay"); - NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{ }; + NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{}; NSString *qualityString = options[@"replaysSessionQuality"]; diff --git a/packages/core/ios/RNSentrySDK.m b/packages/core/ios/RNSentrySDK.m index 0f38cf6c7b..705b706de8 100644 --- a/packages/core/ios/RNSentrySDK.m +++ b/packages/core/ios/RNSentrySDK.m @@ -60,7 +60,7 @@ + (void)start:(NSString *)path configureOptions:(void (^)(SentryOptions *options if (options == nil) { // Fallback in case that options file could not be parsed. NSError *fallbackError = nil; - options = [PrivateSentrySDKOnly optionsWithDictionary:@{ } didFailWithError:&fallbackError]; + options = [PrivateSentrySDKOnly optionsWithDictionary:@{} didFailWithError:&fallbackError]; if (fallbackError != nil) { NSLog(@"[RNSentry] Failed to create fallback options with error: %@", fallbackError.localizedDescription); diff --git a/samples/react-native-macos/macos/sentry-react-native-sample-macOS/AppDelegate.mm b/samples/react-native-macos/macos/sentry-react-native-sample-macOS/AppDelegate.mm index 75b9d1c7b7..3cb5dff1a5 100644 --- a/samples/react-native-macos/macos/sentry-react-native-sample-macOS/AppDelegate.mm +++ b/samples/react-native-macos/macos/sentry-react-native-sample-macOS/AppDelegate.mm @@ -9,7 +9,7 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification self.moduleName = @"sentry-react-native-sample"; // You can add your custom initial props in the dictionary below. // They will be passed down to the ViewController used by React Native. - self.initialProps = @{ }; + self.initialProps = @{}; return [super applicationDidFinishLaunching:notification]; } diff --git a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm index 616456ca75..d08d16acdd 100644 --- a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm +++ b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm @@ -47,7 +47,7 @@ - (BOOL)application:(UIApplication *)application [self.reactNativeFactory startReactNativeWithModuleName:@"sentry-react-native-sample" inWindow:self.window - initialProperties:@{ } + initialProperties:@{} launchOptions:launchOptions]; [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self]; From fcdc83c15a12fdf81528e07d8e0c9ec264b5d031 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 09:46:38 +0100 Subject: [PATCH 16/28] Reverse obj change --- packages/core/ios/RNSentry.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 96a15d56f1..d08cd987d9 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -298,7 +298,7 @@ - (void)stopObserving - (void)handleShakeDetected { if (hasListeners) { - [self sendEventWithName:RNSentryOnShakeEvent body:@{ }]; + [self sendEventWithName:RNSentryOnShakeEvent body:@{}]; } } From cfcb5c7b812b9d45db7a9c31b4908d91f9b1cdba Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 10:05:56 +0100 Subject: [PATCH 17/28] Rename methods for clarity --- CHANGELOG.md | 2 +- packages/core/src/js/feedback/FeedbackWidgetManager.tsx | 6 +++--- packages/core/src/js/index.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d76c560042..f33622291a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs - Enabled by default in Expo apps (requires `expo-updates` to be installed) - 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 })` + - Use `Sentry.enableFeedbackOnShake()` / `Sentry.disableFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })` ### Fixes diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx index 56de6861c9..6ac10721fb 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -133,13 +133,13 @@ const resetScreenshotButtonManager = (): void => { ScreenshotButtonManager.reset(); }; -const showFeedbackOnShake = (): void => { +const enableFeedbackOnShake = (): void => { lazyLoadAutoInjectFeedbackIntegration(); startShakeListener(showFeedbackWidget); }; -const hideFeedbackOnShake = (): void => { +const disableFeedbackOnShake = (): void => { stopShakeListener(); }; -export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showFeedbackOnShake, hideFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager }; +export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, enableFeedbackOnShake, disableFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager }; diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index b486a50353..e83e8d6d54 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -107,8 +107,8 @@ export { showFeedbackWidget, showFeedbackButton, hideFeedbackButton, - showFeedbackOnShake, - hideFeedbackOnShake, + enableFeedbackOnShake, + disableFeedbackOnShake, } from './feedback/FeedbackWidgetManager'; export { getDataFromUri } from './wrapper'; From 33a59185b414dbee8fccf0bf6cd64ea5df25d133 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 10:38:14 +0100 Subject: [PATCH 18/28] fix(android,ios): Fix shake detection lifecycle bugs - iOS: Remove manual `hasListeners = YES` from `enableShakeDetection`; the flag is managed by `startObserving`/`stopObserving` exclusively. Setting it manually could cause spurious events when no JS listeners are registered. - Android: `removeListeners` has no event-type context so it incorrectly decremented the shake counter on any listener removal (e.g. frame/log events), prematurely stopping shake detection. Move shake detection management to `enableShakeDetection`/`disableShakeDetection` which are explicitly called from JS and carry clear intent. Co-Authored-By: Claude Sonnet 4.6 --- .../io/sentry/react/RNSentryModuleImpl.java | 22 +++++-------------- packages/core/ios/RNSentry.mm | 1 - 2 files changed, 5 insertions(+), 18 deletions(-) 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 58022a4303..802f84cea0 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 @@ -125,7 +125,6 @@ public class RNSentryModuleImpl { private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake"; private @Nullable SentryShakeDetector shakeDetector; - private int shakeListenerCount = 0; /** Max trace file size in bytes. */ private long maxTraceFileSize = 5 * 1024 * 1024; @@ -207,23 +206,15 @@ 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) { - shakeListenerCount = Math.max(0, shakeListenerCount - (int) id); - if (shakeListenerCount == 0) { - stopShakeDetection(); - } + // removeListeners does not carry event-type information, so it cannot be used + // to track shake listeners selectively. Shake detection is managed exclusively + // via enableShakeDetection / disableShakeDetection. } private void startShakeDetection() { @@ -254,14 +245,11 @@ private void stopShakeDetection() { } public void enableShakeDetection() { - // On Android, shake detection is started via addListener. This method is a no-op - // because it exists to satisfy the cross-platform spec (on iOS, the NativeEventEmitter - // addListener does not reliably dispatch to native, so an explicit call is needed). + startShakeDetection(); } public void disableShakeDetection() { - // On Android, shake detection is stopped via removeListeners. This method is a no-op - // for the same reason as enableShakeDetection. + stopShakeDetection(); } public void fetchModules(Promise promise) { diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index d08cd987d9..e234dc7e38 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -322,7 +322,6 @@ - (void)handleShakeDetected [shakeDetector performSelector:@selector(enable)]; #pragma clang diagnostic pop } - hasListeners = YES; } RCT_EXPORT_METHOD(disableShakeDetection) From a7b836f18a89162cf2e40a9b18c86ca2e7039f34 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 10:42:30 +0100 Subject: [PATCH 19/28] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f33622291a..7fbd19d574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - Add `expoUpdatesListenerIntegration` that records breadcrumbs for Expo Updates lifecycle events ([#5795](https://github.com/getsentry/sentry-react-native/pull/5795)) - Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs - Enabled by default in Expo apps (requires `expo-updates` to be installed) -- Show feedback widget on device shake ([#5729](https://github.com/getsentry/sentry-react-native/pull/5729)) +- Show feedback widget on device shake ([#5754](https://github.com/getsentry/sentry-react-native/pull/5754)) - Use `Sentry.enableFeedbackOnShake()` / `Sentry.disableFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })` ### Fixes From 259687ad5cae1ea64670745daf6fe7a2f1252138 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 10:53:24 +0100 Subject: [PATCH 20/28] fix(ios): Use dedicated flag for shake event emission Replace `hasListeners` guard in `handleShakeDetected` with a dedicated `_shakeDetectionEnabled` ivar. `hasListeners` is managed by `startObserving`/`stopObserving`, which may not be called on New Architecture (TurboModules) when `NativeEventEmitter.addListener` is used. Using a dedicated flag ensures shake events are emitted whenever shake detection is explicitly enabled from JS, regardless of the `NativeEventEmitter` lifecycle. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/ios/RNSentry.mm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index e234dc7e38..6cb2c522fb 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -60,6 +60,7 @@ @implementation RNSentry { bool hasListeners; + bool _shakeDetectionEnabled; RNSentryTimeToDisplay *_timeToDisplay; NSArray *_ignoreErrorPatternsStr; NSArray *_ignoreErrorPatternsRegex; @@ -297,7 +298,7 @@ - (void)stopObserving - (void)handleShakeDetected { - if (hasListeners) { + if (_shakeDetectionEnabled) { [self sendEventWithName:RNSentryOnShakeEvent body:@{}]; } } @@ -322,10 +323,12 @@ - (void)handleShakeDetected [shakeDetector performSelector:@selector(enable)]; #pragma clang diagnostic pop } + _shakeDetectionEnabled = YES; } RCT_EXPORT_METHOD(disableShakeDetection) { + _shakeDetectionEnabled = NO; Class shakeDetector = NSClassFromString(@"SentryShakeDetector"); if (shakeDetector) { #pragma clang diagnostic push From 6a1db5e62c9715525042f3d94355588eca5bdb76 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 11:14:28 +0100 Subject: [PATCH 21/28] fix(feedback): Guard against shake listener crashes and asymmetric lifecycle - Wrap native bridge calls in `startShakeListener` with try/catch so exceptions from the native module never crash the host app. - Track whether `FeedbackWidgetProvider` started shake detection and only stop it on unmount if this component was the one that started it, preventing imperatively-started shake listeners from being silently killed on remount. Co-Authored-By: Claude Sonnet 4.6 --- .../js/feedback/FeedbackWidgetProvider.tsx | 6 +++- .../core/src/js/feedback/ShakeToReportBug.ts | 29 +++++++++++-------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx index 7ab2a08856..f82a6b39da 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx @@ -53,6 +53,7 @@ export class FeedbackWidgetProvider extends React.Component { @@ -103,6 +104,7 @@ export class FeedbackWidgetProvider extends React.Component void, createEmitter: EmitterFa return; } - const emitter = createEmitter(nativeModule); - _shakeSubscription = emitter.addListener(OnShakeEventName, () => { - onShake(); - }); + try { + const emitter = createEmitter(nativeModule); + _shakeSubscription = emitter.addListener(OnShakeEventName, () => { + onShake(); + }); - // Explicitly enable native shake detection. On iOS with New Architecture (TurboModules), - // NativeEventEmitter.addListener does not dispatch to native addListener:, so the - // native shake listener would never start without this explicit call. - const module = nativeModule as { enableShakeDetection?: () => void }; - if (module.enableShakeDetection) { - module.enableShakeDetection(); - } else { - debug.warn('enableShakeDetection is not available on the native module.'); + // Explicitly enable native shake detection. On iOS with New Architecture (TurboModules), + // NativeEventEmitter.addListener does not dispatch to native addListener:, so the + // native shake listener would never start without this explicit call. + const module = nativeModule as { enableShakeDetection?: () => void }; + if (module.enableShakeDetection) { + module.enableShakeDetection(); + } else { + debug.warn('enableShakeDetection is not available on the native module.'); + } + } catch (e) { + debug.warn('Failed to start shake listener:', e); + _shakeSubscription = null; } } From f88f95cdbb54f792fe2cdde13393f54ed223c097 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 11:20:31 +0100 Subject: [PATCH 22/28] fix(feedback): Remove subscription on shake listener start failure If addListener succeeds but enableShakeDetection throws, the catch block now removes the subscription before nulling the reference, preventing a listener leak. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/js/feedback/ShakeToReportBug.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/feedback/ShakeToReportBug.ts b/packages/core/src/js/feedback/ShakeToReportBug.ts index 1ef3cb8a5c..6a0243d31e 100644 --- a/packages/core/src/js/feedback/ShakeToReportBug.ts +++ b/packages/core/src/js/feedback/ShakeToReportBug.ts @@ -56,7 +56,10 @@ export function startShakeListener(onShake: () => void, createEmitter: EmitterFa } } catch (e) { debug.warn('Failed to start shake listener:', e); - _shakeSubscription = null; + if (_shakeSubscription) { + _shakeSubscription.remove(); + _shakeSubscription = null; + } } } From ca15aff8860b78b1a33884c72b1c3a3d92d13892 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 11:30:31 +0100 Subject: [PATCH 23/28] fix(android): Wrap shake detection in try/catch to prevent host app crashes Consistent with the existing pattern in the file: native operations that could throw are wrapped in try/catch(Throwable) with a warning log so SDK instrumentation errors never crash the host app. Co-Authored-By: Claude Sonnet 4.6 --- .../io/sentry/react/RNSentryModuleImpl.java | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) 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 802f84cea0..8d852e57b5 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 @@ -222,24 +222,34 @@ private void startShakeDetection() { return; } - final ReactApplicationContext context = getReactApplicationContext(); - shakeDetector = new SentryShakeDetector(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); - } - }); + try { // NOPMD - We don't want to crash in any case + final ReactApplicationContext context = getReactApplicationContext(); + shakeDetector = new SentryShakeDetector(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); + } + }); + } catch (Throwable e) { // NOPMD - We don't want to crash in any case + logger.log(SentryLevel.WARNING, "Failed to start shake detection.", e); + shakeDetector = null; + } } private void stopShakeDetection() { - if (shakeDetector != null) { - shakeDetector.stop(); + try { // NOPMD - We don't want to crash in any case + if (shakeDetector != null) { + shakeDetector.stop(); + shakeDetector = null; + } + } catch (Throwable e) { // NOPMD - We don't want to crash in any case + logger.log(SentryLevel.WARNING, "Failed to stop shake detection.", e); shakeDetector = null; } } From bc93b34afd22dfa0b6bc9ff0ce9066d5347f2e22 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 11:36:48 +0100 Subject: [PATCH 24/28] fix(feedback): Prevent provider from stopping externally-owned shake listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startShakeListener now returns boolean indicating whether a new subscription was created (false if one was already active). FeedbackWidgetProvider uses the return value to set _startedShakeListener, so it only stops the listener on unmount if it was the one that started it — preventing it from killing an imperatively-started listener. Co-Authored-By: Claude Sonnet 4.6 --- .../core/src/js/feedback/FeedbackWidgetProvider.tsx | 3 +-- packages/core/src/js/feedback/ShakeToReportBug.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx index f82a6b39da..d71c13411d 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx @@ -103,8 +103,7 @@ export class FeedbackWidgetProvider extends React.Component new NativeEventEmi * - 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 { +export function startShakeListener(onShake: () => void, createEmitter: EmitterFactory = defaultEmitterFactory): boolean { if (_shakeSubscription) { - return; + return false; } if (isWeb()) { debug.warn('Shake detection is not supported on Web.'); - return; + return false; } const nativeModule = getRNSentryModule() as NativeModule | undefined; if (!nativeModule) { debug.warn('Native module is not available. Shake detection will not work.'); - return; + return false; } try { @@ -54,12 +54,14 @@ export function startShakeListener(onShake: () => void, createEmitter: EmitterFa } else { debug.warn('enableShakeDetection is not available on the native module.'); } + return true; } catch (e) { debug.warn('Failed to start shake listener:', e); if (_shakeSubscription) { _shakeSubscription.remove(); _shakeSubscription = null; } + return false; } } From d8fbd8ca95406db285e488995cf307ea34966610 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 11:47:10 +0100 Subject: [PATCH 25/28] Lint fix --- packages/core/src/js/feedback/ShakeToReportBug.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/feedback/ShakeToReportBug.ts b/packages/core/src/js/feedback/ShakeToReportBug.ts index 3fe981d732..e2b5cc43c9 100644 --- a/packages/core/src/js/feedback/ShakeToReportBug.ts +++ b/packages/core/src/js/feedback/ShakeToReportBug.ts @@ -23,7 +23,10 @@ const defaultEmitterFactory: EmitterFactory = nativeModule => new NativeEventEmi * - 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): boolean { +export function startShakeListener( + onShake: () => void, + createEmitter: EmitterFactory = defaultEmitterFactory, +): boolean { if (_shakeSubscription) { return false; } From 99f2507d22328d88267f114a3ffc98a1bf7dd052 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 11:49:40 +0100 Subject: [PATCH 26/28] fix(android): Wrap shake detection callback in try/catch The lambda passed to shakeDetector.start() runs on the sensor thread at shake-time, outside the try/catch that protects the setup. Wrapping it ensures exceptions from getReactApplicationContext(), getJSModule(), or emit() never crash the sensor thread or host app. Co-Authored-By: Claude Sonnet 4.6 --- .../java/io/sentry/react/RNSentryModuleImpl.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 8d852e57b5..024f0d0ed1 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 @@ -228,12 +228,16 @@ private void startShakeDetection() { 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); + try { // NOPMD - We don't want to crash in any case + final ReactApplicationContext ctx = getReactApplicationContext(); + if (ctx.hasActiveReactInstance()) { + ctx.getJSModule( + com.facebook.react.modules.core.DeviceEventManagerModule + .RCTDeviceEventEmitter.class) + .emit(ON_SHAKE_EVENT, null); + } + } catch (Throwable e) { // NOPMD - We don't want to crash in any case + logger.log(SentryLevel.WARNING, "Failed to emit shake event.", e); } }); } catch (Throwable e) { // NOPMD - We don't want to crash in any case From 1d515e7d10d90b83faeb777f9e4a3bdaa4e2f7be Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 11:57:05 +0100 Subject: [PATCH 27/28] fix(feedback): Track ownership in imperative shake API enableFeedbackOnShake now records whether it actually started the listener (using the boolean returned by startShakeListener). disableFeedbackOnShake only stops the listener if it was the one that started it, preventing it from interfering with a listener owned by FeedbackWidgetProvider. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/js/feedback/FeedbackWidgetManager.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx index 6ac10721fb..e2002d67d8 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -133,13 +133,18 @@ const resetScreenshotButtonManager = (): void => { ScreenshotButtonManager.reset(); }; +let _imperativeShakeListenerStarted = false; + const enableFeedbackOnShake = (): void => { lazyLoadAutoInjectFeedbackIntegration(); - startShakeListener(showFeedbackWidget); + _imperativeShakeListenerStarted = startShakeListener(showFeedbackWidget); }; const disableFeedbackOnShake = (): void => { - stopShakeListener(); + if (_imperativeShakeListenerStarted) { + stopShakeListener(); + _imperativeShakeListenerStarted = false; + } }; export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, enableFeedbackOnShake, disableFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager }; From e7996b8efe39a242b3c6af8fc20f4512e25a7a3b Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 19 Mar 2026 12:06:56 +0100 Subject: [PATCH 28/28] fix(feedback): Guard against shake listener crashes and asymmetric lifecycle - Wrap stopShakeListener native calls in try/catch so exceptions from subscription.remove() or disableShakeDetection() never crash the app. - Guard enableFeedbackOnShake so repeated calls don't overwrite _imperativeShakeListenerStarted with false, which would make disableFeedbackOnShake a permanent no-op. Co-Authored-By: Claude Sonnet 4.6 --- .../core/src/js/feedback/FeedbackWidgetManager.tsx | 4 +++- packages/core/src/js/feedback/ShakeToReportBug.ts | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx index e2002d67d8..ba3cef2601 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -137,7 +137,9 @@ let _imperativeShakeListenerStarted = false; const enableFeedbackOnShake = (): void => { lazyLoadAutoInjectFeedbackIntegration(); - _imperativeShakeListenerStarted = startShakeListener(showFeedbackWidget); + if (!_imperativeShakeListenerStarted) { + _imperativeShakeListenerStarted = startShakeListener(showFeedbackWidget); + } }; const disableFeedbackOnShake = (): void => { diff --git a/packages/core/src/js/feedback/ShakeToReportBug.ts b/packages/core/src/js/feedback/ShakeToReportBug.ts index e2b5cc43c9..d58fba9da0 100644 --- a/packages/core/src/js/feedback/ShakeToReportBug.ts +++ b/packages/core/src/js/feedback/ShakeToReportBug.ts @@ -73,11 +73,19 @@ export function startShakeListener( */ export function stopShakeListener(): void { if (_shakeSubscription) { - _shakeSubscription.remove(); + try { + _shakeSubscription.remove(); + } catch (e) { + debug.warn('Failed to remove shake subscription:', e); + } _shakeSubscription = null; - const nativeModule = getRNSentryModule() as { disableShakeDetection?: () => void } | undefined; - nativeModule?.disableShakeDetection?.(); + try { + const nativeModule = getRNSentryModule() as { disableShakeDetection?: () => void } | undefined; + nativeModule?.disableShakeDetection?.(); + } catch (e) { + debug.warn('Failed to disable native shake detection:', e); + } } }