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