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 Git from '../../../Git';
8import { getListOfPackagesAsync, Package } from '../../../Packages';
9import {
10  transformFileAsync,
11  transformString,
12  transformFilesAsync,
13  FileTransform,
14} from '../../../Transforms';
15import { applyPatchAsync } from '../../../Utils';
16
17const CXX_EXPO_MODULE_PATCHES_DIR = path.join(
18  EXPOTOOLS_DIR,
19  'src',
20  'versioning',
21  'android',
22  'versionCxx',
23  'patches'
24);
25
26/**
27 * Executes the versioning for expo-modules with cxx code.
28 *
29 * Currently, it is a patch based process.
30 * we patch build files directly in `packages/{packageName}`,
31 * build the share libraries in place and copy back to versioned jniLibs folder.
32 * To add an module for versioning,
33 * please adds a corresponding `tools/src/versioning/android/versionCxx/patches/{packageName}.patch` patch file.
34 */
35export async function versionCxxExpoModulesAsync(version: string) {
36  const packages = await getListOfPackagesAsync();
37  const versionablePackages = packages.filter((pkg) => isVersionableCxxExpoModule(pkg));
38
39  for (const pkg of versionablePackages) {
40    const { packageName } = pkg;
41    const abiName = `abi${version.replace(/\./g, '_')}`;
42    const versionedAbiRoot = path.join(ANDROID_DIR, 'versioned-abis', `expoview-${abiName}`);
43    const packageFiles = await glob('**/*.{h,cpp,txt,gradle}', {
44      cwd: path.join(PACKAGES_DIR, packageName),
45      ignore: [
46        '{android,android-annotation,android-annotation-processor}/{build,.cxx}/**/*',
47        'ios/**/*',
48      ],
49      absolute: true,
50    });
51
52    await transformPackageAsync(packageFiles, abiName);
53    const patchContent = await getTransformPatchContentAsync(packageName, abiName);
54    if (patchContent) {
55      await applyPatchForPackageAsync(packageName, patchContent);
56    }
57
58    await buildSoLibsAsync(packageName);
59
60    if (patchContent) {
61      await revertPatchForPackageAsync(packageName, patchContent);
62    }
63    await revertTransformPackageAsync(packageFiles);
64
65    await copyPrebuiltSoLibsAsync(packageName, versionedAbiRoot);
66    await versionJavaLoadersAsync(packageName, versionedAbiRoot, abiName);
67
68    console.log(`   ✅  Created versioned c++ libraries for ${packageName}`);
69  }
70}
71
72/**
73 * Returns true if the package is a versionable cxx module
74 */
75function isVersionableCxxExpoModule(pkg: Package) {
76  return (
77    pkg.isSupportedOnPlatform('android') &&
78    pkg.isIncludedInExpoClientOnPlatform('android') &&
79    pkg.isVersionableOnPlatform('android') &&
80    fs.existsSync(path.join(PACKAGES_DIR, pkg.packageName, 'android', 'CMakeLists.txt'))
81  );
82}
83
84async function transformPackageAsync(packageFiles: string[], abiName: string) {
85  await transformFilesAsync(packageFiles, baseTransforms(abiName));
86}
87
88function revertTransformPackageAsync(packageFiles: string[]) {
89  return Git.discardFilesAsync(packageFiles);
90}
91
92function baseTransforms(abiName: string): FileTransform[] {
93  return [
94    {
95      paths: 'CMakeLists.txt',
96      find: /\b(set\s*\(PACKAGE_NAME ['"].+)(['"]\))/g,
97      replaceWith: `$1_${abiName}$2`,
98    },
99    {
100      paths: 'CMakeLists.txt',
101      find: /(\s(ReactAndroid::)?jsi|reactnativejni|hermes|jscexecutor|folly_json|folly_runtime|react_nativemodule_core)\b/g,
102      replaceWith: `$1_${abiName}`,
103    },
104    {
105      paths: '**/*.{h,cpp}',
106      find: /([\b\s(;"]L?)(expo\/modules\/)/g,
107      replaceWith: `$1${abiName}/$2`,
108    },
109    {
110      paths: '**/*.{h,cpp}',
111      find: /([\b\s(;"]L?)(com\/facebook\/react\/)/g,
112      replaceWith: `$1${abiName}/$2`,
113    },
114    {
115      paths: 'build.gradle',
116      find: /(implementation|compileOnly)[ \(]['"]com.facebook.react:react-(native|android)(:\+)?['"]\)?/g,
117      replaceWith: `compileOnly 'host.exp:reactandroid-${abiName}:1.0.0'`,
118    },
119  ];
120}
121
122/**
123 * Applies versioning patch for building shared libraries
124 */
125export function applyPatchForPackageAsync(packageName: string, patchContent: string) {
126  return applyPatchAsync({
127    patchContent,
128    reverse: false,
129    cwd: path.join(PACKAGES_DIR, packageName),
130    stripPrefixNum: 3,
131  });
132}
133
134/**
135 * Reverts versioning patch for building shared libraries
136 */
137export function revertPatchForPackageAsync(packageName: string, patchContent: string) {
138  return applyPatchAsync({
139    patchContent,
140    reverse: true,
141    cwd: path.join(PACKAGES_DIR, packageName),
142    stripPrefixNum: 3,
143  });
144}
145
146/**
147 * Builds shared libraries
148 */
149async function buildSoLibsAsync(packageName: string) {
150  await spawnAsync('./gradlew', [`:${packageName}:copyReleaseJniLibsProjectAndLocalJars`], {
151    cwd: ANDROID_DIR,
152  });
153}
154
155/**
156 * Copies the generated shared libraries from build output to `android/versioned-abis/expoview-abiXX_0_0/src/main/jniLibs`
157 */
158async function copyPrebuiltSoLibsAsync(packageName: string, versionedAbiRoot: string) {
159  const libRoot = path.join(
160    PACKAGES_DIR,
161    packageName,
162    'android',
163    'build',
164    'intermediates',
165    'stripped_native_libs',
166    'release',
167    'out',
168    'lib'
169  );
170
171  const jniLibsRoot = path.join(versionedAbiRoot, 'src', 'main', 'jniLibs');
172  const libs = await glob('**/libexpo*.so', { cwd: libRoot });
173  await Promise.all(
174    libs.map(async (lib) => {
175      const destPath = path.join(jniLibsRoot, lib);
176      await fs.ensureDir(path.dirname(destPath));
177      await fs.copyFile(path.join(libRoot, lib), destPath);
178    })
179  );
180}
181
182/**
183 * Transforms `System.loadLibrary("expoXXX")` to `System.loadLibrary("expoXXX_abiXX_0_0")` in java or kotlin files
184 */
185async function versionJavaLoadersAsync(
186  packageName: string,
187  versionedAbiRoot: string,
188  abiName: string
189) {
190  const srcJavaRoot = path.join(PACKAGES_DIR, packageName, 'android', 'src', 'main', 'java');
191  const srcJavaFiles = await glob('**/*.{java,kt}', { cwd: srcJavaRoot });
192  const versionedJavaFiles = srcJavaFiles.map((file) =>
193    path.join(versionedAbiRoot, 'src', 'main', 'java', abiName, file)
194  );
195  await Promise.all(
196    versionedJavaFiles.map(async (file) => {
197      if (await fs.pathExists(file)) {
198        await transformFileAsync(file, [
199          {
200            find: /\b((System|SoLoader)\.loadLibrary\("expo[^"]*)("\);?)/g,
201            replaceWith: (s: string, g1, _, g3) =>
202              !s.includes(abiName) ? `${g1}_${abiName}${g3}` : s,
203          },
204        ]);
205      }
206    })
207  );
208}
209
210/**
211 * Read the patch content and do `abiName` transformation
212 */
213async function getTransformPatchContentAsync(
214  packageName: string,
215  abiName: string
216): Promise<string | null> {
217  const patchFile = path.join(CXX_EXPO_MODULE_PATCHES_DIR, `${packageName}.patch`);
218  if (!fs.existsSync(patchFile)) {
219    return null;
220  }
221  let content = await fs.readFile(patchFile, 'utf8');
222  content = await transformString(content, [
223    {
224      find: /\{VERSIONED_ABI_NAME\}/g,
225      replaceWith: abiName,
226    },
227    {
228      find: /\{VERSIONED_ABI_NAME_JNI_ESCAPED\}/g,
229      replaceWith: escapeJniSymbol(abiName),
230    },
231  ]);
232  return content;
233}
234
235/**
236 * Escapes special characters for java symbol -> cpp symbol mapping
237 * Reference: https://docs.oracle.com/en/java/javase/17/docs/specs/jni/design.html#resolving-native-method-names
238 * UTF-16 codes are not supported
239 */
240function escapeJniSymbol(symbol) {
241  const mappings = {
242    '/': '_',
243    _: '_1',
244    ';': '_2',
245    '[': '_3',
246  };
247  return symbol.replace(/[/_;\[]/g, (match) => mappings[match]);
248}
249