diff --git a/CHANGELOG.md b/CHANGELOG.md index ef04ec37c7..c4a0dfb748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add RNSentrySDK APIs support to @sentry/react-native/expo plugin ([#4633](https://github.com/getsentry/sentry-react-native/pull/4633)) - User Feedback Widget Beta ([#4435](https://github.com/getsentry/sentry-react-native/pull/4435)) To collect user feedback from inside your application call `Sentry.showFeedbackWidget()`. diff --git a/packages/core/plugin/src/withSentry.ts b/packages/core/plugin/src/withSentry.ts index 70d4c8932b..3da8885e7b 100644 --- a/packages/core/plugin/src/withSentry.ts +++ b/packages/core/plugin/src/withSentry.ts @@ -12,6 +12,7 @@ interface PluginProps { project?: string; authToken?: string; url?: string; + useNativeInit?: boolean; experimental_android?: SentryAndroidGradlePluginOptions; } @@ -26,7 +27,7 @@ const withSentryPlugin: ConfigPlugin = (config, props) => { let cfg = config; if (sentryProperties !== null) { try { - cfg = withSentryAndroid(cfg, sentryProperties); + cfg = withSentryAndroid(cfg, { sentryProperties, useNativeInit: props?.useNativeInit }); } catch (e) { warnOnce(`There was a problem with configuring your native Android project: ${e}`); } @@ -39,7 +40,7 @@ const withSentryPlugin: ConfigPlugin = (config, props) => { } } try { - cfg = withSentryIOS(cfg, sentryProperties); + cfg = withSentryIOS(cfg, { sentryProperties, useNativeInit: props?.useNativeInit }); } catch (e) { warnOnce(`There was a problem with configuring your native iOS project: ${e}`); } diff --git a/packages/core/plugin/src/withSentryAndroid.ts b/packages/core/plugin/src/withSentryAndroid.ts index 9beaa23883..e7228f473c 100644 --- a/packages/core/plugin/src/withSentryAndroid.ts +++ b/packages/core/plugin/src/withSentryAndroid.ts @@ -1,11 +1,15 @@ +import type { ExpoConfig } from '@expo/config-types'; import type { ConfigPlugin } from 'expo/config-plugins'; -import { withAppBuildGradle, withDangerousMod } from 'expo/config-plugins'; +import { withAppBuildGradle, withDangerousMod, withMainApplication } from 'expo/config-plugins'; import * as path from 'path'; import { warnOnce, writeSentryPropertiesTo } from './utils'; -export const withSentryAndroid: ConfigPlugin = (config, sentryProperties: string) => { - const cfg = withAppBuildGradle(config, config => { +export const withSentryAndroid: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = ( + config, + { sentryProperties, useNativeInit = true }, +) => { + const appBuildGradleCfg = withAppBuildGradle(config, config => { if (config.modResults.language === 'groovy') { config.modResults.contents = modifyAppBuildGradle(config.modResults.contents); } else { @@ -13,7 +17,10 @@ export const withSentryAndroid: ConfigPlugin = (config, sentryProperties } return config; }); - return withDangerousMod(cfg, [ + + const mainApplicationCfg = useNativeInit ? modifyMainApplication(appBuildGradleCfg) : appBuildGradleCfg; + + return withDangerousMod(mainApplicationCfg, [ 'android', config => { writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'android'), sentryProperties); @@ -49,3 +56,57 @@ export function modifyAppBuildGradle(buildGradle: string): string { return buildGradle.replace(pattern, match => `${applyFrom}\n\n${match}`); } + +export function modifyMainApplication(config: ExpoConfig): ExpoConfig { + return withMainApplication(config, async config => { + if (!config.modResults || !config.modResults.path) { + warnOnce("Can't add 'RNSentrySDK.init' to Android MainApplication, because the file was not found."); + return config; + } + + const fileName = path.basename(config.modResults.path); + + if (config.modResults.contents.includes('RNSentrySDK.init')) { + warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.init', the native code won't be updated.`); + return config; + } + + if (config.modResults.language === 'java') { + // Add RNSentrySDK.init + const originalContents = config.modResults.contents; + config.modResults.contents = config.modResults.contents.replace( + /(super\.onCreate\(\)[;\n]*)([ \t]*)/, + `$1\n$2RNSentrySDK.init(this);\n$2`, + ); + if (config.modResults.contents === originalContents) { + warnOnce(`Failed to insert 'RNSentrySDK.init' in '${fileName}'.`); + } else if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK;')) { + // Insert import statement after package declaration + config.modResults.contents = config.modResults.contents.replace( + /(package .*;\n\n?)/, + `$1import io.sentry.react.RNSentrySDK;\n`, + ); + } + } else if (config.modResults.language === 'kt') { + // Add RNSentrySDK.init + const originalContents = config.modResults.contents; + config.modResults.contents = config.modResults.contents.replace( + /(super\.onCreate\(\)[;\n]*)([ \t]*)/, + `$1\n$2RNSentrySDK.init(this)\n$2`, + ); + if (config.modResults.contents === originalContents) { + warnOnce(`Failed to insert 'RNSentrySDK.init' in '${fileName}'.`); + } else if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK')) { + // Insert import statement after package declaration + config.modResults.contents = config.modResults.contents.replace( + /(package .*\n\n?)/, + `$1import io.sentry.react.RNSentrySDK\n`, + ); + } + } else { + warnOnce(`Unrecognized language detected in '${fileName}', the native code won't be updated.`); + } + + return config; + }); +} diff --git a/packages/core/plugin/src/withSentryIOS.ts b/packages/core/plugin/src/withSentryIOS.ts index db25261839..7be1e0af0c 100644 --- a/packages/core/plugin/src/withSentryIOS.ts +++ b/packages/core/plugin/src/withSentryIOS.ts @@ -1,6 +1,7 @@ +import type { ExpoConfig } from '@expo/config-types'; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import type { ConfigPlugin, XcodeProject } from 'expo/config-plugins'; -import { withDangerousMod, withXcodeProject } from 'expo/config-plugins'; +import { withAppDelegate, withDangerousMod, withXcodeProject } from 'expo/config-plugins'; import * as path from 'path'; import { warnOnce, writeSentryPropertiesTo } from './utils'; @@ -12,8 +13,11 @@ const SENTRY_REACT_NATIVE_XCODE_PATH = const SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH = "`${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; -export const withSentryIOS: ConfigPlugin = (config, sentryProperties: string) => { - const cfg = withXcodeProject(config, config => { +export const withSentryIOS: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = ( + config, + { sentryProperties, useNativeInit = true }, +) => { + const xcodeProjectCfg = withXcodeProject(config, config => { const xcodeProject: XcodeProject = config.modResults; const sentryBuildPhase = xcodeProject.pbxItemByComment( @@ -36,7 +40,9 @@ export const withSentryIOS: ConfigPlugin = (config, sentryProperties: st return config; }); - return withDangerousMod(cfg, [ + const appDelegateCfc = useNativeInit ? modifyAppDelegate(xcodeProjectCfg) : xcodeProjectCfg; + + return withDangerousMod(appDelegateCfc, [ 'ios', config => { writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties); @@ -79,3 +85,57 @@ export function addSentryWithBundledScriptsToBundleShellScript(script: string): (match: string) => `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_PATH} ${match}`, ); } + +export function modifyAppDelegate(config: ExpoConfig): ExpoConfig { + return withAppDelegate(config, async config => { + if (!config.modResults || !config.modResults.path) { + warnOnce("Can't add 'RNSentrySDK.start()' to the iOS AppDelegate, because the file was not found."); + return config; + } + + const fileName = path.basename(config.modResults.path); + + if (config.modResults.language === 'swift') { + if (config.modResults.contents.includes('RNSentrySDK.start()')) { + warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.start()'.`); + return config; + } + // Add RNSentrySDK.start() at the beginning of application method + const originalContents = config.modResults.contents; + config.modResults.contents = config.modResults.contents.replace( + /(func application\([^)]*\) -> Bool \{)/s, + `$1\n RNSentrySDK.start()`, + ); + if (config.modResults.contents === originalContents) { + warnOnce(`Failed to insert 'RNSentrySDK.start()' in '${fileName}'.`); + } else if (!config.modResults.contents.includes('import RNSentry')) { + // Insert import statement after UIKit import + config.modResults.contents = config.modResults.contents.replace(/(import UIKit\n)/, `$1import RNSentry\n`); + } + } else if (config.modResults.language === 'objc') { + if (config.modResults.contents.includes('[RNSentrySDK start]')) { + warnOnce(`Your '${fileName}' already contains '[RNSentrySDK start]'.`); + return config; + } + // Add [RNSentrySDK start] at the beginning of application:didFinishLaunchingWithOptions method + const originalContents = config.modResults.contents; + config.modResults.contents = config.modResults.contents.replace( + /(- \(BOOL\)application:[\s\S]*?didFinishLaunchingWithOptions:[\s\S]*?\{\n)(\s*)/s, + `$1$2[RNSentrySDK start];\n$2`, + ); + if (config.modResults.contents === originalContents) { + warnOnce(`Failed to insert '[RNSentrySDK start]' in '${fileName}.`); + } else if (!config.modResults.contents.includes('#import ')) { + // Add import after AppDelegate.h + config.modResults.contents = config.modResults.contents.replace( + /(#import "AppDelegate.h"\n)/, + `$1#import \n`, + ); + } + } else { + warnOnce(`Unsupported language detected in '${fileName}', the native code won't be updated.`); + } + + return config; + }); +} diff --git a/packages/core/test/expo-plugin/modifyAppDelegate.test.ts b/packages/core/test/expo-plugin/modifyAppDelegate.test.ts new file mode 100644 index 0000000000..3933c2a4e7 --- /dev/null +++ b/packages/core/test/expo-plugin/modifyAppDelegate.test.ts @@ -0,0 +1,183 @@ +import type { ExpoConfig } from '@expo/config-types'; + +import { warnOnce } from '../../plugin/src/utils'; +import { modifyAppDelegate } from '../../plugin/src/withSentryIOS'; + +// Mock dependencies +jest.mock('@expo/config-plugins', () => ({ + ...jest.requireActual('@expo/config-plugins'), + withAppDelegate: jest.fn((config, callback) => callback(config)), +})); + +jest.mock('../../plugin/src/utils', () => ({ + warnOnce: jest.fn(), +})); + +interface MockedExpoConfig extends ExpoConfig { + modResults: { + path: string; + contents: string; + language: 'swift' | 'objc'; + }; +} + +const objcContents = `#import "AppDelegate.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + self.moduleName = @"main"; + + // 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 = @{}; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end +`; + +const objcExpected = `#import "AppDelegate.h" +#import + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + [RNSentrySDK start]; + self.moduleName = @"main"; + + // 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 = @{}; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end +`; + +const swiftContents = `import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import UIKit + +@main +class AppDelegate: RCTAppDelegate { + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + self.moduleName = "sentry-react-native-sample" + self.dependencyProvider = RCTAppDependencyProvider() + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +}`; + +const swiftExpected = `import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import UIKit +import RNSentry + +@main +class AppDelegate: RCTAppDelegate { + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + RNSentrySDK.start() + self.moduleName = "sentry-react-native-sample" + self.dependencyProvider = RCTAppDependencyProvider() + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +}`; + +describe('modifyAppDelegate', () => { + let config: MockedExpoConfig; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset to a mocked Swift config after each test + config = createMockConfig(); + }); + + it('should skip modification if modResults or path is missing', async () => { + config.modResults.path = undefined; + + const result = await modifyAppDelegate(config); + + expect(warnOnce).toHaveBeenCalledWith( + `Can't add 'RNSentrySDK.start()' to the iOS AppDelegate, because the file was not found.`, + ); + expect(result).toBe(config); // No modification + }); + + it('should warn if RNSentrySDK.start() is already present in a Swift project', async () => { + config.modResults.contents = 'RNSentrySDK.start();'; + + const result = await modifyAppDelegate(config); + + expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.swift' already contains 'RNSentrySDK.start()'.`); + expect(result).toBe(config); // No modification + }); + + it('should warn if [RNSentrySDK start] is already present in an Objective-C project', async () => { + config.modResults.language = 'objc'; + config.modResults.path = 'samples/react-native/ios/AppDelegate.mm'; + config.modResults.contents = '[RNSentrySDK start];'; + + const result = await modifyAppDelegate(config); + + expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.mm' already contains '[RNSentrySDK start]'.`); + expect(result).toBe(config); // No modification + }); + + it('should modify a Swift file by adding the RNSentrySDK import and start', async () => { + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('import RNSentry'); + expect(result.modResults.contents).toContain('RNSentrySDK.start()'); + expect(result.modResults.contents).toBe(swiftExpected); + }); + + it('should modify an Objective-C file by adding the RNSentrySDK import and start', async () => { + config.modResults.language = 'objc'; + config.modResults.contents = objcContents; + + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('#import '); + expect(result.modResults.contents).toContain('[RNSentrySDK start];'); + expect(result.modResults.contents).toBe(objcExpected); + }); + + it('should insert import statements only once in an Swift project', async () => { + config.modResults.contents = + 'import UIKit\nimport RNSentrySDK\n\noverride func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {'; + + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + const importCount = (result.modResults.contents.match(/import RNSentrySDK/g) || []).length; + expect(importCount).toBe(1); + }); + + it('should insert import statements only once in an Objective-C project', async () => { + config.modResults.language = 'objc'; + config.modResults.contents = + '#import "AppDelegate.h"\n#import \n\n- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {'; + + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + const importCount = (result.modResults.contents.match(/#import /g) || []).length; + expect(importCount).toBe(1); + }); +}); + +function createMockConfig(): MockedExpoConfig { + return { + name: 'test', + slug: 'test', + modResults: { + path: 'samples/react-native/ios/AppDelegate.swift', + contents: swiftContents, + language: 'swift', + }, + }; +} diff --git a/packages/core/test/expo-plugin/modifyMainApplication.test.ts b/packages/core/test/expo-plugin/modifyMainApplication.test.ts new file mode 100644 index 0000000000..e55319f8a9 --- /dev/null +++ b/packages/core/test/expo-plugin/modifyMainApplication.test.ts @@ -0,0 +1,192 @@ +import type { ExpoConfig } from '@expo/config-types'; + +import { warnOnce } from '../../plugin/src/utils'; +import { modifyMainApplication } from '../../plugin/src/withSentryAndroid'; + +// Mock dependencies +jest.mock('@expo/config-plugins', () => ({ + ...jest.requireActual('@expo/config-plugins'), + withMainApplication: jest.fn((config, callback) => callback(config)), +})); + +jest.mock('../../plugin/src/utils', () => ({ + warnOnce: jest.fn(), +})); + +interface MockedExpoConfig extends ExpoConfig { + modResults: { + path: string; + contents: string; + language: 'java' | 'kt'; + }; +} + +const kotlinContents = `package io.sentry.expo.sample + +import android.app.Application + +import com.facebook.react.ReactApplication +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.soloader.SoLoader + +import expo.modules.ApplicationLifecycleDispatcher + +class MainApplication : Application(), ReactApplication { + override fun onCreate() { + super.onCreate() + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } +} +`; + +const kotlinExpected = `package io.sentry.expo.sample + +import io.sentry.react.RNSentrySDK +import android.app.Application + +import com.facebook.react.ReactApplication +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.soloader.SoLoader + +import expo.modules.ApplicationLifecycleDispatcher + +class MainApplication : Application(), ReactApplication { + override fun onCreate() { + super.onCreate() + + RNSentrySDK.init(this) + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } +} +`; + +const javaContents = `package com.testappplain; + +import android.app.Application; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.config.ReactFeatureFlags; +import com.facebook.soloader.SoLoader; + +public class MainApplication extends Application implements ReactApplication { + @Override + public void onCreate() { + super.onCreate(); + // If you opted-in for the New Architecture, we enable the TurboModule system + ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + SoLoader.init(this, /* native exopackage */ false); + initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); + } +} +`; + +const javaExpected = `package com.testappplain; + +import io.sentry.react.RNSentrySDK; +import android.app.Application; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.config.ReactFeatureFlags; +import com.facebook.soloader.SoLoader; + +public class MainApplication extends Application implements ReactApplication { + @Override + public void onCreate() { + super.onCreate(); + + RNSentrySDK.init(this); + // If you opted-in for the New Architecture, we enable the TurboModule system + ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + SoLoader.init(this, /* native exopackage */ false); + initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); + } +} +`; + +describe('modifyMainApplication', () => { + let config: MockedExpoConfig; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset to a mocked Java config after each test + config = createMockConfig(); + }); + + it('should skip modification if modResults or path is missing', async () => { + config.modResults.path = undefined; + + const result = await modifyMainApplication(config); + + expect(warnOnce).toHaveBeenCalledWith( + `Can't add 'RNSentrySDK.init' to Android MainApplication, because the file was not found.`, + ); + expect(result).toBe(config); // No modification + }); + + it('should warn if RNSentrySDK.init is already present', async () => { + config.modResults.contents = 'package com.example;\nsuper.onCreate();\nRNSentrySDK.init(this);'; + + const result = await modifyMainApplication(config); + + expect(warnOnce).toHaveBeenCalledWith( + `Your 'MainApplication.java' already contains 'RNSentrySDK.init', the native code won't be updated.`, + ); + expect(result).toBe(config); // No modification + }); + + it('should modify a Java file by adding the RNSentrySDK import and init', async () => { + const result = (await modifyMainApplication(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('import io.sentry.react.RNSentrySDK;'); + expect(result.modResults.contents).toContain('RNSentrySDK.init(this);'); + expect(result.modResults.contents).toBe(javaExpected); + }); + + it('should modify a Kotlin file by adding the RNSentrySDK import and init', async () => { + config.modResults.language = 'kt'; + config.modResults.contents = kotlinContents; + + const result = (await modifyMainApplication(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('import io.sentry.react.RNSentrySDK'); + expect(result.modResults.contents).toContain('RNSentrySDK.init(this)'); + expect(result.modResults.contents).toBe(kotlinExpected); + }); + + it('should insert import statements only once', async () => { + config.modResults.contents = 'package com.example;\nimport io.sentry.react.RNSentrySDK;\nsuper.onCreate();'; + + const result = (await modifyMainApplication(config)) as MockedExpoConfig; + + const importCount = (result.modResults.contents.match(/import io.sentry.react.RNSentrySDK/g) || []).length; + expect(importCount).toBe(1); + }); +}); + +function createMockConfig(): MockedExpoConfig { + return { + name: 'test', + slug: 'test', + modResults: { + path: '/android/app/src/main/java/com/example/MainApplication.java', + contents: javaContents, + language: 'java', + }, + }; +} diff --git a/samples/expo/app.json b/samples/expo/app.json index 1f1c89980d..2978475605 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -45,6 +45,7 @@ "url": "https://sentry.io/", "project": "sentry-react-native", "organization": "sentry-sdks", + "useNativeInit": true, "experimental_android": { "enableAndroidGradlePlugin": true, "autoUploadProguardMapping": true, @@ -71,4 +72,4 @@ ] ] } -} \ No newline at end of file +}