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