1import { ExpoConfig } from 'expo/config';
2import {
3  ConfigPlugin,
4  createRunOncePlugin,
5  ExportedConfigWithProps,
6  WarningAggregator,
7  withDangerousMod,
8  withMainActivity,
9} from 'expo/config-plugins';
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
45async function readFileAsync(path: string): Promise<string> {
46  return fs.promises.readFile(path, 'utf8');
47}
48
49async function saveFileAsync(path: string, content: string): Promise<void> {
50  return fs.promises.writeFile(path, content, 'utf8');
51}
52
53function findClosingBracketMatchIndex(str: string, pos: number) {
54  if (str[pos] !== '(') {
55    throw new Error("No '(' at index " + pos);
56  }
57  let depth = 1;
58  for (let i = pos + 1; i < str.length; i++) {
59    switch (str[i]) {
60      case '(':
61        depth++;
62        break;
63      case ')':
64        if (--depth === 0) {
65          return i;
66        }
67        break;
68    }
69  }
70  return -1; // No matching closing parenthesis
71}
72
73const replaceBetween = (origin: string, startIndex: number, endIndex: number, insertion: string) =>
74  `${origin.substring(0, startIndex)}${insertion}${origin.substring(endIndex)}`;
75
76function addJavaImports(javaSource: string, javaImports: string[]): string {
77  const lines = javaSource.split('\n');
78  const lineIndexWithPackageDeclaration = lines.findIndex((line) => line.match(/^package .*;$/));
79  for (const javaImport of javaImports) {
80    if (!javaSource.includes(javaImport)) {
81      const importStatement = `import ${javaImport};`;
82      lines.splice(lineIndexWithPackageDeclaration + 1, 0, importStatement);
83    }
84  }
85  return lines.join('\n');
86}
87
88async function editMainApplication(
89  config: ExportedConfigWithProps,
90  action: (mainApplication: string) => string
91): Promise<void> {
92  const mainApplicationPath = path.join(
93    config.modRequest.platformProjectRoot,
94    'app',
95    'src',
96    'main',
97    'java',
98    ...config.android!.package!.split('.'),
99    'MainApplication.java'
100  );
101
102  try {
103    const mainApplication = action(await readFileAsync(mainApplicationPath));
104    return await saveFileAsync(mainApplicationPath, mainApplication);
105  } catch (e) {
106    WarningAggregator.addWarningAndroid(
107      'expo-dev-launcher',
108      `Couldn't modify MainApplication.java - ${e}.
109See the expo-dev-client installation instructions to modify your MainApplication.java manually: ${InstallationPage}`
110    );
111  }
112}
113
114async function editPodfile(config: ExportedConfigWithProps, action: (podfile: string) => string) {
115  const podfilePath = path.join(config.modRequest.platformProjectRoot, 'Podfile');
116  try {
117    const podfile = action(await readFileAsync(podfilePath));
118    return await saveFileAsync(podfilePath, podfile);
119  } catch (e) {
120    WarningAggregator.addWarningIOS(
121      'expo-dev-launcher',
122      `Couldn't modify AppDelegate.m - ${e}.
123See the expo-dev-client installation instructions to modify your AppDelegate.m manually: ${InstallationPage}`
124    );
125  }
126}
127
128const withDevLauncherApplication: ConfigPlugin = (config) => {
129  return withDangerousMod(config, [
130    'android',
131    async (config) => {
132      await editMainApplication(config, (mainApplication) => {
133        mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_ANDROID_IMPORT]);
134
135        mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [
136          `    ${DEV_LAUNCHER_ANDROID_INIT}`,
137        ]);
138
139        let expoUpdatesVersion;
140        try {
141          expoUpdatesVersion = resolveExpoUpdatesVersion(config.modRequest.projectRoot);
142        } catch (e) {
143          WarningAggregator.addWarningAndroid(
144            'expo-dev-launcher',
145            `Failed to check compatibility with expo-updates - ${e}`
146          );
147        }
148        if (expoUpdatesVersion && semver.gt(expoUpdatesVersion, '0.6.0')) {
149          mainApplication = addJavaImports(mainApplication, [DEV_LAUNCHER_UPDATES_ANDROID_IMPORT]);
150          mainApplication = addLines(mainApplication, 'initializeFlipper\\(this', 0, [
151            `    ${DEV_LAUNCHER_UPDATES_ANDROID_INIT}`,
152          ]);
153          mainApplication = replaceLine(
154            mainApplication,
155            'return BuildConfig.DEBUG;',
156            `      ${DEV_LAUNCHER_UPDATES_DEVELOPER_SUPPORT}`
157          );
158        }
159
160        return mainApplication;
161      });
162      return config;
163    },
164  ]);
165};
166
167export function modifyJavaMainActivity(content: string): string {
168  content = addJavaImports(content, [DEV_LAUNCHER_ANDROID_IMPORT, 'android.content.Intent']);
169
170  if (!content.includes('onNewIntent')) {
171    const lines = content.split('\n');
172    const onCreateIndex = lines.findIndex((line) => line.includes('public class MainActivity'));
173
174    lines.splice(onCreateIndex + 1, 0, DEV_LAUNCHER_ON_NEW_INTENT);
175
176    content = lines.join('\n');
177  }
178  if (!content.includes(DEV_LAUNCHER_HANDLE_INTENT)) {
179    content = addLines(content, /super\.onNewIntent\(intent\)/, 0, [DEV_LAUNCHER_HANDLE_INTENT]);
180  }
181
182  if (!content.includes('DevLauncherController.wrapReactActivityDelegate')) {
183    const activityDelegateMatches = Array.from(
184      content.matchAll(/new ReactActivityDelegate(Wrapper)/g)
185    );
186
187    if (activityDelegateMatches.length !== 1) {
188      WarningAggregator.addWarningAndroid(
189        'expo-dev-launcher',
190        `Failed to wrap 'ReactActivityDelegate'
191See the expo-dev-client installation instructions to modify your MainActivity.java manually: ${InstallationPage}`
192      );
193      return content;
194    }
195
196    const activityDelegateMatch = activityDelegateMatches[0];
197    const matchIndex = activityDelegateMatch.index!;
198    const openingBracketIndex = matchIndex + activityDelegateMatch[0].length; // next character after `new ReactActivityDelegateWrapper`
199
200    const closingBracketIndex = findClosingBracketMatchIndex(content, openingBracketIndex);
201    const reactActivityDelegateDeclaration = content.substring(matchIndex, closingBracketIndex + 1);
202
203    content = replaceBetween(
204      content,
205      matchIndex,
206      closingBracketIndex + 1,
207      DEV_LAUNCHER_WRAPPED_ACTIVITY_DELEGATE(reactActivityDelegateDeclaration)
208    );
209  }
210  return content;
211}
212
213const withDevLauncherActivity: ConfigPlugin = (config) => {
214  return withMainActivity(config, (config) => {
215    if (config.modResults.language === 'java') {
216      config.modResults.contents = modifyJavaMainActivity(config.modResults.contents);
217    } else {
218      WarningAggregator.addWarningAndroid(
219        'expo-dev-launcher',
220        `Cannot automatically configure MainActivity if it's not java.
221See the expo-dev-client installation instructions to modify your MainActivity manually: ${InstallationPage}`
222      );
223    }
224
225    return config;
226  });
227};
228
229const withDevLauncherPodfile: ConfigPlugin = (config) => {
230  return withDangerousMod(config, [
231    'ios',
232    async (config) => {
233      await editPodfile(config, (podfile) => {
234        // replace all iOS versions below 12
235        podfile = podfile.replace(/platform :ios, '((\d\.0)|(1[0-1].0))'/, "platform :ios, '13.0'");
236        // Match both variations of Ruby config:
237        // unknown: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', :configurations => :debug
238        // Rubocop: pod 'expo-dev-launcher', path: '../node_modules/expo-dev-launcher', configurations: :debug
239        if (
240          !podfile.match(
241            /pod ['"]expo-dev-launcher['"],\s?path: ['"][^'"]*node_modules\/expo-dev-launcher['"],\s?:?configurations:?\s(?:=>\s)?:debug/
242          )
243        ) {
244          const packagePath = path.dirname(require.resolve('expo-dev-launcher/package.json'));
245          const relativePath = path.relative(config.modRequest.platformProjectRoot, packagePath);
246          podfile = addLines(podfile, 'use_react_native', 0, [
247            `  pod 'expo-dev-launcher', path: '${relativePath}', :configurations => :debug`,
248          ]);
249        }
250        return podfile;
251      });
252      return config;
253    },
254  ]);
255};
256
257const withDevLauncher = (config: ExpoConfig) => {
258  // projects using SDKs before 45 need the old regex-based integration
259  // TODO: remove these once we drop support for SDK 44
260  if (config.sdkVersion && semver.lt(config.sdkVersion, '45.0.0')) {
261    config = withDevLauncherActivity(config);
262    config = withDevLauncherApplication(config);
263    config = withDevLauncherPodfile(config);
264    config = withDevLauncherAppDelegate(config);
265  }
266  return config;
267};
268
269export default createRunOncePlugin(withDevLauncher, pkg.name, pkg.version);
270