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