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