Skip to content

Commit bef7be6

Browse files
authored
Merge 5447be9 into b4ee16b
2 parents b4ee16b + 5447be9 commit bef7be6

File tree

7 files changed

+510
-11
lines changed

7 files changed

+510
-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: 65 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,57 @@ 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+
// Add RNSentrySDK.init
76+
const originalContents = config.modResults.contents;
77+
config.modResults.contents = config.modResults.contents.replace(
78+
/(super\.onCreate\(\)[;\n]*)([ \t]*)/,
79+
`$1\n$2RNSentrySDK.init(this);\n$2`,
80+
);
81+
if (config.modResults.contents === originalContents) {
82+
warnOnce(`Failed to insert 'RNSentrySDK.init' in '${fileName}'.`);
83+
} else if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK;')) {
84+
// Insert import statement after package declaration
85+
config.modResults.contents = config.modResults.contents.replace(
86+
/(package .*;\n\n?)/,
87+
`$1import io.sentry.react.RNSentrySDK;\n`,
88+
);
89+
}
90+
} else if (config.modResults.language === 'kt') {
91+
// Add RNSentrySDK.init
92+
const originalContents = config.modResults.contents;
93+
config.modResults.contents = config.modResults.contents.replace(
94+
/(super\.onCreate\(\)[;\n]*)([ \t]*)/,
95+
`$1\n$2RNSentrySDK.init(this)\n$2`,
96+
);
97+
if (config.modResults.contents === originalContents) {
98+
warnOnce(`Failed to insert 'RNSentrySDK.init' in '${fileName}'.`);
99+
} else if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK')) {
100+
// Insert import statement after package declaration
101+
config.modResults.contents = config.modResults.contents.replace(
102+
/(package .*\n\n?)/,
103+
`$1import io.sentry.react.RNSentrySDK\n`,
104+
);
105+
}
106+
} else {
107+
warnOnce(`Unrecognized language detected in '${fileName}', the native code won't be updated.`);
108+
}
109+
110+
return config;
111+
});
112+
}

packages/core/plugin/src/withSentryIOS.ts

Lines changed: 64 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,57 @@ 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("Can't add 'RNSentrySDK.start()' to the iOS AppDelegate, because the file was not found.");
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+
// Add RNSentrySDK.start() at the beginning of application method
104+
const originalContents = config.modResults.contents;
105+
config.modResults.contents = config.modResults.contents.replace(
106+
/(func application\([^)]*\) -> Bool \{)/s,
107+
`$1\n RNSentrySDK.start()`,
108+
);
109+
if (config.modResults.contents === originalContents) {
110+
warnOnce(`Failed to insert 'RNSentrySDK.start()' in '${fileName}.`);
111+
} else if (!config.modResults.contents.includes('import RNSentry')) {
112+
// Insert import statement after UIKit import
113+
config.modResults.contents = config.modResults.contents.replace(/(import UIKit\n)/, `$1import RNSentry\n`);
114+
}
115+
} else if (config.modResults.language === 'objc') {
116+
if (config.modResults.contents.includes('[RNSentrySDK start]')) {
117+
warnOnce(`Your '${fileName}' already contains '[RNSentrySDK start]'.`);
118+
return config;
119+
}
120+
// Add [RNSentrySDK start] at the beginning of application:didFinishLaunchingWithOptions method
121+
const originalContents = config.modResults.contents;
122+
config.modResults.contents = config.modResults.contents.replace(
123+
/(- \(BOOL\)application:[\s\S]*?didFinishLaunchingWithOptions:[\s\S]*?\{\n)(\s*)/s,
124+
`$1$2[RNSentrySDK start];\n$2`,
125+
);
126+
if (config.modResults.contents === originalContents) {
127+
warnOnce(`Failed to insert '[RNSentrySDK start]' in '${fileName}.`);
128+
} else if (!config.modResults.contents.includes('#import <RNSentry/RNSentry.h>')) {
129+
// Add import after AppDelegate.h
130+
config.modResults.contents = config.modResults.contents.replace(
131+
/(#import "AppDelegate.h"\n)/,
132+
`$1#import <RNSentry/RNSentry.h>\n`,
133+
);
134+
}
135+
} else {
136+
warnOnce(`Unsupported language detected in '${fileName}', the native code won't be updated.`);
137+
}
138+
139+
return config;
140+
});
141+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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 = createMockConfig();
99+
});
100+
101+
it('should skip modification if modResults or path is missing', async () => {
102+
config.modResults.path = undefined;
103+
104+
const result = await modifyAppDelegate(config);
105+
106+
expect(warnOnce).toHaveBeenCalledWith(
107+
`Can't add 'RNSentrySDK.start()' to the iOS AppDelegate, because the file was not found.`,
108+
);
109+
expect(result).toBe(config); // No modification
110+
});
111+
112+
it('should warn if RNSentrySDK.start() is already present in a Swift project', async () => {
113+
config.modResults.contents = 'RNSentrySDK.start();';
114+
115+
const result = await modifyAppDelegate(config);
116+
117+
expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.swift' already contains 'RNSentrySDK.start()'.`);
118+
expect(result).toBe(config); // No modification
119+
});
120+
121+
it('should warn if [RNSentrySDK start] is already present in an Objective-C project', async () => {
122+
config.modResults.language = 'objc';
123+
config.modResults.path = 'samples/react-native/ios/AppDelegate.mm';
124+
config.modResults.contents = '[RNSentrySDK start];';
125+
126+
const result = await modifyAppDelegate(config);
127+
128+
expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.mm' already contains '[RNSentrySDK start]'.`);
129+
expect(result).toBe(config); // No modification
130+
});
131+
132+
it('should modify a Swift file by adding the RNSentrySDK import and start', async () => {
133+
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;
134+
135+
expect(result.modResults.contents).toContain('import RNSentry');
136+
expect(result.modResults.contents).toContain('RNSentrySDK.start()');
137+
expect(result.modResults.contents).toBe(swiftExpected);
138+
});
139+
140+
it('should modify an Objective-C file by adding the RNSentrySDK import and start', async () => {
141+
config.modResults.language = 'objc';
142+
config.modResults.contents = objcContents;
143+
144+
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;
145+
146+
expect(result.modResults.contents).toContain('#import <RNSentry/RNSentry.h>');
147+
expect(result.modResults.contents).toContain('[RNSentrySDK start];');
148+
expect(result.modResults.contents).toBe(objcExpected);
149+
});
150+
151+
it('should insert import statements only once in an Swift project', async () => {
152+
config.modResults.contents =
153+
'import UIKit\nimport RNSentrySDK\n\noverride func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {';
154+
155+
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;
156+
157+
const importCount = (result.modResults.contents.match(/import RNSentrySDK/g) || []).length;
158+
expect(importCount).toBe(1);
159+
});
160+
161+
it('should insert import statements only once in an Objective-C project', async () => {
162+
config.modResults.language = 'objc';
163+
config.modResults.contents =
164+
'#import "AppDelegate.h"\n#import <RNSentry/RNSentry.h>\n\n- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {';
165+
166+
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;
167+
168+
const importCount = (result.modResults.contents.match(/#import <RNSentry\/RNSentry.h>/g) || []).length;
169+
expect(importCount).toBe(1);
170+
});
171+
});
172+
173+
function createMockConfig(): MockedExpoConfig {
174+
return {
175+
name: 'test',
176+
slug: 'test',
177+
modResults: {
178+
path: 'samples/react-native/ios/AppDelegate.swift',
179+
contents: swiftContents,
180+
language: 'swift',
181+
},
182+
};
183+
}

0 commit comments

Comments
 (0)