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