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