diff --git a/CHANGELOG.md b/CHANGELOG.md index 765919b54b..af96302971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### 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 })` - Add `onNativeLog` callback to intercept and forward native SDK logs to JavaScript console ([#5622](https://github.com/getsentry/sentry-react-native/pull/5622)) - The callback receives native log events with `level`, `component`, and `message` properties - Only works when `debug: true` is enabled in `Sentry.init` 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 b239852cbf..58022a4303 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 @@ -45,6 +45,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; @@ -122,6 +123,10 @@ public class RNSentryModuleImpl { private final @NotNull Runnable emitNewFrameEvent; + 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; @@ -202,16 +207,61 @@ 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 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); + } + }); + } + + private void stopShakeDetection() { + if (shakeDetector != null) { + shakeDetector.stop(); + shakeDetector = null; + } + } + + 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) { 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 9260876452..96a15d56f1 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -295,9 +295,53 @@ - (void)stopObserving [[RNSentryNativeLogsForwarder shared] stopForwarding]; } +- (void)handleShakeDetected +{ + if (hasListeners) { + [self sendEventWithName:RNSentryOnShakeEvent body:@{ }]; + } +} + +// 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) +{ + [[NSNotificationCenter defaultCenter] removeObserver:self + name:RNSentryShakeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleShakeDetected) + name:RNSentryShakeNotification + object:nil]; + 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) +{ + 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:RNSentryShakeNotification + object:nil]; +} + - (NSArray *)supportedEvents { - return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent ]; + return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent, RNSentryOnShakeEvent ]; } RCT_EXPORT_METHOD( diff --git a/packages/core/ios/RNSentryEvents.h b/packages/core/ios/RNSentryEvents.h index 6f1a5f0540..13884dc4eb 100644 --- a/packages/core/ios/RNSentryEvents.h +++ b/packages/core/ios/RNSentryEvents.h @@ -1,4 +1,5 @@ #import extern NSString *const RNSentryNewFrameEvent; +extern NSString *const RNSentryOnShakeEvent; extern NSString *const RNSentryNativeLogEvent; diff --git a/packages/core/ios/RNSentryEvents.m b/packages/core/ios/RNSentryEvents.m index bb3e842d73..c4bf3ab350 100644 --- a/packages/core/ios/RNSentryEvents.m +++ b/packages/core/ios/RNSentryEvents.m @@ -1,4 +1,5 @@ #import "RNSentryEvents.h" NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame"; +NSString *const RNSentryOnShakeEvent = @"rn_sentry_on_shake"; NSString *const RNSentryNativeLogEvent = @"SentryNativeLog"; 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/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/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..d263d54f58 --- /dev/null +++ b/packages/core/src/js/feedback/ShakeToReportBug.ts @@ -0,0 +1,77 @@ +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) { + 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, () => { + 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.'); + } +} + +/** + * Stops listening for device shake events. + */ +export function stopShakeListener(): void { + if (_shakeSubscription) { + _shakeSubscription.remove(); + _shakeSubscription = null; + + const nativeModule = getRNSentryModule() as { disableShakeDetection?: () => void } | undefined; + nativeModule?.disableShakeDetection?.(); + } +} + +/** + * 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 b9670f67eb..9dc2461729 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -101,6 +101,12 @@ export { Mask, Unmask } from './replay/CustomMask'; export { FeedbackButton } from './feedback/FeedbackButton'; export { FeedbackWidget } from './feedback/FeedbackWidget'; -export { showFeedbackWidget, showFeedbackButton, hideFeedbackButton } from './feedback/FeedbackWidgetManager'; +export { + showFeedbackWidget, + showFeedbackButton, + hideFeedbackButton, + showFeedbackOnShake, + hideFeedbackOnShake, +} from './feedback/FeedbackWidgetManager'; export { getDataFromUri } from './wrapper'; diff --git a/packages/core/test/feedback/ShakeToReportBug.test.tsx b/packages/core/test/feedback/ShakeToReportBug.test.tsx new file mode 100644 index 0000000000..3b85c17960 --- /dev/null +++ b/packages/core/test/feedback/ShakeToReportBug.test.tsx @@ -0,0 +1,213 @@ +import { debug, setCurrentClient } from '@sentry/core'; +import { render } from '@testing-library/react-native'; +import * as React from 'react'; +import { Text } from 'react-native'; +import { + resetFeedbackWidgetManager, +} from '../../src/js/feedback/FeedbackWidgetManager'; +import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider'; +import { feedbackIntegration } from '../../src/js/feedback/integration'; +import { isShakeListenerActive, startShakeListener, stopShakeListener } from '../../src/js/feedback/ShakeToReportBug'; +import { isModalSupported } from '../../src/js/feedback/utils'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +jest.mock('../../src/js/feedback/utils', () => ({ + isModalSupported: jest.fn(), + isNativeDriverSupportedForColorAnimations: jest.fn().mockReturnValue(true), +})); + +const mockedIsModalSupported = isModalSupported as jest.MockedFunction; + +jest.mock('../../src/js/wrapper', () => ({ + getRNSentryModule: jest.fn(() => ({ + addListener: jest.fn(), + removeListeners: jest.fn(), + })), +})); + +let mockShakeCallback: (() => void) | undefined; +const mockRemove = jest.fn(); + +const createMockEmitter = () => { + return jest.fn().mockReturnValue({ + addListener: jest.fn().mockImplementation((_eventType: string, listener: () => void) => { + mockShakeCallback = listener; + return { remove: mockRemove }; + }), + }); +}; + +let mockEmitterFactory: ReturnType; + +// Also mock the module-level NativeEventEmitter used by FeedbackWidgetProvider's auto-start +jest.mock('../../src/js/feedback/ShakeToReportBug', () => { + const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug'); + return { + ...actual, + startShakeListener: jest.fn(actual.startShakeListener), + stopShakeListener: jest.fn(actual.stopShakeListener), + isShakeListenerActive: jest.fn(actual.isShakeListenerActive), + }; +}); + +beforeEach(() => { + debug.error = jest.fn(); + debug.log = jest.fn() as typeof debug.log; + debug.warn = jest.fn() as typeof debug.warn; +}); + +describe('ShakeToReportBug', () => { + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + resetFeedbackWidgetManager(); + + // Get the actual functions (unmocked) + const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug'); + actual.stopShakeListener(); + + mockShakeCallback = undefined; + mockRemove.mockClear(); + mockEmitterFactory = createMockEmitter(); + + (startShakeListener as jest.Mock).mockClear(); + (stopShakeListener as jest.Mock).mockClear(); + (isShakeListenerActive as jest.Mock).mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('startShakeListener / stopShakeListener', () => { + it('starts listening for shake events', () => { + const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug'); + actual.startShakeListener(jest.fn(), mockEmitterFactory); + + expect(actual.isShakeListenerActive()).toBe(true); + expect(mockEmitterFactory).toHaveBeenCalledTimes(1); + }); + + it('does not start a second listener if already active', () => { + const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug'); + actual.startShakeListener(jest.fn(), mockEmitterFactory); + actual.startShakeListener(jest.fn(), mockEmitterFactory); + + expect(actual.isShakeListenerActive()).toBe(true); + expect(mockEmitterFactory).toHaveBeenCalledTimes(1); + }); + + it('stops listening for shake events', () => { + const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug'); + actual.startShakeListener(jest.fn(), mockEmitterFactory); + actual.stopShakeListener(); + + expect(actual.isShakeListenerActive()).toBe(false); + expect(mockRemove).toHaveBeenCalledTimes(1); + }); + + it('does not throw when stopping without starting', () => { + const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug'); + expect(() => actual.stopShakeListener()).not.toThrow(); + }); + + it('invokes onShake callback when shake event is received', () => { + const actual = jest.requireActual('../../src/js/feedback/ShakeToReportBug'); + const onShake = jest.fn(); + actual.startShakeListener(onShake, mockEmitterFactory); + + mockShakeCallback?.(); + + expect(onShake).toHaveBeenCalledTimes(1); + }); + }); + + describe('feedbackIntegration with enableShakeToReport', () => { + it('auto-starts shake listener when enableShakeToReport is true', () => { + mockedIsModalSupported.mockReturnValue(true); + + const integration = feedbackIntegration({ + enableShakeToReport: true, + }); + + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + client.addIntegration(integration); + + render( + + App Components + , + ); + + expect(startShakeListener).toHaveBeenCalled(); + }); + + it('does not auto-start shake listener when enableShakeToReport is false', () => { + mockedIsModalSupported.mockReturnValue(true); + + const integration = feedbackIntegration({ + enableShakeToReport: false, + }); + + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + client.addIntegration(integration); + + render( + + App Components + , + ); + + expect(startShakeListener).not.toHaveBeenCalled(); + }); + + it('does not auto-start shake listener when enableShakeToReport is not set', () => { + mockedIsModalSupported.mockReturnValue(true); + + const integration = feedbackIntegration(); + + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + client.addIntegration(integration); + + render( + + App Components + , + ); + + expect(startShakeListener).not.toHaveBeenCalled(); + }); + + it('stops shake listener when FeedbackWidgetProvider unmounts', () => { + mockedIsModalSupported.mockReturnValue(true); + + const integration = feedbackIntegration({ + enableShakeToReport: true, + }); + + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + client.addIntegration(integration); + + const { unmount } = render( + + App Components + , + ); + + expect(startShakeListener).toHaveBeenCalled(); + + unmount(); + + expect(stopShakeListener).toHaveBeenCalled(); + }); + }); +}); diff --git a/samples/react-native-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]; 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',