xref: /expo/tools/src/versioning/android/index.ts (revision 9d7b0c19)
1import spawnAsync from '@expo/spawn-async';
2import chalk from 'chalk';
3import fs from 'fs-extra';
4import glob from 'glob-promise';
5import minimatch from 'minimatch';
6import path from 'path';
7import semver from 'semver';
8
9import * as Directories from '../../Directories';
10import { getListOfPackagesAsync } from '../../Packages';
11import { copyFileWithTransformsAsync } from '../../Transforms';
12import { searchFilesAsync } from '../../Utils';
13import { copyExpoviewAsync } from './copyExpoview';
14import { expoModulesTransforms } from './expoModulesTransforms';
15import { packagesToKeep } from './packagesConfig';
16import { versionCxxExpoModulesAsync } from './versionCxx';
17import { updateVersionedReactNativeAsync } from './versionReactNative';
18import { removeVersionedVendoredModulesAsync } from './versionVendoredModules';
19
20export { versionVendoredModulesAsync } from './versionVendoredModules';
21
22const EXPO_DIR = Directories.getExpoRepositoryRootDir();
23const ANDROID_DIR = Directories.getAndroidDir();
24const EXPOTOOLS_DIR = Directories.getExpotoolsDir();
25const SCRIPT_DIR = path.join(EXPOTOOLS_DIR, 'src/versioning/android');
26const SED_PREFIX = process.platform === 'darwin' ? "sed -i ''" : 'sed -i';
27
28const appPath = path.join(ANDROID_DIR, 'app');
29const expoviewPath = path.join(ANDROID_DIR, 'expoview');
30const versionedAbisPath = path.join(ANDROID_DIR, 'versioned-abis');
31const versionedExpoviewAbiPath = (abiName) => path.join(versionedAbisPath, `expoview-${abiName}`);
32const expoviewBuildGradlePath = path.join(expoviewPath, 'build.gradle');
33const appManifestPath = path.join(appPath, 'src', 'main', 'AndroidManifest.xml');
34const templateManifestPath = path.join(
35  EXPO_DIR,
36  'template-files',
37  'android',
38  'AndroidManifest.xml'
39);
40const settingsGradlePath = path.join(ANDROID_DIR, 'settings.gradle');
41const appBuildGradlePath = path.join(appPath, 'build.gradle');
42const buildGradlePath = path.join(ANDROID_DIR, 'build.gradle');
43const sdkVersionsPath = path.join(ANDROID_DIR, 'sdkVersions.json');
44const rnActivityPath = path.join(
45  expoviewPath,
46  'src/versioned/java/host/exp/exponent/experience/MultipleVersionReactNativeActivity.java'
47);
48const expoviewConstantsPath = path.join(
49  expoviewPath,
50  'src/main/java/host/exp/exponent/Constants.java'
51);
52const testSuiteTestsPath = path.join(
53  appPath,
54  'src/androidTest/java/host/exp/exponent/TestSuiteTests.kt'
55);
56const versionedReactAndroidPath = path.join(ANDROID_DIR, 'versioned-react-native/ReactAndroid');
57const versionedHermesPath = path.join(ANDROID_DIR, 'versioned-react-native/sdks/hermes');
58
59async function transformFileAsync(filePath: string, regexp: RegExp, replacement: string = '') {
60  const fileContent = await fs.readFile(filePath, 'utf8');
61  await fs.writeFile(filePath, fileContent.replace(regexp, replacement));
62}
63
64async function removeVersionReferencesFromFileAsync(sdkMajorVersion: string, filePath: string) {
65  console.log(
66    `Removing code surrounded by ${chalk.gray(`// BEGIN_SDK_${sdkMajorVersion}`)} and ${chalk.gray(
67      `// END_SDK_${sdkMajorVersion}`
68    )} from ${chalk.magenta(path.relative(EXPO_DIR, filePath))}...`
69  );
70  await transformFileAsync(
71    filePath,
72    new RegExp(
73      `\\s*//\\s*BEGIN_SDK_${sdkMajorVersion}(_\d+)*\\n.*?//\\s*END_SDK_${sdkMajorVersion}(_\d+)*`,
74      'gs'
75    ),
76    ''
77  );
78}
79
80async function removeVersionedExpoviewAsync(versionedExpoviewAbiPath: string) {
81  console.log(
82    `Removing versioned expoview at ${chalk.magenta(
83      path.relative(EXPO_DIR, versionedExpoviewAbiPath)
84    )}...`
85  );
86  await fs.remove(versionedExpoviewAbiPath);
87}
88
89async function removeFromManifestAsync(sdkMajorVersion: string, manifestPath: string) {
90  console.log(
91    `Removing code surrounded by ${chalk.gray(
92      `<!-- BEGIN_SDK_${sdkMajorVersion} -->`
93    )} and ${chalk.gray(`<!-- END_SDK_${sdkMajorVersion} -->`)} from ${chalk.magenta(
94      path.relative(EXPO_DIR, manifestPath)
95    )}...`
96  );
97  await transformFileAsync(
98    manifestPath,
99    new RegExp(
100      `\\s*<!--\\s*BEGIN_SDK_${sdkMajorVersion}(_\d+)*\\s*-->.*?<!--\\s*END_SDK_${sdkMajorVersion}(_\d+)*\\s*-->`,
101      'gs'
102    ),
103    ''
104  );
105}
106
107async function removeFromSettingsGradleAsync(abiName: string, settingsGradlePath: string) {
108  console.log(
109    `Removing ${chalk.green(`expoview-${abiName}`)} from ${chalk.magenta(
110      path.relative(EXPO_DIR, settingsGradlePath)
111    )}...`
112  );
113  const sdkVersion = abiName.replace(/abi(\d+)_0_0/, 'sdk$1');
114  await transformFileAsync(settingsGradlePath, new RegExp(`\\n\\s*"${abiName}",[^\\n]*`, 'g'), '');
115  await transformFileAsync(
116    settingsGradlePath,
117    new RegExp(`\\nuseVendoredModulesForSettingsGradle\\('${sdkVersion}'\\)[^\\n]*`, 'g'),
118    ''
119  );
120}
121
122async function removeFromBuildGradleAsync(abiName: string, buildGradlePath: string) {
123  console.log(
124    `Removing maven repository for ${chalk.green(`expoview-${abiName}`)} from ${chalk.magenta(
125      path.relative(EXPO_DIR, buildGradlePath)
126    )}...`
127  );
128  await transformFileAsync(
129    buildGradlePath,
130    new RegExp(`\\s*maven\\s*{\\s*url\\s*".*?/expoview-${abiName}/maven"\\s*}[^\\n]*`),
131    ''
132  );
133}
134
135async function removeFromSdkVersionsAsync(version: string, sdkVersionsPath: string) {
136  console.log(
137    `Removing ${chalk.cyan(version)} from ${chalk.magenta(
138      path.relative(EXPO_DIR, sdkVersionsPath)
139    )}...`
140  );
141  await transformFileAsync(sdkVersionsPath, new RegExp(`"${version}",\s*`, 'g'), '');
142}
143
144async function removeTestSuiteTestsAsync(version: string, testsFilePath: string) {
145  console.log(
146    `Removing test-suite tests from ${chalk.magenta(path.relative(EXPO_DIR, testsFilePath))}...`
147  );
148  await transformFileAsync(
149    testsFilePath,
150    new RegExp(`\\s*(@\\w+\\s+)*@ExpoSdkVersionTest\\("${version}"\\)[^}]+}`),
151    ''
152  );
153}
154
155async function findAndPrintVersionReferencesInSourceFilesAsync(version: string): Promise<boolean> {
156  const pattern = new RegExp(
157    `(${version.replace(/\./g, '[._]')}|(SDK|ABI).?${semver.major(version)})`,
158    'ig'
159  );
160  let matchesCount = 0;
161
162  const files = await glob('**/{src/**/*.@(java|kt|xml),build.gradle}', {
163    cwd: ANDROID_DIR,
164    ignore: 'vendored/**/*',
165  });
166
167  for (const file of files) {
168    const filePath = path.join(ANDROID_DIR, file);
169    const fileContent = await fs.readFile(filePath, 'utf8');
170    const fileLines = fileContent.split(/\r\n?|\n/g);
171    let match;
172
173    while ((match = pattern.exec(fileContent)) != null) {
174      const index = pattern.lastIndex - match[0].length;
175      const lineNumberWithMatch = fileContent.substring(0, index).split(/\r\n?|\n/g).length - 1;
176      const firstLineInContext = Math.max(0, lineNumberWithMatch - 2);
177      const lastLineInContext = Math.min(lineNumberWithMatch + 2, fileLines.length);
178
179      ++matchesCount;
180
181      console.log(
182        `Found ${chalk.bold.green(match[0])} in ${chalk.magenta(
183          path.relative(EXPO_DIR, filePath)
184        )}:`
185      );
186
187      for (let lineIndex = firstLineInContext; lineIndex <= lastLineInContext; lineIndex++) {
188        console.log(
189          `${chalk.gray(1 + lineIndex + ':')} ${fileLines[lineIndex].replace(
190            match[0],
191            chalk.bgMagenta(match[0])
192          )}`
193        );
194      }
195      console.log();
196    }
197  }
198  return matchesCount > 0;
199}
200
201export async function removeVersionAsync(version: string) {
202  const abiName = `abi${version.replace(/\./g, '_')}`;
203  const sdkMajorVersion = `${semver.major(version)}`;
204
205  console.log(`Removing SDK version ${chalk.cyan(version)} for ${chalk.blue('Android')}...`);
206
207  // Remove expoview-abi*_0_0 library
208  await removeVersionedExpoviewAsync(versionedExpoviewAbiPath(abiName));
209  await removeFromSettingsGradleAsync(abiName, settingsGradlePath);
210  await removeFromBuildGradleAsync(abiName, buildGradlePath);
211
212  // Remove code surrounded by BEGIN_SDK_* and END_SDK_*
213  await removeVersionReferencesFromFileAsync(sdkMajorVersion, expoviewBuildGradlePath);
214  await removeVersionReferencesFromFileAsync(sdkMajorVersion, appBuildGradlePath);
215  await removeVersionReferencesFromFileAsync(sdkMajorVersion, rnActivityPath);
216  await removeVersionReferencesFromFileAsync(sdkMajorVersion, expoviewConstantsPath);
217
218  // Remove test-suite tests from the app.
219  await removeTestSuiteTestsAsync(version, testSuiteTestsPath);
220
221  // Update AndroidManifests
222  await removeFromManifestAsync(sdkMajorVersion, appManifestPath);
223  await removeFromManifestAsync(sdkMajorVersion, templateManifestPath);
224
225  // Remove vendored modules
226  await removeVersionedVendoredModulesAsync(version);
227
228  // Remove SDK version from the list of supported SDKs
229  await removeFromSdkVersionsAsync(version, sdkVersionsPath);
230
231  console.log(`\nLooking for SDK references in source files...`);
232
233  if (await findAndPrintVersionReferencesInSourceFilesAsync(version)) {
234    console.log(
235      chalk.yellow(`Please review all of these references and remove them manually if possible!\n`)
236    );
237  }
238}
239
240async function copyExpoModulesAsync(version: string) {
241  const packages = await getListOfPackagesAsync();
242  for (const pkg of packages) {
243    if (
244      pkg.isSupportedOnPlatform('android') &&
245      pkg.isIncludedInExpoClientOnPlatform('android') &&
246      pkg.isVersionableOnPlatform('android')
247    ) {
248      const abiVersion = `abi${version.replace(/\./g, '_')}`;
249      const targetDirectory = path.join(ANDROID_DIR, `versioned-abis/expoview-${abiVersion}`);
250      const sourceDirectory = path.join(pkg.path, pkg.androidSubdirectory);
251      const transforms = expoModulesTransforms(pkg.packageName, abiVersion);
252
253      const files = await searchFilesAsync(sourceDirectory, [
254        './src/main/java/**',
255        './src/main/kotlin/**',
256        './src/main/AndroidManifest.xml',
257      ]);
258
259      for (const javaPkg of packagesToKeep) {
260        const javaPkgWithSlash = javaPkg.replace(/\./g, '/');
261        const pathFromPackage = `./src/main/{java,kotlin}/${javaPkgWithSlash}{/**,.java,.kt}`;
262        for (const file of files) {
263          if (minimatch(file, pathFromPackage)) {
264            files.delete(file);
265            continue;
266          }
267        }
268      }
269
270      for (const sourceFile of files) {
271        await copyFileWithTransformsAsync({
272          sourceFile,
273          targetDirectory,
274          sourceDirectory,
275          transforms,
276        });
277      }
278      const temporaryPackageManifestPath = path.join(
279        targetDirectory,
280        'src/main/TemporaryExpoModuleAndroidManifest.xml'
281      );
282      const mainManifestPath = path.join(targetDirectory, 'src/main/AndroidManifest.xml');
283      await spawnAsync('java', [
284        '-jar',
285        path.join(SCRIPT_DIR, 'android-manifest-merger-3898d3a.jar'),
286        '--main',
287        mainManifestPath,
288        '--libs',
289        temporaryPackageManifestPath,
290        '--placeholder',
291        'applicationId=${applicationId}',
292        '--out',
293        mainManifestPath,
294        '--log',
295        'WARNING',
296      ]);
297      await fs.remove(temporaryPackageManifestPath);
298      console.log(`   ✅  Created versioned ${pkg.packageName}`);
299    }
300  }
301}
302
303async function addVersionedActivitesToManifests(version: string) {
304  const abiVersion = version.replace(/\./g, '_');
305  const abiName = `abi${abiVersion}`;
306  const majorVersion = semver.major(version);
307
308  await transformFileAsync(
309    templateManifestPath,
310    new RegExp('<!-- ADD DEV SETTINGS HERE -->'),
311    `<!-- ADD DEV SETTINGS HERE -->
312    <!-- BEGIN_SDK_${majorVersion} -->
313    <activity android:name="${abiName}.com.facebook.react.devsupport.DevSettingsActivity"/>
314    <!-- END_SDK_${majorVersion} -->`
315  );
316}
317
318async function registerNewVersionUnderSdkVersions(version: string) {
319  const fileString = await fs.readFile(sdkVersionsPath, 'utf8');
320  let jsConfig;
321  // read the existing json config and add the new version to the sdkVersions array
322  try {
323    jsConfig = JSON.parse(fileString);
324  } catch (e) {
325    console.log('Error parsing existing sdkVersions.json file, writing a new one...', e);
326    console.log('The erroneous file contents was:', fileString);
327    jsConfig = {
328      sdkVersions: [],
329    };
330  }
331  // apply changes
332  jsConfig.sdkVersions.push(version);
333  await fs.writeFile(sdkVersionsPath, JSON.stringify(jsConfig));
334}
335
336async function cleanUpAsync(version: string) {
337  const abiVersion = version.replace(/\./g, '_');
338  const abiName = `abi${abiVersion}`;
339
340  const versionedAbiSrcPath = path.join(
341    versionedExpoviewAbiPath(abiName),
342    'src/main/java',
343    abiName
344  );
345
346  const filesToDelete: string[] = [];
347
348  // delete PrintDocumentAdapter*Callback.kt
349  // their package is `android.print` and therefore they are not changed by the versioning script
350  // so we will have duplicate classes
351  const printCallbackFiles = await glob(
352    path.join(versionedAbiSrcPath, 'expo/modules/print/*Callback.kt')
353  );
354  for (const file of printCallbackFiles) {
355    const contents = await fs.readFile(file, 'utf8');
356    if (!contents.includes(`package ${abiName}`)) {
357      filesToDelete.push(file);
358    } else {
359      console.log(`Skipping deleting ${file} because it appears to have been versioned`);
360    }
361  }
362
363  // delete versioned loader providers since we don't need them
364  filesToDelete.push(path.join(versionedAbiSrcPath, 'expo/loaders'));
365
366  console.log('Deleting the following files and directories:');
367  console.log(filesToDelete);
368
369  for (const file of filesToDelete) {
370    await fs.remove(file);
371  }
372
373  // misc fixes for versioned code
374  const versionedExponentPackagePath = path.join(
375    versionedAbiSrcPath,
376    'host/exp/exponent/ExponentPackage.kt'
377  );
378  await transformFileAsync(
379    versionedExponentPackagePath,
380    new RegExp('// WHEN_VERSIONING_REMOVE_FROM_HERE', 'g'),
381    '/* WHEN_VERSIONING_REMOVE_FROM_HERE'
382  );
383  await transformFileAsync(
384    versionedExponentPackagePath,
385    new RegExp('// WHEN_VERSIONING_REMOVE_TO_HERE', 'g'),
386    'WHEN_VERSIONING_REMOVE_TO_HERE */'
387  );
388
389  await transformFileAsync(
390    path.join(versionedAbiSrcPath, 'host/exp/exponent/VersionedUtils.kt'),
391    new RegExp('// DO NOT EDIT THIS COMMENT - used by versioning scripts[^,]+,[^,]+,'),
392    'null, null,'
393  );
394
395  // replace abixx_x_x...R with abixx_x_x.host.exp.expoview.R
396  await spawnAsync(
397    `find ${versionedAbiSrcPath} -iname '*.java' -type f -print0 | ` +
398      `xargs -0 ${SED_PREFIX} 's/import ${abiName}\.[^;]*\.R;/import ${abiName}.host.exp.expoview.R;/g'`,
399    [],
400    { shell: true }
401  );
402  await spawnAsync(
403    `find ${versionedAbiSrcPath} -iname '*.kt' -type f -print0 | ` +
404      `xargs -0 ${SED_PREFIX} 's/import ${abiName}\\..*\\.R$/import ${abiName}.host.exp.expoview.R/g'`,
405    [],
406    { shell: true }
407  );
408
409  // add new versioned maven to build.gradle
410  await transformFileAsync(
411    buildGradlePath,
412    new RegExp('// For old expoviews to work'),
413    `// For old expoviews to work
414    maven {
415      url "$rootDir/versioned-abis/expoview-${abiName}/maven"
416    }`
417  );
418}
419
420async function exportReactNdks() {
421  const versionedRN = path.join(versionedReactAndroidPath, '..');
422  await spawnAsync(`./gradlew :ReactAndroid:packageReactNdkLibs`, [], {
423    shell: true,
424    cwd: versionedRN,
425    stdio: 'inherit',
426    env: {
427      ...process.env,
428      REACT_NATIVE_OVERRIDE_HERMES_DIR: versionedHermesPath,
429    },
430  });
431}
432
433async function exportReactNdksIfNeeded() {
434  const ndksPath = path.join(versionedReactAndroidPath, 'build', 'react-ndk', 'exported');
435  const exists = await fs.pathExists(ndksPath);
436  if (!exists) {
437    await exportReactNdks();
438    return;
439  }
440
441  const exportedSO = await glob(path.join(ndksPath, '**/*.so'));
442  if (exportedSO.length === 0) {
443    await exportReactNdks();
444  }
445}
446
447export async function addVersionAsync(version: string) {
448  console.log(' ��   1/9: Updating android/versioned-react-native...');
449  await updateVersionedReactNativeAsync(ANDROID_DIR, version);
450  console.log(' ✅  1/9: Finished\n\n');
451
452  console.log(' ��  2/9: Building versioned ReactAndroid AAR...');
453  await spawnAsync('./android-build-aar.sh', [version], {
454    shell: true,
455    cwd: SCRIPT_DIR,
456    stdio: 'inherit',
457  });
458  console.log(' ✅  2/9: Finished\n\n');
459
460  console.log(' ��   3/9: Creating versioned expoview package...');
461  await copyExpoviewAsync(version, ANDROID_DIR);
462  console.log(' ✅  3/9: Finished\n\n');
463
464  console.log(' ��   4/9: Exporting react ndks if needed...');
465  await exportReactNdksIfNeeded();
466  console.log(' ✅  4/9: Finished\n\n');
467
468  console.log(' ��   5/9: Creating versioned expo-modules packages...');
469  await copyExpoModulesAsync(version);
470  console.log(' ✅  5/9: Finished\n\n');
471
472  console.log(' ��   6/9: Versoning c++ libraries for expo-modules...');
473  await versionCxxExpoModulesAsync(version);
474  console.log(' ✅  6/9: Finished\n\n');
475
476  console.log(' ��   7/9: Adding extra versioned activites to AndroidManifest...');
477  await addVersionedActivitesToManifests(version);
478  console.log(' ✅  7/9: Finished\n\n');
479
480  console.log(' ��   8/9: Registering new version under sdkVersions config...');
481  await registerNewVersionUnderSdkVersions(version);
482  console.log(' ✅  8/9: Finished\n\n');
483
484  console.log(' ��   9/9: Misc cleanup...');
485  await cleanUpAsync(version);
486  console.log(' ✅  9/9: Finished');
487
488  const abiVersion = `abi${version.replace(/\./g, '_')}`;
489  const versionedAar = path.join(
490    versionedExpoviewAbiPath(abiVersion),
491    `maven/host/exp/reactandroid-${abiVersion}/1.0.0/reactandroid-${abiVersion}-1.0.0.aar`
492  );
493  console.log(
494    '\n' +
495      chalk.yellow(
496        '################################################################################################################'
497      ) +
498      `\nIf you want to commit the versioned code to git, please also upload the versioned aar at ${chalk.cyan(
499        versionedAar
500      )} to:\n` +
501      chalk.cyan(
502        `https://github.com/expo/react-native/releases/download/sdk-${version}/reactandroid-${abiVersion}-1.0.0.aar`
503      ) +
504      '\n' +
505      chalk.yellow(
506        '################################################################################################################'
507      ) +
508      '\n'
509  );
510}
511