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