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}:copyReleaseJniLibsProjectOnly`],
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    'library_jni',
92    'release',
93    'jni'
94  );
95  const libFiles = await glob('**/*.so', {
96    cwd: buildLibDir,
97  });
98  assert(libFiles.length >= 0);
99  await Promise.all(
100    libFiles.map(async (file) => {
101      const srcPath = path.join(buildLibDir, file);
102      const archName = path.basename(path.dirname(file));
103      const dstPath = path.join(jniLibDir, archName, path.basename(file));
104      await fs.ensureDir(path.dirname(dstPath));
105      await fs.copy(srcPath, dstPath);
106    })
107  );
108
109  // Truncate CMakeLists.txt and not to build this cxx module when building versioned Expo Go
110  await fs.writeFile(cmakeFile, '');
111  await fs.remove(path.join(moduleRootDir, 'build'));
112}
113
114/**
115 * Transform ExponentPackage.kt, e.g. add import abi prefix
116 */
117async function transformExponentPackageAsync(name: string, prefix: string) {
118  const transforms = exponentPackageTransforms(prefix)[name] ?? null;
119  const exponentPackageFile = path.resolve(
120    path.join(
121      ANDROID_DIR,
122      `versioned-abis/expoview-${prefix}/src/main/java/${prefix}/host/exp/exponent/ExponentPackage.kt`
123    )
124  );
125  await transformFileAsync(exponentPackageFile, transforms);
126}
127
128/**
129 * Gets the library name of each vendored module in a specific directory.
130 */
131async function getVendoredModuleNamesAsync(directory: string): Promise<string[]> {
132  const vendoredGradlePaths = await glob(`**/build.gradle`, {
133    cwd: directory,
134    nodir: true,
135    realpath: true,
136  });
137
138  const gradlePattern = new RegExp(`${directory}/(.*)/android/build.gradle`, 'i');
139
140  return vendoredGradlePaths.reduce((result, gradlePath) => {
141    const moduleName = gradlePath.match(gradlePattern);
142    if (moduleName) {
143      result.push(moduleName[1]);
144    }
145    return result;
146  }, [] as string[]);
147}
148
149/**
150 * Removes the directory with vendored modules for given SDK number.
151 */
152export async function removeVersionedVendoredModulesAsync(sdkNumber: number): Promise<void> {
153  const versionedDir = vendoredDirectoryForSDK(sdkNumber);
154  await fs.remove(versionedDir);
155}
156
157/**
158 * Get the gradle dependency version from `android/expoview/build.gradle`
159 */
160async function getGradleDependencyVersionFromExpoViewAsync(
161  group: string,
162  name: string
163): Promise<string | null> {
164  const expoviewGradleFile = path.join(ANDROID_DIR, 'expoview', 'build.gradle');
165  const content = await fs.readFile(expoviewGradleFile, 'utf-8');
166  const searchPattern = new RegExp(
167    `\\b(api|implementation)[\\s(]['"]${group}:${name}:(.+?)['"]`,
168    'g'
169  );
170  const result = searchPattern.exec(content);
171  if (!result) {
172    return null;
173  }
174  return result[2];
175}
176
177/**
178 * Generates base transforms to apply for all vendored modules.
179 */
180async function baseTransformsFactoryAsync(prefix: string): Promise<Required<FileTransforms>> {
181  const fbjniVersion = await getGradleDependencyVersionFromExpoViewAsync(
182    'com.facebook.fbjni',
183    'fbjni-java-only'
184  );
185  const proguardAnnotationVersion = await getGradleDependencyVersionFromExpoViewAsync(
186    'com.facebook.yoga',
187    'proguard-annotations'
188  );
189
190  return {
191    path: [
192      {
193        // For package renaming, src/main/java/* -> src/main/java/abiN/*
194        find: /\/(java|kotlin)\//,
195        replaceWith: `/$1/${prefix}/`,
196      },
197    ],
198    content: [
199      {
200        paths: '*.{java,kt}',
201        find: /(^package\s+)([\w.]+;?)/m,
202        replaceWith: `$1${prefix}.$2`,
203      },
204      {
205        paths: '*.{java,kt}',
206        find: /(\bcom\.facebook\.(catalyst|csslayout|fbreact|hermes|perftest|quicklog|react|systrace|yoga|debug)\b)/g,
207        replaceWith: `${prefix}.$1`,
208      },
209      {
210        paths: '*.{java,kt}',
211        find: /\b((System|SoLoader)\.loadLibrary\("[^"]*)("\);?)/g,
212        replaceWith: `$1_${prefix}$3`,
213      },
214      {
215        paths: '*.{h,cpp}',
216        find: /(\bkJavaDescriptor\s*=\s*\n?\s*"L)/gm,
217        replaceWith: `$1${prefix}/`,
218      },
219      {
220        paths: 'build.gradle',
221        find: /\b(compileOnly|implementation)\s+['"]com.facebook.react:react-native:.+['"]/gm,
222        replaceWith:
223          `implementation 'host.exp:reactandroid-${prefix}:1.0.0'` +
224          '\n' +
225          // Adding some compile time common dependencies where the versioned react-native AAR doesn't expose
226          `    compileOnly 'com.facebook.fbjni:fbjni:${fbjniVersion}'\n` +
227          `    compileOnly 'com.facebook.yoga:proguard-annotations:${proguardAnnotationVersion}'\n` +
228          `    compileOnly 'androidx.annotation:annotation:+'\n`,
229      },
230      {
231        paths: 'build.gradle',
232        find: 'buildDir/react-native-0*/jni',
233        replaceWith: 'buildDir/reactandroid-abi*/jni',
234      },
235      {
236        paths: ['build.gradle', 'CMakeLists.txt'],
237        find: /\/react-native\//g,
238        replaceWith: '/versioned-react-native/',
239      },
240      {
241        paths: 'build.gradle',
242        find: /def rnAAR = fileTree.*\*\.aar.*\)/g,
243        replaceWith: `def rnAAR = fileTree("\${rootDir}/versioned-abis").matching({ include "**/reactandroid-${prefix}/**/*.aar" })`,
244      },
245      {
246        paths: 'build.gradle',
247        find: /def rnAAR = fileTree.*rnAarMatcher.*\)/g,
248        replaceWith: `def rnAAR = fileTree("\${rootDir}/versioned-abis").matching({ include "**/reactandroid-${prefix}/**/*.aar" })`,
249      },
250      {
251        paths: 'CMakeLists.txt',
252        find: /(^set\s*\(PACKAGE_NAME\s*['"])(\w+)(['"]\))/gm,
253        replaceWith: `$1$2_${prefix}$3`,
254      },
255      {
256        paths: 'CMakeLists.txt',
257        find: /(\bfind_library\(\n?\s*[A-Z_]+\n?\s*)(\w+)/gm,
258        replaceWith(substring, group1, libName) {
259          if (['fbjni', 'log'].includes(libName)) {
260            return substring;
261          }
262          return `${group1}${libName}_${prefix}`;
263        },
264      },
265      {
266        paths: 'AndroidManifest.xml',
267        find: /(\bpackage=")([\w.]+)(")/,
268        replaceWith: `$1${prefix}.$2$3`,
269      },
270    ],
271  };
272}
273
274/**
275 * Returns the vendored directory for given SDK number.
276 */
277function vendoredDirectoryForSDK(sdkNumber: number): string {
278  return path.join(ANDROID_VENDORED_DIR, `sdk${sdkNumber}`);
279}
280