1import spawnAsync from '@expo/spawn-async';
2import fs from 'fs-extra';
3import glob from 'glob-promise';
4import path from 'path';
5
6import { ANDROID_DIR, PACKAGES_DIR, EXPOTOOLS_DIR } from '../../../Constants';
7import { getListOfPackagesAsync, Package } from '../../../Packages';
8import { transformFileAsync, transformString } from '../../../Transforms';
9import { applyPatchAsync } from '../../../Utils';
10
11const CXX_EXPO_MODULE_PATCHES_DIR = path.join(
12  EXPOTOOLS_DIR,
13  'src',
14  'versioning',
15  'android',
16  'versionCxx',
17  'patches'
18);
19
20/**
21 * Executes the versioning for expo-modules with cxx code.
22 *
23 * Currently, it is a patch based process.
24 * we patch build files directly in `packages/{packageName}`,
25 * build the share libraries in place and copy back to versioned jniLibs folder.
26 * To add an module for versioning,
27 * please adds a corresponding `tools/src/versioning/android/versionCxx/patches/{packageName}.patch` patch file.
28 */
29export async function versionCxxExpoModulesAsync(version: string) {
30  const packages = await getListOfPackagesAsync();
31  const versionablePackages = packages.filter((pkg) => isVersionableCxxExpoModule(pkg));
32
33  for (const pkg of versionablePackages) {
34    const { packageName } = pkg;
35    const abiName = `abi${version.replace(/\./g, '_')}`;
36    const versionedAbiRoot = path.join(ANDROID_DIR, 'versioned-abis', `expoview-${abiName}`);
37
38    const patchContent = await getTransformPatchContentAsync(packageName, abiName);
39
40    await applyPatchForPackageAsync(packageName, patchContent);
41    await buildSoLibsAsync(packageName);
42    await revertPatchForPackageAsync(packageName, patchContent);
43
44    await copyPrebuiltSoLibsAsync(packageName, versionedAbiRoot);
45    await versionJavaLoadersAsync(packageName, versionedAbiRoot, abiName);
46
47    console.log(`   ✅  Created versioned c++ libraries for ${packageName}`);
48  }
49}
50
51/**
52 * Returns true if the package is a versionable cxx module
53 */
54function isVersionableCxxExpoModule(pkg: Package) {
55  return (
56    pkg.isSupportedOnPlatform('android') &&
57    pkg.isIncludedInExpoClientOnPlatform('android') &&
58    pkg.isVersionableOnPlatform('android') &&
59    fs.existsSync(path.join(CXX_EXPO_MODULE_PATCHES_DIR, `${pkg.packageName}.patch`))
60  );
61}
62
63/**
64 * Applies versioning patch for building shared libraries
65 */
66export function applyPatchForPackageAsync(packageName: string, patchContent: string) {
67  return applyPatchAsync({
68    patchContent,
69    reverse: false,
70    cwd: path.join(PACKAGES_DIR, packageName),
71    stripPrefixNum: 3,
72  });
73}
74
75/**
76 * Reverts versioning patch for building shared libraries
77 */
78export function revertPatchForPackageAsync(packageName: string, patchContent: string) {
79  return applyPatchAsync({
80    patchContent,
81    reverse: true,
82    cwd: path.join(PACKAGES_DIR, packageName),
83    stripPrefixNum: 3,
84  });
85}
86
87/**
88 * Builds shared libraries
89 */
90async function buildSoLibsAsync(packageName: string) {
91  await spawnAsync('./gradlew', [`:${packageName}:copyReleaseJniLibsProjectOnly`], {
92    cwd: ANDROID_DIR,
93  });
94}
95
96/**
97 * Copies the generated shared libraries from build output to `android/versioned-abis/expoview-abiXX_0_0/src/main/jniLibs`
98 */
99async function copyPrebuiltSoLibsAsync(packageName: string, versionedAbiRoot: string) {
100  const libRoot = path.join(
101    PACKAGES_DIR,
102    packageName,
103    'android',
104    'build',
105    'intermediates',
106    'library_jni',
107    'release',
108    'jni'
109  );
110
111  const jniLibsRoot = path.join(versionedAbiRoot, 'src', 'main', 'jniLibs');
112  const libs = await glob('**/libexpo*.so', { cwd: libRoot });
113  await Promise.all(
114    libs.map((lib) => fs.copyFile(path.join(libRoot, lib), path.join(jniLibsRoot, lib)))
115  );
116}
117
118/**
119 * Transforms `System.loadLibrary("expoXXX")` to `System.loadLibrary("expoXXX_abiXX_0_0")` in java or kotlin files
120 */
121async function versionJavaLoadersAsync(
122  packageName: string,
123  versionedAbiRoot: string,
124  abiName: string
125) {
126  const srcJavaRoot = path.join(PACKAGES_DIR, packageName, 'android', 'src', 'main', 'java');
127  const srcJavaFiles = await glob('**/*.{java,kt}', { cwd: srcJavaRoot });
128  const versionedJavaFiles = srcJavaFiles.map((file) =>
129    path.join(versionedAbiRoot, 'src', 'main', 'java', abiName, file)
130  );
131  await Promise.all(
132    versionedJavaFiles.map(async (file) => {
133      if (await fs.pathExists(file)) {
134        await transformFileAsync(file, [
135          {
136            find: /\b((System|SoLoader)\.loadLibrary\("expo[^"]*)("\);?)/g,
137            replaceWith: `$1_${abiName}$3`,
138          },
139        ]);
140      }
141    })
142  );
143}
144
145/**
146 * Read the patch content and do `abiName` transformation
147 */
148async function getTransformPatchContentAsync(packageName: string, abiName: string) {
149  const patchFile = path.join(CXX_EXPO_MODULE_PATCHES_DIR, `${packageName}.patch`);
150  let content = await fs.readFile(patchFile, 'utf8');
151  content = await transformString(content, [
152    {
153      find: /\{VERSIONED_ABI_NAME\}/g,
154      replaceWith: abiName,
155    },
156    {
157      find: /\{VERSIONED_ABI_NAME_JNI_ESCAPED\}/g,
158      replaceWith: escapeJniSymbol(abiName),
159    },
160  ]);
161  return content;
162}
163
164/**
165 * Escapes special characters for java symbol -> cpp symbol mapping
166 * Reference: https://docs.oracle.com/en/java/javase/17/docs/specs/jni/design.html#resolving-native-method-names
167 * UTF-16 codes are not supported
168 */
169function escapeJniSymbol(symbol) {
170  const mappings = {
171    '/': '_',
172    _: '_1',
173    ';': '_2',
174    '[': '_3',
175  };
176  return symbol.replace(/[/_;\[]/g, (match) => mappings[match]);
177}
178