1import spawnAsync from '@expo/spawn-async';
2import chalk from 'chalk';
3import fs from 'fs-extra';
4import inquirer from 'inquirer';
5import path from 'path';
6import readline from 'readline';
7
8import * as Directories from '../Directories';
9import * as Packages from '../Packages';
10import * as ProjectVersions from '../ProjectVersions';
11
12type ActionOptions = {
13  sdkVersion: string;
14  packages?: string;
15};
16
17type Package = {
18  name: string;
19  sourceDir: string;
20  buildDirRelative: string;
21};
22
23// There are a few packages that we want to exclude from shell app builds; they don't follow any
24// easy pattern so we just keep track of them manually here.
25export const EXCLUDED_PACKAGE_SLUGS = [
26  'expo-dev-client',
27  'expo-dev-launcher',
28  'expo-dev-menu',
29  'expo-dev-menu-interface',
30  'expo-module-template',
31  'expo-modules-test-core',
32  'unimodules-core',
33  'unimodules-react-native-adapter',
34];
35
36const EXPO_ROOT_DIR = Directories.getExpoRepositoryRootDir();
37const ANDROID_DIR = Directories.getAndroidDir();
38
39const REACT_ANDROID_PKG = {
40  name: 'ReactAndroid',
41  sourceDir: path.join(ANDROID_DIR, 'ReactAndroid'),
42  buildDirRelative: path.join('com', 'facebook', 'react'),
43};
44const EXPOVIEW_PKG = {
45  name: 'expoview',
46  sourceDir: path.join(ANDROID_DIR, 'expoview'),
47  buildDirRelative: path.join('host', 'exp', 'exponent', 'expoview'),
48};
49
50async function _findUnimodules(pkgDir: string): Promise<Package[]> {
51  const unimodules: Package[] = [];
52
53  const packages = await Packages.getListOfPackagesAsync();
54  for (const pkg of packages) {
55    if (!pkg.isSupportedOnPlatform('android') || !pkg.androidPackageName) continue;
56    unimodules.push({
57      name: pkg.packageSlug,
58      sourceDir: path.join(pkg.path, pkg.androidSubdirectory),
59      buildDirRelative: `${pkg.androidPackageName.replace(/\./g, '/')}/${pkg.packageSlug}`,
60    });
61  }
62
63  return unimodules;
64}
65
66async function _isPackageUpToDate(sourceDir: string, buildDir: string): Promise<boolean> {
67  try {
68    const sourceCommits = await _gitLogAsync(sourceDir);
69    const buildCommits = await _gitLogAsync(buildDir);
70
71    const latestSourceCommitSha = sourceCommits.lines[0].split(' ')[0];
72    const latestBuildCommitSha = buildCommits.lines[0].split(' ')[0];
73
74    // throws if source commit is not an ancestor of build commit
75    await spawnAsync(
76      'git',
77      ['merge-base', '--is-ancestor', latestSourceCommitSha, latestBuildCommitSha],
78      {
79        cwd: EXPO_ROOT_DIR,
80      }
81    );
82    return true;
83  } catch {
84    return false;
85  }
86}
87
88async function _gitLogAsync(path: string): Promise<{ lines: string[] }> {
89  const child = await spawnAsync('git', ['log', `--pretty=oneline`, '--', path], {
90    stdio: 'pipe',
91    cwd: EXPO_ROOT_DIR,
92  });
93
94  return {
95    lines: child.stdout
96      .trim()
97      .split(/\r?\n/g)
98      .filter((a) => a),
99  };
100}
101
102async function _getSuggestedPackagesToBuild(packages: Package[]): Promise<string[]> {
103  const packagesToBuild: string[] = [];
104  for (const pkg of packages) {
105    const isUpToDate = await _isPackageUpToDate(
106      pkg.sourceDir,
107      path.join(EXPO_ROOT_DIR, 'android', 'maven', pkg.buildDirRelative)
108    );
109    if (!isUpToDate) {
110      packagesToBuild.push(pkg.name);
111    }
112  }
113  return packagesToBuild;
114}
115
116async function _regexFileAsync(
117  filename: string,
118  regex: RegExp | string,
119  replace: string
120): Promise<void> {
121  const file = await fs.readFile(filename);
122  const fileString = file.toString();
123  await fs.writeFile(filename, fileString.replace(regex, replace));
124}
125
126const savedFiles = {};
127async function _stashFilesAsync(filenames: string[]): Promise<void> {
128  for (const filename of filenames) {
129    const file = await fs.readFile(filename);
130    savedFiles[filename] = file.toString();
131  }
132}
133
134async function _restoreFilesAsync(): Promise<void> {
135  for (const filename in savedFiles) {
136    await fs.writeFile(filename, savedFiles[filename]);
137    delete savedFiles[filename];
138  }
139}
140
141async function _commentWhenDistributing(filenames: string[]): Promise<void> {
142  for (const filename of filenames) {
143    await _regexFileAsync(
144      filename,
145      /\/\/ WHEN_DISTRIBUTING_REMOVE_FROM_HERE/g,
146      '/* WHEN_DISTRIBUTING_REMOVE_FROM_HERE'
147    );
148    await _regexFileAsync(
149      filename,
150      /\/\ WHEN_DISTRIBUTING_REMOVE_TO_HERE/g,
151      'WHEN_DISTRIBUTING_REMOVE_TO_HERE */'
152    );
153  }
154}
155
156async function _uncommentWhenDistributing(filenames: string[]): Promise<void> {
157  for (const filename of filenames) {
158    await _regexFileAsync(filename, '/* UNCOMMENT WHEN DISTRIBUTING', '');
159    await _regexFileAsync(filename, 'END UNCOMMENT WHEN DISTRIBUTING */', '');
160  }
161}
162
163async function _updateExpoViewAsync(packages: Package[], sdkVersion: string): Promise<number> {
164  const appBuildGradle = path.join(ANDROID_DIR, 'app', 'build.gradle');
165  const rootBuildGradle = path.join(ANDROID_DIR, 'build.gradle');
166  const expoViewBuildGradle = path.join(ANDROID_DIR, 'expoview', 'build.gradle');
167  const settingsGradle = path.join(ANDROID_DIR, 'settings.gradle');
168  const constantsJava = path.join(
169    ANDROID_DIR,
170    'expoview/src/main/java/host/exp/exponent/Constants.java'
171  );
172  const multipleVersionReactNativeActivity = path.join(
173    ANDROID_DIR,
174    'expoview/src/versioned/java/host/exp/exponent/experience/MultipleVersionReactNativeActivity.java'
175  );
176
177  // Modify permanently
178  await _regexFileAsync(expoViewBuildGradle, /version = '[\d.]+'/, `version = '${sdkVersion}'`);
179  await _regexFileAsync(
180    expoViewBuildGradle,
181    /api 'com.facebook.react:react-native:[\d.]+'/,
182    `api 'com.facebook.react:react-native:${sdkVersion}'`
183  );
184  await _regexFileAsync(
185    path.join(ANDROID_DIR, 'ReactAndroid', 'build.gradle'),
186    /version = '[\d.]+'/,
187    `version = '${sdkVersion}'`
188  );
189  await _regexFileAsync(
190    path.join(ANDROID_DIR, 'app', 'build.gradle'),
191    /host.exp.exponent:expoview:[\d.]+/,
192    `host.exp.exponent:expoview:${sdkVersion}`
193  );
194
195  await _stashFilesAsync([
196    appBuildGradle,
197    rootBuildGradle,
198    expoViewBuildGradle,
199    multipleVersionReactNativeActivity,
200    constantsJava,
201    settingsGradle,
202  ]);
203
204  // Modify temporarily
205  await _regexFileAsync(
206    constantsJava,
207    /TEMPORARY_ABI_VERSION\s*=\s*null/,
208    `TEMPORARY_ABI_VERSION = "${sdkVersion}"`
209  );
210  await _uncommentWhenDistributing([appBuildGradle, expoViewBuildGradle]);
211  await _commentWhenDistributing([
212    constantsJava,
213    rootBuildGradle,
214    expoViewBuildGradle,
215    multipleVersionReactNativeActivity,
216  ]);
217
218  // Clear maven local so that we don't end up with multiple versions
219  console.log(' ❌  Clearing old package versions...');
220
221  for (const pkg of packages) {
222    await fs.remove(path.join(process.env.HOME!, '.m2', 'repository', pkg.buildDirRelative));
223    await fs.remove(path.join(ANDROID_DIR, 'maven', pkg.buildDirRelative));
224    await fs.remove(path.join(pkg.sourceDir, 'build'));
225  }
226
227  // hacky workaround for weird issue where some packages need to be built twice after cleaning
228  // in order to have .so libs included in the aar
229  const reactAndroidIndex = packages.findIndex((pkg) => pkg.name === REACT_ANDROID_PKG.name);
230  if (reactAndroidIndex > -1) {
231    packages.splice(reactAndroidIndex, 0, REACT_ANDROID_PKG);
232  }
233  const expoviewIndex = packages.findIndex((pkg) => pkg.name === EXPOVIEW_PKG.name);
234  if (expoviewIndex > -1) {
235    packages.splice(expoviewIndex, 0, EXPOVIEW_PKG);
236  }
237
238  const failedPackages: string[] = [];
239  for (const pkg of packages) {
240    process.stdout.write(` ��   Building ${pkg.name}...`);
241    try {
242      await spawnAsync('./gradlew', [`:${pkg.name}:publish`], {
243        cwd: ANDROID_DIR,
244      });
245      readline.clearLine(process.stdout, 0);
246      readline.cursorTo(process.stdout, 0);
247      process.stdout.write(` ✅  Finished building ${pkg.name}\n`);
248    } catch (e) {
249      if (
250        e.status === 130 ||
251        e.signal === 'SIGINT' ||
252        e.status === 137 ||
253        e.signal === 'SIGKILL' ||
254        e.status === 143 ||
255        e.signal === 'SIGTERM'
256      ) {
257        throw e;
258      } else {
259        failedPackages.push(pkg.name);
260        readline.clearLine(process.stdout, 0);
261        readline.cursorTo(process.stdout, 0);
262        process.stdout.write(` ❌  Failed to build ${pkg.name}:\n`);
263        console.error(chalk.red(e.message));
264        console.error(chalk.red(e.stderr));
265      }
266    }
267  }
268
269  await _restoreFilesAsync();
270
271  console.log(' ��  Copying newly built packages...');
272
273  await fs.mkdirs(path.join(ANDROID_DIR, 'maven/com/facebook'));
274  await fs.mkdirs(path.join(ANDROID_DIR, 'maven/host/exp/exponent'));
275  await fs.mkdirs(path.join(ANDROID_DIR, 'maven/org/unimodules'));
276
277  for (const pkg of packages) {
278    if (failedPackages.includes(pkg.name)) {
279      continue;
280    }
281    await fs.copy(
282      path.join(process.env.HOME!, '.m2', 'repository', pkg.buildDirRelative),
283      path.join(ANDROID_DIR, 'maven', pkg.buildDirRelative)
284    );
285  }
286
287  if (failedPackages.length) {
288    console.log(' ❌  The following packages failed to build:');
289    console.log(failedPackages);
290    console.log(
291      `You will need to fix the compilation errors show in the logs above and then run \`et abp -s ${sdkVersion} -p ${failedPackages.join(
292        ','
293      )}\``
294    );
295  }
296
297  return failedPackages.length;
298}
299
300async function action(options: ActionOptions) {
301  process.on('SIGINT', _exitHandler);
302  process.on('SIGTERM', _exitHandler);
303
304  const detachableUniversalModules = (
305    await _findUnimodules(path.join(EXPO_ROOT_DIR, 'packages'))
306  ).filter((unimodule) => !EXCLUDED_PACKAGE_SLUGS.includes(unimodule.name));
307
308  // packages must stay in this order --
309  // ReactAndroid MUST be first and expoview MUST be last
310  const packages: Package[] = [REACT_ANDROID_PKG, ...detachableUniversalModules, EXPOVIEW_PKG];
311  let packagesToBuild: string[] = [];
312
313  const expoviewBuildGradle = await fs.readFile(path.join(ANDROID_DIR, 'expoview', 'build.gradle'));
314  const match = expoviewBuildGradle
315    .toString()
316    .match(/api 'com.facebook.react:react-native:([\d.]+)'/);
317  if (!match || !match[1]) {
318    throw new Error(
319      'Could not find SDK version in android/expoview/build.gradle: unexpected format'
320    );
321  }
322
323  if (match[1] !== options.sdkVersion) {
324    console.log(
325      " ��  It looks like you're adding a new SDK version. Ignoring the `--packages` option and rebuilding all packages..."
326    );
327    packagesToBuild = packages.map((pkg) => pkg.name);
328  } else if (options.packages) {
329    if (options.packages === 'all') {
330      packagesToBuild = packages.map((pkg) => pkg.name);
331    } else if (options.packages === 'suggested') {
332      console.log(' ��  Gathering data about packages...');
333      packagesToBuild = await _getSuggestedPackagesToBuild(packages);
334    } else {
335      const packageNames = options.packages.split(',');
336      packagesToBuild = packages
337        .map((pkg) => pkg.name)
338        .filter((pkgName) => packageNames.includes(pkgName));
339    }
340    console.log(' ��   Rebuilding the following packages:');
341    console.log(packagesToBuild);
342  } else {
343    // gather suggested package data and then show prompts
344    console.log(' ��  Gathering data...');
345
346    packagesToBuild = await _getSuggestedPackagesToBuild(packages);
347
348    console.log(' ��️   It appears that the following packages need to be rebuilt:');
349    console.log(packagesToBuild);
350
351    const { option } = await inquirer.prompt<{ option: string }>([
352      {
353        type: 'list',
354        name: 'option',
355        message: 'What would you like to do?',
356        choices: [
357          { value: 'suggested', name: 'Build the suggested packages only' },
358          { value: 'all', name: 'Build all packages' },
359          { value: 'choose', name: 'Choose packages manually' },
360        ],
361      },
362    ]);
363
364    if (option === 'all') {
365      packagesToBuild = packages.map((pkg) => pkg.name);
366    } else if (option === 'choose') {
367      const result = await inquirer.prompt<{ packagesToBuild: string[] }>([
368        {
369          type: 'checkbox',
370          name: 'packagesToBuild',
371          message: 'Choose which packages to build\n  ● selected ○ unselected\n',
372          choices: packages.map((pkg) => pkg.name),
373          default: packagesToBuild,
374          pageSize: Math.min(packages.length, (process.stdout.rows || 100) - 2),
375        },
376      ]);
377      packagesToBuild = result.packagesToBuild;
378    }
379  }
380
381  try {
382    const failedPackagesCount = await _updateExpoViewAsync(
383      packages.filter((pkg) => packagesToBuild.includes(pkg.name)),
384      options.sdkVersion
385    );
386    if (failedPackagesCount > 0) {
387      process.exitCode = 1;
388    }
389  } catch (e) {
390    await _exitHandler();
391    throw e;
392  }
393}
394
395async function _exitHandler(): Promise<void> {
396  if (Object.keys(savedFiles).length) {
397    console.log('Exited early, cleaning up...');
398    await _restoreFilesAsync();
399  }
400}
401
402export default (program: any) => {
403  program
404    .command('android-build-packages')
405    .alias('abp')
406    .description('Builds all Android AAR packages for Turtle')
407    .option('-s, --sdkVersion [string]', '[optional] SDK version')
408    .option(
409      '-p, --packages [string]',
410      '[optional] packages to build. May be `all`, `suggested`, or a comma-separate list of package names.'
411    )
412    .asyncAction(async (options: Partial<ActionOptions>) => {
413      const sdkVersion =
414        options.sdkVersion ?? (await ProjectVersions.getNewestSDKVersionAsync('android'));
415
416      if (!sdkVersion) {
417        throw new Error('Could not infer SDK version, please run with `--sdkVersion SDK_VERSION`');
418      }
419
420      await action({ ...options, sdkVersion });
421    });
422};
423