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