1import spawnAsync from '@expo/spawn-async';
2import assert from 'assert';
3import chalk from 'chalk';
4import fs from 'fs-extra';
5import glob from 'glob-promise';
6import path from 'path';
7
8import { ANDROID_DIR } from '../../Constants';
9import logger from '../../Logger';
10import { copyFileWithTransformsAsync, transformFileAsync } from '../../Transforms';
11import { FileTransforms } from '../../Transforms.types';
12import { searchFilesAsync } from '../../Utils';
13import {
14  exponentPackageTransforms,
15  vendoredModulesTransforms,
16} from './transforms/vendoredModulesTransforms';
17
18const ANDROID_VENDORED_DIR = path.join(ANDROID_DIR, 'vendored');
19
20/**
21 * Versions Android vendored modules.
22 */
23export async function versionVendoredModulesAsync(
24  sdkNumber: number,
25  filterModules: string[] | null
26): Promise<void> {
27  const prefix = `abi${sdkNumber}_0_0`;
28  const config = vendoredModulesTransforms(prefix);
29  const baseTransforms = await baseTransformsFactoryAsync(prefix);
30  const unversionedDir = path.join(ANDROID_VENDORED_DIR, 'unversioned');
31  const versionedDir = vendoredDirectoryForSDK(sdkNumber);
32  let vendoredModuleNames = await getVendoredModuleNamesAsync(unversionedDir);
33  if (filterModules) {
34    vendoredModuleNames = vendoredModuleNames.filter((name) => filterModules.includes(name));
35  }
36
37  for (const name of vendoredModuleNames) {
38    logger.info('�� Versioning vendored module %s', chalk.green(name));
39
40    const moduleConfig = config[name];
41    const sourceDirectory = path.join(unversionedDir, name);
42    const targetDirectory = path.join(versionedDir, name);
43    const files = await searchFilesAsync(sourceDirectory, '**');
44
45    await fs.remove(targetDirectory);
46
47    for (const sourceFile of files) {
48      await copyFileWithTransformsAsync({
49        sourceFile,
50        sourceDirectory,
51        targetDirectory,
52        transforms: {
53          path: [...baseTransforms.path, ...(moduleConfig?.path ?? [])],
54          content: [...baseTransforms.content, ...(moduleConfig?.content ?? [])],
55        },
56      });
57    }
58
59    await maybePrebuildSharedLibsAsync(name, sdkNumber);
60    await transformExponentPackageAsync(name, prefix);
61  }
62}
63
64/**
65 * Prebuild shared libraries to jniLibs and cleanup CMakeLists.txt
66 */
67async function maybePrebuildSharedLibsAsync(module: string, sdkNumber: number) {
68  const moduleRootDir = path.join(ANDROID_DIR, 'vendored', `sdk${sdkNumber}`, module, 'android');
69  const cmakeFile = path.join(moduleRootDir, 'CMakeLists.txt');
70  if (!fs.existsSync(cmakeFile)) {
71    return;
72  }
73
74  logger.info('   Prebuilding shared libraries for %s', module);
75  const gradleProject = module.replace(/\//g, '_');
76  await spawnAsync(
77    './gradlew',
78    [`:vendored_sdk${sdkNumber}_${gradleProject}:copyReleaseJniLibsProjectAndLocalJars`],
79    {
80      cwd: ANDROID_DIR,
81      // Uncomment the following line for verbose building output
82      // stdio: 'inherit',
83    }
84  );
85
86  const jniLibDir = path.join(moduleRootDir, 'src', 'main', 'jniLibs');
87  const buildLibDir = path.join(
88    moduleRootDir,
89    'build',
90    'intermediates',
91    'stripped_native_libs',
92    'release',
93    'out',
94    'lib'
95  );
96  const libFiles = await glob('**/*.so', {
97    cwd: buildLibDir,
98  });
99  assert(libFiles.length > 0);
100  await Promise.all(
101    libFiles.map(async (file) => {
102      const srcPath = path.join(buildLibDir, file);
103      const archName = path.basename(path.dirname(file));
104      const dstPath = path.join(jniLibDir, archName, path.basename(file));
105      await fs.ensureDir(path.dirname(dstPath));
106      await fs.copy(srcPath, dstPath);
107    })
108  );
109
110  // Truncate CMakeLists.txt and not to build this cxx module when building versioned Expo Go
111  await fs.writeFile(cmakeFile, '');
112  await fs.remove(path.join(moduleRootDir, 'build'));
113}
114
115/**
116 * Transform ExponentPackage.kt, e.g. add import abi prefix
117 */
118async function transformExponentPackageAsync(name: string, prefix: string) {
119  const transforms = exponentPackageTransforms(prefix)[name] ?? null;
120  const exponentPackageFile = path.resolve(
121    path.join(
122      ANDROID_DIR,
123      `versioned-abis/expoview-${prefix}/src/main/java/${prefix}/host/exp/exponent/ExponentPackage.kt`
124    )
125  );
126  await transformFileAsync(exponentPackageFile, transforms);
127}
128
129/**
130 * Gets the library name of each vendored module in a specific directory.
131 */
132async function getVendoredModuleNamesAsync(directory: string): Promise<string[]> {
133  const vendoredGradlePaths = await glob(`**/build.gradle`, {
134    cwd: directory,
135    nodir: true,
136    realpath: true,
137  });
138
139  const gradlePattern = new RegExp(`${directory}/(.*)/android/build.gradle`, 'i');
140
141  return vendoredGradlePaths.reduce((result, gradlePath) => {
142    const moduleName = gradlePath.match(gradlePattern);
143    if (moduleName) {
144      result.push(moduleName[1]);
145    }
146    return result;
147  }, [] as string[]);
148}
149
150/**
151 * Removes the directory with vendored modules for given SDK number.
152 */
153export async function removeVersionedVendoredModulesAsync(sdkNumber: number): Promise<void> {
154  const versionedDir = vendoredDirectoryForSDK(sdkNumber);
155  await fs.remove(versionedDir);
156}
157
158/**
159 * Get the gradle dependency version from `android/expoview/build.gradle`
160 */
161async function getGradleDependencyVersionFromExpoViewAsync(
162  group: string,
163  name: string
164): Promise<string | null> {
165  const expoviewGradleFile = path.join(ANDROID_DIR, 'expoview', 'build.gradle');
166  const content = await fs.readFile(expoviewGradleFile, 'utf-8');
167  const searchPattern = new RegExp(
168    `\\b(api|implementation)[\\s(]['"]${group}:${name}:(.+?)['"]`,
169    'g'
170  );
171  const result = searchPattern.exec(content);
172  if (!result) {
173    return null;
174  }
175  return result[2];
176}
177
178/**
179 * Generates base transforms to apply for all vendored modules.
180 */
181async function baseTransformsFactoryAsync(prefix: string): Promise<Required<FileTransforms>> {
182  const fbjniVersion = await getGradleDependencyVersionFromExpoViewAsync(
183    'com.facebook.fbjni',
184    'fbjni-java-only'
185  );
186  const proguardAnnotationVersion = await getGradleDependencyVersionFromExpoViewAsync(
187    'com.facebook.yoga',
188    'proguard-annotations'
189  );
190
191  return {
192    path: [
193      {
194        // For package renaming, src/main/java/* -> src/main/java/abiN/*
195        find: /\/(java|kotlin)\//,
196        replaceWith: `/$1/${prefix}/`,
197      },
198    ],
199    content: [
200      {
201        paths: '*.{java,kt}',
202        find: /(^package\s+)([\w.]+;?)/m,
203        replaceWith: `$1${prefix}.$2`,
204      },
205      {
206        paths: '*.{java,kt}',
207        find: /(\bcom\.facebook\.(catalyst|csslayout|fbreact|hermes|perftest|quicklog|react|systrace|yoga|debug)\b)/g,
208        replaceWith: `${prefix}.$1`,
209      },
210      {
211        paths: '*.{java,kt}',
212        find: /\b((System|SoLoader)\.loadLibrary\("[^"]*)("\);?)/g,
213        replaceWith: `$1_${prefix}$3`,
214      },
215      {
216        paths: '*.{h,cpp}',
217        find: /(\bkJavaDescriptor\s*=\s*\n?\s*"L)/gm,
218        replaceWith: `$1${prefix}/`,
219      },
220      {
221        paths: 'build.gradle',
222        find: /\b(compileOnly|implementation)\s+['"]com.facebook.react:react-native:.+['"]/gm,
223        replaceWith:
224          `implementation 'host.exp:reactandroid-${prefix}:1.0.0'` +
225          '\n' +
226          // Adding some compile time common dependencies where the versioned react-native AAR doesn't expose
227          `    compileOnly 'com.facebook.fbjni:fbjni:${fbjniVersion}'\n` +
228          `    compileOnly 'com.facebook.yoga:proguard-annotations:${proguardAnnotationVersion}'\n` +
229          `    compileOnly 'androidx.annotation:annotation:+'\n`,
230      },
231      {
232        paths: 'build.gradle',
233        find: 'buildDir/react-native-0*/jni',
234        replaceWith: 'buildDir/reactandroid-abi*/jni',
235      },
236      {
237        paths: ['build.gradle', 'CMakeLists.txt'],
238        find: /\/react-native\//g,
239        replaceWith: '/versioned-react-native/',
240      },
241      {
242        paths: 'build.gradle',
243        find: /def rnAAR = fileTree.*\*\.aar.*\)/g,
244        replaceWith: `def rnAAR = fileTree("\${rootDir}/versioned-abis").matching({ include "**/reactandroid-${prefix}/**/*.aar" })`,
245      },
246      {
247        paths: 'build.gradle',
248        find: /def rnAAR = fileTree.*rnAarMatcher.*\)/g,
249        replaceWith: `def rnAAR = fileTree("\${rootDir}/versioned-abis").matching({ include "**/reactandroid-${prefix}/**/*.aar" })`,
250      },
251      {
252        paths: 'CMakeLists.txt',
253        find: /(^set\s*\(PACKAGE_NAME\s*['"])(\w+)(['"]\))/gm,
254        replaceWith: `$1$2_${prefix}$3`,
255      },
256      {
257        paths: 'CMakeLists.txt',
258        find: /(\bfind_library\(\n?\s*[A-Z_]+\n?\s*)(\w+)/gm,
259        replaceWith(substring, group1, libName) {
260          if (['fbjni', 'log'].includes(libName)) {
261            return substring;
262          }
263          return `${group1}${libName}_${prefix}`;
264        },
265      },
266      {
267        paths: 'AndroidManifest.xml',
268        find: /(\bpackage=")([\w.]+)(")/,
269        replaceWith: `$1${prefix}.$2$3`,
270      },
271    ],
272  };
273}
274
275/**
276 * Returns the vendored directory for given SDK number.
277 */
278function vendoredDirectoryForSDK(sdkNumber: number): string {
279  return path.join(ANDROID_VENDORED_DIR, `sdk${sdkNumber}`);
280}
281