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