From 9bc0c17d3ac7cb2b0ffe0567999a42de52dca2ba Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 26 Feb 2026 16:57:45 +0100 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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];