Skip to content

Commit 1c7705c

Browse files
authored
Merge 0431cc3 into b4ee16b
2 parents b4ee16b + 0431cc3 commit 1c7705c

File tree

7 files changed

+502
-11
lines changed

7 files changed

+502
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
### Features
1212

13+
- Add RNSentrySDK APIs support to @sentry/react-native/expo plugin ([#4633](https://github.com/getsentry/sentry-react-native/pull/4633))
1314
- User Feedback Widget Beta ([#4435](https://github.com/getsentry/sentry-react-native/pull/4435))
1415

1516
To collect user feedback from inside your application call `Sentry.showFeedbackWidget()`.

packages/core/plugin/src/withSentry.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface PluginProps {
1212
project?: string;
1313
authToken?: string;
1414
url?: string;
15+
useNativeInit?: boolean;
1516
experimental_android?: SentryAndroidGradlePluginOptions;
1617
}
1718

@@ -26,7 +27,7 @@ const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
2627
let cfg = config;
2728
if (sentryProperties !== null) {
2829
try {
29-
cfg = withSentryAndroid(cfg, sentryProperties);
30+
cfg = withSentryAndroid(cfg, { sentryProperties, useNativeInit: props?.useNativeInit });
3031
} catch (e) {
3132
warnOnce(`There was a problem with configuring your native Android project: ${e}`);
3233
}
@@ -39,7 +40,7 @@ const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
3940
}
4041
}
4142
try {
42-
cfg = withSentryIOS(cfg, sentryProperties);
43+
cfg = withSentryIOS(cfg, { sentryProperties, useNativeInit: props?.useNativeInit });
4344
} catch (e) {
4445
warnOnce(`There was a problem with configuring your native iOS project: ${e}`);
4546
}

packages/core/plugin/src/withSentryAndroid.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
1+
import type { ExpoConfig } from '@expo/config-types';
12
import type { ConfigPlugin } from 'expo/config-plugins';
2-
import { withAppBuildGradle, withDangerousMod } from 'expo/config-plugins';
3+
import { withAppBuildGradle, withDangerousMod, withMainApplication } from 'expo/config-plugins';
34
import * as path from 'path';
45

56
import { warnOnce, writeSentryPropertiesTo } from './utils';
67

7-
export const withSentryAndroid: ConfigPlugin<string> = (config, sentryProperties: string) => {
8-
const cfg = withAppBuildGradle(config, config => {
8+
export const withSentryAndroid: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = (
9+
config,
10+
{ sentryProperties, useNativeInit = true },
11+
) => {
12+
const appBuildGradleCfg = withAppBuildGradle(config, config => {
913
if (config.modResults.language === 'groovy') {
1014
config.modResults.contents = modifyAppBuildGradle(config.modResults.contents);
1115
} else {
1216
throw new Error('Cannot configure Sentry in the app gradle because the build.gradle is not groovy');
1317
}
1418
return config;
1519
});
16-
return withDangerousMod(cfg, [
20+
21+
const mainApplicationCfg = useNativeInit ? modifyMainApplication(appBuildGradleCfg) : appBuildGradleCfg;
22+
23+
return withDangerousMod(mainApplicationCfg, [
1724
'android',
1825
config => {
1926
writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'android'), sentryProperties);
@@ -49,3 +56,58 @@ export function modifyAppBuildGradle(buildGradle: string): string {
4956

5057
return buildGradle.replace(pattern, match => `${applyFrom}\n\n${match}`);
5158
}
59+
60+
export function modifyMainApplication(config: ExpoConfig): ExpoConfig {
61+
return withMainApplication(config, async config => {
62+
if (!config.modResults || !config.modResults.path) {
63+
warnOnce("Can't add 'RNSentrySDK.init' to Android MainApplication, because the file was not found.");
64+
return config;
65+
}
66+
67+
const fileName = path.basename(config.modResults.path);
68+
69+
if (config.modResults.contents.includes('RNSentrySDK.init')) {
70+
warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.init', the native code won't be updated.`);
71+
return config;
72+
}
73+
74+
if (config.modResults.language === 'java') {
75+
if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK;')) {
76+
// Insert import statement after package declaration
77+
config.modResults.contents = config.modResults.contents.replace(
78+
/(package .*;\n\n?)/,
79+
`$1import io.sentry.react.RNSentrySDK;\n`,
80+
);
81+
}
82+
// Add RNSentrySDK.init
83+
const originalContents = config.modResults.contents;
84+
config.modResults.contents = config.modResults.contents.replace(
85+
/(super\.onCreate\(\)[;\n]*)([ \t]*)/,
86+
`$1\n$2RNSentrySDK.init(this);\n$2`,
87+
);
88+
if (config.modResults.contents === originalContents) {
89+
warnOnce(`Failed to insert 'RNSentrySDK.init'.`);
90+
}
91+
} else {
92+
// Kotlin
93+
if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK')) {
94+
// Insert import statement after package declaration
95+
config.modResults.contents = config.modResults.contents.replace(
96+
/(package .*\n\n?)/,
97+
`$1import io.sentry.react.RNSentrySDK\n`,
98+
);
99+
}
100+
// Add RNSentrySDK.init
101+
const originalContents = config.modResults.contents;
102+
config.modResults.contents = config.modResults.contents.replace(
103+
/(super\.onCreate\(\)[;\n]*)([ \t]*)/,
104+
`$1\n$2RNSentrySDK.init(this)\n$2`,
105+
);
106+
if (config.modResults.contents === originalContents) {
107+
warnOnce(`Failed to insert 'RNSentrySDK.init'.`);
108+
}
109+
}
110+
111+
return config;
112+
});
113+
}

packages/core/plugin/src/withSentryIOS.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import type { ExpoConfig } from '@expo/config-types';
12
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
23
import type { ConfigPlugin, XcodeProject } from 'expo/config-plugins';
3-
import { withDangerousMod, withXcodeProject } from 'expo/config-plugins';
4+
import { withAppDelegate, withDangerousMod, withXcodeProject } from 'expo/config-plugins';
45
import * as path from 'path';
56

67
import { warnOnce, writeSentryPropertiesTo } from './utils';
@@ -12,8 +13,11 @@ const SENTRY_REACT_NATIVE_XCODE_PATH =
1213
const SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH =
1314
"`${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
1415

15-
export const withSentryIOS: ConfigPlugin<string> = (config, sentryProperties: string) => {
16-
const cfg = withXcodeProject(config, config => {
16+
export const withSentryIOS: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = (
17+
config,
18+
{ sentryProperties, useNativeInit = true },
19+
) => {
20+
const xcodeProjectCfg = withXcodeProject(config, config => {
1721
const xcodeProject: XcodeProject = config.modResults;
1822

1923
const sentryBuildPhase = xcodeProject.pbxItemByComment(
@@ -36,7 +40,9 @@ export const withSentryIOS: ConfigPlugin<string> = (config, sentryProperties: st
3640
return config;
3741
});
3842

39-
return withDangerousMod(cfg, [
43+
const appDelegateCfc = useNativeInit ? modifyAppDelegate(xcodeProjectCfg) : xcodeProjectCfg;
44+
45+
return withDangerousMod(appDelegateCfc, [
4046
'ios',
4147
config => {
4248
writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
@@ -79,3 +85,58 @@ export function addSentryWithBundledScriptsToBundleShellScript(script: string):
7985
(match: string) => `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_PATH} ${match}`,
8086
);
8187
}
88+
89+
export function modifyAppDelegate(config: ExpoConfig): ExpoConfig {
90+
return withAppDelegate(config, async config => {
91+
if (!config.modResults || !config.modResults.path) {
92+
warnOnce('Skipping AppDelegate modification because the file does not exist.');
93+
return config;
94+
}
95+
96+
const fileName = path.basename(config.modResults.path);
97+
98+
if (config.modResults.language === 'swift') {
99+
if (config.modResults.contents.includes('RNSentrySDK.start()')) {
100+
warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.start()'.`);
101+
return config;
102+
}
103+
if (!config.modResults.contents.includes('import RNSentry')) {
104+
// Insert import statement after UIKit import
105+
config.modResults.contents = config.modResults.contents.replace(/(import UIKit\n)/, `$1import RNSentry\n`);
106+
}
107+
// Add RNSentrySDK.start() at the beginning of application method
108+
const originalContents = config.modResults.contents;
109+
config.modResults.contents = config.modResults.contents.replace(
110+
/(func application\([^)]*\) -> Bool \{)/s,
111+
`$1\n RNSentrySDK.start()`,
112+
);
113+
if (config.modResults.contents === originalContents) {
114+
warnOnce(`Failed to insert 'RNSentrySDK.start()'.`);
115+
}
116+
} else {
117+
// Objective-C
118+
if (config.modResults.contents.includes('[RNSentrySDK start]')) {
119+
warnOnce(`Your '${fileName}' already contains '[RNSentrySDK start]'.`);
120+
return config;
121+
}
122+
if (!config.modResults.contents.includes('#import <RNSentry/RNSentry.h>')) {
123+
// Add import after AppDelegate.h
124+
config.modResults.contents = config.modResults.contents.replace(
125+
/(#import "AppDelegate.h"\n)/,
126+
`$1#import <RNSentry/RNSentry.h>\n`,
127+
);
128+
}
129+
// Add [RNSentrySDK start] at the beginning of application:didFinishLaunchingWithOptions method
130+
const originalContents = config.modResults.contents;
131+
config.modResults.contents = config.modResults.contents.replace(
132+
/(- \(BOOL\)application:[\s\S]*?didFinishLaunchingWithOptions:[\s\S]*?\{\n)(\s*)/s,
133+
`$1$2[RNSentrySDK start];\n$2`,
134+
);
135+
if (config.modResults.contents === originalContents) {
136+
warnOnce(`Failed to insert '[RNSentrySDK start]'.`);
137+
}
138+
}
139+
140+
return config;
141+
});
142+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { ExpoConfig } from '@expo/config-types';
2+
3+
import { warnOnce } from '../../plugin/src/utils';
4+
import { modifyAppDelegate } from '../../plugin/src/withSentryIOS';
5+
6+
// Mock dependencies
7+
jest.mock('@expo/config-plugins', () => ({
8+
...jest.requireActual('@expo/config-plugins'),
9+
withAppDelegate: jest.fn((config, callback) => callback(config)),
10+
}));
11+
12+
jest.mock('../../plugin/src/utils', () => ({
13+
warnOnce: jest.fn(),
14+
}));
15+
16+
interface MockedExpoConfig extends ExpoConfig {
17+
modResults: {
18+
path: string;
19+
contents: string;
20+
language: 'swift' | 'objc';
21+
};
22+
}
23+
24+
const objcContents = `#import "AppDelegate.h"
25+
26+
@implementation AppDelegate
27+
28+
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
29+
{
30+
self.moduleName = @"main";
31+
32+
// You can add your custom initial props in the dictionary below.
33+
// They will be passed down to the ViewController used by React Native.
34+
self.initialProps = @{};
35+
36+
return [super application:application didFinishLaunchingWithOptions:launchOptions];
37+
}
38+
39+
@end
40+
`;
41+
42+
const objcExpected = `#import "AppDelegate.h"
43+
#import <RNSentry/RNSentry.h>
44+
45+
@implementation AppDelegate
46+
47+
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
48+
{
49+
[RNSentrySDK start];
50+
self.moduleName = @"main";
51+
52+
// You can add your custom initial props in the dictionary below.
53+
// They will be passed down to the ViewController used by React Native.
54+
self.initialProps = @{};
55+
56+
return [super application:application didFinishLaunchingWithOptions:launchOptions];
57+
}
58+
59+
@end
60+
`;
61+
62+
const swiftContents = `import React
63+
import React_RCTAppDelegate
64+
import ReactAppDependencyProvider
65+
import UIKit
66+
67+
@main
68+
class AppDelegate: RCTAppDelegate {
69+
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
70+
self.moduleName = "sentry-react-native-sample"
71+
self.dependencyProvider = RCTAppDependencyProvider()
72+
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
73+
}
74+
}`;
75+
76+
const swiftExpected = `import React
77+
import React_RCTAppDelegate
78+
import ReactAppDependencyProvider
79+
import UIKit
80+
import RNSentry
81+
82+
@main
83+
class AppDelegate: RCTAppDelegate {
84+
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
85+
RNSentrySDK.start()
86+
self.moduleName = "sentry-react-native-sample"
87+
self.dependencyProvider = RCTAppDependencyProvider()
88+
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
89+
}
90+
}`;
91+
92+
describe('modifyAppDelegate', () => {
93+
let config: MockedExpoConfig;
94+
95+
beforeEach(() => {
96+
jest.clearAllMocks();
97+
// Reset to a mocked Swift config after each test
98+
config = {
99+
name: 'test',
100+
slug: 'test',
101+
modResults: {
102+
path: 'samples/react-native/ios/AppDelegate.swift',
103+
contents: swiftContents,
104+
language: 'swift',
105+
},
106+
};
107+
});
108+
109+
it('should skip modification if modResults or path is missing', async () => {
110+
config.modResults.path = undefined;
111+
112+
const result = await modifyAppDelegate(config);
113+
114+
expect(warnOnce).toHaveBeenCalledWith('Skipping AppDelegate modification because the file does not exist.');
115+
expect(result).toBe(config); // No modification
116+
});
117+
118+
it('should warn if RNSentrySDK.start() is already present in a Swift project', async () => {
119+
config.modResults.contents = 'RNSentrySDK.start();';
120+
121+
const result = await modifyAppDelegate(config);
122+
123+
expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.swift' already contains 'RNSentrySDK.start()'.`);
124+
expect(result).toBe(config); // No modification
125+
});
126+
127+
it('should warn if [RNSentrySDK start] is already present in an Objective-C project', async () => {
128+
config.modResults.language = 'objc';
129+
config.modResults.path = 'samples/react-native/ios/AppDelegate.mm';
130+
config.modResults.contents = '[RNSentrySDK start];';
131+
132+
const result = await modifyAppDelegate(config);
133+
134+
expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.mm' already contains '[RNSentrySDK start]'.`);
135+
expect(result).toBe(config); // No modification
136+
});
137+
138+
it('should modify a Swift file by adding the RNSentrySDK import and start', async () => {
139+
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;
140+
141+
expect(result.modResults.contents).toContain('import RNSentry');
142+
expect(result.modResults.contents).toContain('RNSentrySDK.start()');
143+
expect(result.modResults.contents).toBe(swiftExpected);
144+
});
145+
146+
it('should modify an Objective-C file by adding the RNSentrySDK import and start', async () => {
147+
config.modResults.language = 'objc';
148+
config.modResults.contents = objcContents;
149+
150+
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;
151+
152+
expect(result.modResults.contents).toContain('#import <RNSentry/RNSentry.h>');
153+
expect(result.modResults.contents).toContain('[RNSentrySDK start];');
154+
expect(result.modResults.contents).toBe(objcExpected);
155+
});
156+
157+
it('should insert import statements only once in an Swift project', async () => {
158+
config.modResults.contents =
159+
'import UIKit\nimport RNSentrySDK\n\noverride func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {';
160+
161+
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;
162+
163+
const importCount = (result.modResults.contents.match(/import RNSentrySDK/g) || []).length;
164+
expect(importCount).toBe(1);
165+
});
166+
167+
it('should insert import statements only once in an Objective-C project', async () => {
168+
config.modResults.language = 'objc';
169+
config.modResults.contents =
170+
'#import "AppDelegate.h"\n#import <RNSentry/RNSentry.h>\n\n- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {';
171+
172+
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;
173+
174+
const importCount = (result.modResults.contents.match(/#import <RNSentry\/RNSentry.h>/g) || []).length;
175+
expect(importCount).toBe(1);
176+
});
177+
});

0 commit comments

Comments
 (0)