1import {
2  ConfigPlugin,
3  createRunOncePlugin,
4  ExportedConfigWithProps,
5  WarningAggregator,
6  withDangerousMod,
7  withMainActivity,
8} from '@expo/config-plugins';
9import { ExpoConfig } from '@expo/config-types';
10import fs from 'fs';
11import path from 'path';
12import semver from 'semver';
13
14import { InstallationPage } from './constants';
15import { resolveExpoUpdatesVersion } from './resolveExpoUpdatesVersion';
16import { addLines, replaceLine } from './utils';
17import { withDevLauncherAppDelegate } from './withDevLauncherAppDelegate';
18
19const pkg = require('expo-dev-launcher/package.json');
20
21const DEV_LAUNCHER_ANDROID_IMPORT = 'expo.modules.devlauncher.DevLauncherController';
22const DEV_LAUNCHER_UPDATES_ANDROID_IMPORT = 'expo.modules.updates.UpdatesDevLauncherController';
23const DEV_LAUNCHER_ON_NEW_INTENT = [
24  '',
25  '  @Override',
26  '  public void onNewIntent(Intent intent) {',
27  '    super.onNewIntent(intent);',
28  '  }',
29  '',
30].join('\n');
31const DEV_LAUNCHER_HANDLE_INTENT = [
32  '    if (DevLauncherController.tryToHandleIntent(this, intent)) {',
33  '      return;',
34  '    }',
35].join('\n');
36const DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE = (activityDelegateDeclaration: string) =>
37  `DevLauncherController.wrapReactActivityDelegate(this, () -> ${activityDelegateDeclaration})`;
38const DEV_LAUNCHER_ANDROID_INIT = 'DevLauncherController.initialize(this, getReactNativeHost());';
39const DEV_LAUNCHER_UPDATES_ANDROID_INIT = `if (BuildConfig.DEBUG) {
40      DevLauncherController.getInstance().setUpdatesInterface(UpdatesDevLauncherController.initialize(this));
41    }`;
42const DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT =
43  'return DevLauncherController.getInstance().getUseDeveloperSupport();';
44
45const DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS_REGEX =
46  /import ['"](?:expo-dev-client|expo-dev-launcher)['"]/;
47
48async function readFileAsync(path: string): Promise<string> {
49  return fs.promises.readFile(path, 'utf8');
50}
51
52async function saveFileAsync(path: string, content: string): Promise<void> {
53  return fs.promises.writeFile(path, content, 'utf8');
54}
55
56function findClosingBracketMatchIndex(str: string, pos: number) {
57  if (str[pos] !== '(') {
58    throw new Error("No '(' at index " + pos);
59  }
60  let depth = 1;
61  for (let i = pos + 1; i < str.length; i++) {
62    switch (str[i]) {
63      case '(':
64        depth++;
65        break;
66      case ')':
67        if (--depth === 0) {
68          return i;
69        }
70        break;
71    }
72  }
73  return -1; // No matching closing parenthesis
74}
75
76const replaceBetween = (origin: string, startIndex: number, endIndex: number, insertion: string) =>
77  `${origin.substring(0, startIndex)}${insertion}${origin.substring(endIndex)}`;
78
79function addJavaImports(javaSource: string, javaImports: string[]): string {
80  const lines = javaSource.split('\n');
81  const lineIndexWithPackageDeclaration = lines.findIndex((line) => line.match(/^package .*;$/));
82  for (const javaImport of javaImports) {
83    if (!javaSource.includes(javaImport)) {
84      const importStatement = `import ${javaImport};`;
85      lines.splice(lineIndexWithPackageDeclaration + 1, 0, importStatement);
86    }
87  }
88  return lines.join('\n');
89}
90
91async function editMainApplication(
92  config: ExportedConfigWithProps,
93  action: (mainApplication: string) => string
94): Promise<void> {
95  const mainApplicationPath = path.join(
96    config.modRequest.platformProjectRoot,
97    'app',
98    'src',
99    'main',
100    'java',
101    ...config.android!.package!.split('.'),
102    'MainApplication.java'
103  );
104
105  try {
106    const mainApplication = action(await readFileAsync(mainApplicationPath));
107    return await saveFileAsync(mainApplicationPath, mainApplication);
108  } catch (e) {
109    WarningAggregator.addWarningAndroid(
110      'expo-dev-launcher',
111      `Couldn't modify MainApplication.java - ${e}.
112See the expo-dev-client installation instructions to modify your MainApplication.java manually: ${InstallationPage}`
113    );
114  }
115}
116
117async function editPodfile(config: ExportedConfigWithProps, action: (podfile: string) => string) {
118  const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile');
119  try {
120    const podfile = action(await readFileAsync(podfilePath));
121    return await saveFileAsync(podfilePath, podfile);
122  } catch (e) {
123    WarningAggregator.addWarningIOS(
124      'expo-dev-launcher',
125      `Couldn't modify AppDelegate.m - ${e}.
126See the expo-dev-client installation instructions to modify your AppDelegate.m manually: ${InstallationPage}`
127    );
128  }
129}
130
131async function editIndex(config: ExportedConfigWithProps, action: (index: string) => string) {
132  const indexPath = path.join(config.modRequest.projectRoot, 'index.js');
133  try {
134    const index = action(await readFileAsync(indexPath));
135    return await saveFileAsync(indexPath, index);
136  } catch (e) {
137    WarningAggregator.addWarningIOS(
138      'expo-dev-launcher',
139      `Couldn't modify index.js - ${e}.
140See the expo-dev-client installation instructions to modify your index.js manually: ${InstallationPage}`
141    );
142  }
143}
144
145const withDevLauncherApplication: ConfigPlugin = (config) => {
146  return withDangerousMod(config, [
147    'android',
148    async (config) => {
149      await editMainApplication(config, (mainApplication) => {
150        mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_ANDROID_IMPORT]);
151
152        mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [
153          `    ${DEV_LAUNCHER_ANDROID_INIT}`,
154        ]);
155
156        let expoUpdatesVersion;
157        try {
158          expoUpdatesVersion = resolveExpoUpdatesVersion(config.modRequest.projectRoot);
159        } catch (e) {
160          WarningAggregator.addWarningAndroid(
161            'expo-dev-launcher',
162            `Failed to check compatibility with expo-updates - ${e}`
163          );
164        }
165        if (expoUpdatesVersion && semver.gt(expoUpdatesVersion, '0.6.0')) {
166          mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_UPDATES_ANDROID_IMPORT]);
167          mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [
168            `    ${DEV_LAUNCHER_UPDATES_ANDROID_INIT}`,
169          ]);
170          mainApplication = replaceLine(
171            mainApplication,
172            'return BuildConfig.DEBUG;',
173            `      ${DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT}`
174          );
175        }
176
177        return mainApplication;
178      });
179      return config;
180    },
181  ]);
182};
183
184export function modifyJavaMainActivity(content: string): string {
185  content = addJavaImports(content, [DEV_LAUNCHER_ANDROID_IMPORT, 'android.content.Intent']);
186
187  if (!content.includes('onNewIntent')) {
188    const lines = content.split('\n');
189    const onCreateIndex = lines.findIndex((line) => line.includes('public class MainActivity'));
190
191    lines.splice(onCreateIndex + 1, 0, DEV_LAUNCHER_ON_NEW_INTENT);
192
193    content = lines.join('\n');
194  }
195  if (!content.includes(DEV_LAUNCHER_HANDLE_INTENT)) {
196    content = addLines(content, /super\.onNewIntent\(intent\)/, 0, [DEV_LAUNCHER_HANDLE_INTENT]);
197  }
198
199  if (!content.includes('DevLauncherController.wrapReactActivityDelegate')) {
200    const activityDelegateMatches = Array.from(
201      content.matchAll(/new ReactActivityDelegate(Wrapper)/g)
202    );
203
204    if (activityDelegateMatches.length !== 1) {
205      WarningAggregator.addWarningAndroid(
206        'expo-dev-launcher',
207        `Failed to wrap 'ReactActivityDelegate'
208See the expo-dev-client installation instructions to modify your MainActivity.java manually: ${InstallationPage}`
209      );
210      return content;
211    }
212
213    const activityDelegateMatch = activityDelegateMatches[0];
214    const matchIndex = activityDelegateMatch.index!;
215    const openingBracketIndex = matchIndex + activityDelegateMatch[0].length; // next character after `new ReactActivityDelegateWrapper`
216
217    const closingBracketIndex = findClosingBracketMatchIndex(content, openingBracketIndex);
218    const reactActivityDelegateDeclaration = content.substring(matchIndex, closingBracketIndex + 1);
219
220    content = replaceBetween(
221      content,
222      matchIndex,
223      closingBracketIndex + 1,
224      DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE(reactActivityDelegateDeclaration)
225    );
226  }
227  return content;
228}
229
230const withDevLauncherActivity: ConfigPlugin = (config) => {
231  return withMainActivity(config, (config) => {
232    if (config.modResults.language === 'java') {
233      config.modResults.contents = modifyJavaMainActivity(config.modResults.contents);
234    } else {
235      WarningAggregator.addWarningAndroid(
236        'expo-dev-launcher',
237        `Cannot automatically configure MainActivity if it's not java.
238See the expo-dev-client installation instructions to modify your MainActivity manually: ${InstallationPage}`
239      );
240    }
241
242    return config;
243  });
244};
245
246const withDevLauncherPodfile: ConfigPlugin = (config) => {
247  return withDangerousMod(config, [
248    'ios',
249    async (config) => {
250      await editPodfile(config, (podfile) => {
251        // replace all iOS versions below 12
252        podfile = podfile.replace(/platform :ios, '((\d\.0)|(1[0-1].0))'/, "platform :ios, '12.0'");
253        // Match both variations of Ruby config:
254        // unknown: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', :configurations => :debug
255        // Rubocop: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', configurations: :debug
256        if (
257          !podfile.match(
258            /pod ['"]expo-dev-launcher['"],\s?path: ['"][^'"]*node_modules\/expo-dev-launcher['"],\s?:?configurations:?\s(?:=>\s)?:debug/
259          )
260        ) {
261          const packagePath = path.dirname(require.resolve('expo-dev-launcher/package.json'));
262          const relativePath = path.relative(config.modRequest.platformProjectRoot, packagePath);
263          podfile = addLines(podfile, 'use_react_native', 0, [
264            `  pod 'expo-dev-launcher', path: '${relativePath}', :configurations => :debug`,
265          ]);
266        }
267        return podfile;
268      });
269      return config;
270    },
271  ]);
272};
273
274const withErrorHandling: ConfigPlugin = (config) => {
275  const injectErrorHandlers = async (config: ExportedConfigWithProps) => {
276    await editIndex(config, (index) => {
277      if (!DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS_REGEX.test(index)) {
278        index = `import 'expo-dev-client';\n\n${index}`;
279      }
280      return index;
281    });
282    return config;
283  };
284
285  // We need to run the same task twice to ensure it will work on both platforms,
286  // because if someone runs `expo run:ios`, it will trigger only dangerous mode for that specific platform.
287  // Note: after the first execution, the second one won't change anything.
288  config = withDangerousMod(config, ['android', injectErrorHandlers]);
289  config = withDangerousMod(config, ['ios', injectErrorHandlers]);
290
291  return config;
292};
293
294const withDevLauncher = (config: ExpoConfig) => {
295  // projects using SDKs before 45 need the old regex-based integration
296  // TODO: remove these once we drop support for SDK 44
297  if (config.sdkVersion && semver.lt(config.sdkVersion, '45.0.0')) {
298    config = withDevLauncherActivity(config);
299    config = withDevLauncherApplication(config);
300    config = withDevLauncherPodfile(config);
301    config = withDevLauncherAppDelegate(config);
302  }
303  config = withErrorHandling(config);
304  return config;
305};
306
307export default createRunOncePlugin(withDevLauncher, pkg.name, pkg.version);
308