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