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 { resolveExpoUpdatesVersion } from './resolveExpoUpdatesVersion';
15import { withDevLauncherAppDelegate } from './withDevLauncherAppDelegate';
16
17const pkg = require('expo-dev-launcher/package.json');
18
19const DEV_LAUNCHER_ANDROID_IMPORT = 'expo.modules.devlauncher.DevLauncherController';
20const DEV_LAUNCHER_UPDATES_ANDROID_IMPORT = 'expo.modules.updates.UpdatesDevLauncherController';
21const DEV_LAUNCHER_ON_NEW_INTENT = `
22  @Override
23  public void onNewIntent(Intent intent) {
24      if (DevLauncherController.tryToHandleIntent(this, intent)) {
25         return;
26      }
27      super.onNewIntent(intent);
28  }
29`;
30const DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE = `DevLauncherController.wrapReactActivityDelegate(this, () -> $1);`;
31const DEV_LAUNCHER_ANDROID_INIT = 'DevLauncherController.initialize(this, getReactNativeHost());';
32const DEV_LAUNCHER_UPDATES_ANDROID_INIT = `if (BuildConfig.DEBUG) {
33      DevLauncherController.getInstance().setUpdatesInterface(UpdatesDevLauncherController.initialize(this));
34    }`;
35const DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT =
36  'return DevLauncherController.getInstance().getUseDeveloperSupport();';
37
38const DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS = `import 'expo-dev-client';`;
39
40async function readFileAsync(path: string): Promise<string> {
41  return fs.promises.readFile(path, 'utf8');
42}
43
44async function saveFileAsync(path: string, content: string): Promise<void> {
45  return fs.promises.writeFile(path, content, 'utf8');
46}
47
48function addLines(content: string, find: string | RegExp, offset: number, toAdd: string[]) {
49  const lines = content.split('\n');
50
51  let lineIndex = lines.findIndex(line => line.match(find));
52
53  for (const newLine of toAdd) {
54    if (!content.includes(newLine)) {
55      lines.splice(lineIndex + offset, 0, newLine);
56      lineIndex++;
57    }
58  }
59
60  return lines.join('\n');
61}
62
63function replaceLine(content: string, find: string | RegExp, replace: string) {
64  const lines = content.split('\n');
65
66  if (!content.includes(replace)) {
67    const lineIndex = lines.findIndex(line => line.match(find));
68    lines.splice(lineIndex, 1, replace);
69  }
70
71  return lines.join('\n');
72}
73
74function addJavaImports(javaSource: string, javaImports: string[]): string {
75  const lines = javaSource.split('\n');
76  const lineIndexWithPackageDeclaration = lines.findIndex(line => line.match(/^package .*;$/));
77  for (const javaImport of javaImports) {
78    if (!javaSource.includes(javaImport)) {
79      const importStatement = `import ${javaImport};`;
80      lines.splice(lineIndexWithPackageDeclaration + 1, 0, importStatement);
81    }
82  }
83  return lines.join('\n');
84}
85
86async function editMainApplication(
87  config: ExportedConfigWithProps,
88  action: (mainApplication: string) => string
89): Promise<void> {
90  const mainApplicationPath = path.join(
91    config.modRequest.platformProjectRoot,
92    'app',
93    'src',
94    'main',
95    'java',
96    ...config.android!.package!.split('.'),
97    'MainApplication.java'
98  );
99
100  try {
101    const mainApplication = action(await readFileAsync(mainApplicationPath));
102    return await saveFileAsync(mainApplicationPath, mainApplication);
103  } catch (e) {
104    WarningAggregator.addWarningIOS(
105      'expo-dev-launcher',
106      `Couldn't modify MainApplication.java - ${e}.`
107    );
108  }
109}
110
111async function editPodfile(config: ExportedConfigWithProps, action: (podfile: string) => string) {
112  const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile');
113  try {
114    const podfile = action(await readFileAsync(podfilePath));
115    return await saveFileAsync(podfilePath, podfile);
116  } catch (e) {
117    WarningAggregator.addWarningIOS('expo-dev-launcher', `Couldn't modify AppDelegate.m - ${e}.`);
118  }
119}
120
121async function editIndex(config: ExportedConfigWithProps, action: (index: string) => string) {
122  const indexPath = path.join(config.modRequest.projectRoot, 'index.js');
123  try {
124    const index = action(await readFileAsync(indexPath));
125    return await saveFileAsync(indexPath, index);
126  } catch (e) {
127    WarningAggregator.addWarningIOS('expo-dev-launcher', `Couldn't modify index.js - ${e}.`);
128  }
129}
130
131const withDevLauncherApplication: ConfigPlugin = config => {
132  return withDangerousMod(config, [
133    'android',
134    async config => {
135      await editMainApplication(config, mainApplication => {
136        mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_ANDROID_IMPORT]);
137
138        mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [
139          `    ${DEV_LAUNCHER_ANDROID_INIT}`,
140        ]);
141
142        let expoUpdatesVersion;
143        try {
144          expoUpdatesVersion = resolveExpoUpdatesVersion(config.modRequest.projectRoot);
145        } catch (e) {
146          WarningAggregator.addWarningAndroid(
147            'expo-dev-launcher',
148            `Failed to check compatibility with expo-updates - ${e}`
149          );
150        }
151        if (expoUpdatesVersion && semver.gt(expoUpdatesVersion, '0.6.0')) {
152          mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_UPDATES_ANDROID_IMPORT]);
153          mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [
154            `    ${DEV_LAUNCHER_UPDATES_ANDROID_INIT}`,
155          ]);
156          mainApplication = replaceLine(
157            mainApplication,
158            'return BuildConfig.DEBUG;',
159            `      ${DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT}`
160          );
161        }
162
163        return mainApplication;
164      });
165      return config;
166    },
167  ]);
168};
169
170const withDevLauncherActivity: ConfigPlugin = config => {
171  return withMainActivity(config, config => {
172    if (config.modResults.language === 'java') {
173      let content = addJavaImports(config.modResults.contents, [
174        DEV_LAUNCHER_ANDROID_IMPORT,
175        'android.content.Intent',
176      ]);
177
178      if (!content.includes(DEV_LAUNCHER_ON_NEW_INTENT)) {
179        const lines = content.split('\n');
180        const onCreateIndex = lines.findIndex(line => line.includes('public class MainActivity'));
181
182        lines.splice(onCreateIndex + 1, 0, DEV_LAUNCHER_ON_NEW_INTENT);
183
184        content = lines.join('\n');
185      }
186
187      if (!content.includes('DevLauncherController.wrapReactActivityDelegate')) {
188        content = content.replace(
189          /(new ReactActivityDelegate(.*|\s)*});$/m,
190          DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE
191        );
192      }
193
194      config.modResults.contents = content;
195    } else {
196      WarningAggregator.addWarningAndroid(
197        'expo-dev-launcher',
198        `Cannot automatically configure MainActivity if it's not java`
199      );
200    }
201
202    return config;
203  });
204};
205
206const withDevLauncherPodfile: ConfigPlugin = config => {
207  return withDangerousMod(config, [
208    'ios',
209    async config => {
210      await editPodfile(config, podfile => {
211        podfile = podfile.replace("platform :ios, '10.0'", "platform :ios, '11.0'");
212        // Match both variations of Ruby config:
213        // unknown: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', :configurations => :debug
214        // Rubocop: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', configurations: :debug
215        if (
216          !podfile.match(
217            /pod ['"]expo-dev-launcher['"],\s?path: ['"][^'"]*node_modules\/expo-dev-launcher['"],\s?:?configurations:?\s(?:=>\s)?:debug/
218          )
219        ) {
220          const packagePath = path.dirname(require.resolve('expo-dev-launcher/package.json'));
221          const relativePath = path.relative(config.modRequest.platformProjectRoot, packagePath);
222          podfile = addLines(podfile, 'use_react_native', 0, [
223            `  pod 'expo-dev-launcher', path: '${relativePath}', :configurations => :debug`,
224          ]);
225        }
226        return podfile;
227      });
228      return config;
229    },
230  ]);
231};
232
233const withErrorHandling: ConfigPlugin = config => {
234  const injectErrorHandlers = async (config: ExportedConfigWithProps) => {
235    await editIndex(config, index => {
236      if (!index.includes(DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS)) {
237        index = DEV_LAUNCHER_JS_REGISTER_ERROR_HANDLERS + '\n\n' + index;
238      }
239      return index;
240    });
241    return config;
242  };
243
244  // We need to run the same task twice to ensure it will work on both platforms,
245  // because if someone runs `expo run:ios`, it will trigger only dangerous mode for that specific platform.
246  // Note: after the first execution, the second one won't change anything.
247  config = withDangerousMod(config, ['android', injectErrorHandlers]);
248  config = withDangerousMod(config, ['ios', injectErrorHandlers]);
249
250  return config;
251};
252
253const withDevLauncher = (config: ExpoConfig) => {
254  config = withDevLauncherActivity(config);
255  config = withDevLauncherApplication(config);
256  config = withDevLauncherPodfile(config);
257  config = withDevLauncherAppDelegate(config);
258  config = withErrorHandling(config);
259  return config;
260};
261
262export default createRunOncePlugin(withDevLauncher, pkg.name, pkg.version);
263