1import spawnAsync from '@expo/spawn-async'; 2import chalk from 'chalk'; 3import fs from 'fs-extra'; 4import glob from 'glob-promise'; 5import minimatch from 'minimatch'; 6import path from 'path'; 7import semver from 'semver'; 8 9import * as Directories from '../../Directories'; 10import { getListOfPackagesAsync } from '../../Packages'; 11import { copyFileWithTransformsAsync } from '../../Transforms'; 12import { searchFilesAsync } from '../../Utils'; 13import { copyExpoviewAsync } from './copyExpoview'; 14import { expoModulesTransforms } from './expoModulesTransforms'; 15import { packagesToKeep } from './packagesConfig'; 16import { versionCxxExpoModulesAsync } from './versionCxx'; 17import { updateVersionedReactNativeAsync } from './versionReactNative'; 18import { removeVersionedVendoredModulesAsync } from './versionVendoredModules'; 19 20export { versionVendoredModulesAsync } from './versionVendoredModules'; 21 22const EXPO_DIR = Directories.getExpoRepositoryRootDir(); 23const ANDROID_DIR = Directories.getAndroidDir(); 24const EXPOTOOLS_DIR = Directories.getExpotoolsDir(); 25const SCRIPT_DIR = path.join(EXPOTOOLS_DIR, 'src/versioning/android'); 26const SED_PREFIX = process.platform === 'darwin' ? "sed -i ''" : 'sed -i'; 27 28const appPath = path.join(ANDROID_DIR, 'app'); 29const expoviewPath = path.join(ANDROID_DIR, 'expoview'); 30const versionedAbisPath = path.join(ANDROID_DIR, 'versioned-abis'); 31const versionedExpoviewAbiPath = (abiName) => path.join(versionedAbisPath, `expoview-${abiName}`); 32const expoviewBuildGradlePath = path.join(expoviewPath, 'build.gradle'); 33const appManifestPath = path.join(appPath, 'src', 'main', 'AndroidManifest.xml'); 34const templateManifestPath = path.join( 35 EXPO_DIR, 36 'template-files', 37 'android', 38 'AndroidManifest.xml' 39); 40const settingsGradlePath = path.join(ANDROID_DIR, 'settings.gradle'); 41const appBuildGradlePath = path.join(appPath, 'build.gradle'); 42const buildGradlePath = path.join(ANDROID_DIR, 'build.gradle'); 43const sdkVersionsPath = path.join(ANDROID_DIR, 'sdkVersions.json'); 44const rnActivityPath = path.join( 45 expoviewPath, 46 'src/versioned/java/host/exp/exponent/experience/MultipleVersionReactNativeActivity.java' 47); 48const expoviewConstantsPath = path.join( 49 expoviewPath, 50 'src/main/java/host/exp/exponent/Constants.java' 51); 52const testSuiteTestsPath = path.join( 53 appPath, 54 'src/androidTest/java/host/exp/exponent/TestSuiteTests.kt' 55); 56const versionedReactAndroidPath = path.join(ANDROID_DIR, 'versioned-react-native/ReactAndroid'); 57const versionedHermesPath = path.join(ANDROID_DIR, 'versioned-react-native/sdks/hermes'); 58 59async function transformFileAsync(filePath: string, regexp: RegExp, replacement: string = '') { 60 const fileContent = await fs.readFile(filePath, 'utf8'); 61 await fs.writeFile(filePath, fileContent.replace(regexp, replacement)); 62} 63 64async function removeVersionReferencesFromFileAsync(sdkMajorVersion: string, filePath: string) { 65 console.log( 66 `Removing code surrounded by ${chalk.gray(`// BEGIN_SDK_${sdkMajorVersion}`)} and ${chalk.gray( 67 `// END_SDK_${sdkMajorVersion}` 68 )} from ${chalk.magenta(path.relative(EXPO_DIR, filePath))}...` 69 ); 70 await transformFileAsync( 71 filePath, 72 new RegExp( 73 `\\s*//\\s*BEGIN_SDK_${sdkMajorVersion}(_\d+)*\\n.*?//\\s*END_SDK_${sdkMajorVersion}(_\d+)*`, 74 'gs' 75 ), 76 '' 77 ); 78} 79 80async function removeVersionedExpoviewAsync(versionedExpoviewAbiPath: string) { 81 console.log( 82 `Removing versioned expoview at ${chalk.magenta( 83 path.relative(EXPO_DIR, versionedExpoviewAbiPath) 84 )}...` 85 ); 86 await fs.remove(versionedExpoviewAbiPath); 87} 88 89async function removeFromManifestAsync(sdkMajorVersion: string, manifestPath: string) { 90 console.log( 91 `Removing code surrounded by ${chalk.gray( 92 `<!-- BEGIN_SDK_${sdkMajorVersion} -->` 93 )} and ${chalk.gray(`<!-- END_SDK_${sdkMajorVersion} -->`)} from ${chalk.magenta( 94 path.relative(EXPO_DIR, manifestPath) 95 )}...` 96 ); 97 await transformFileAsync( 98 manifestPath, 99 new RegExp( 100 `\\s*<!--\\s*BEGIN_SDK_${sdkMajorVersion}(_\d+)*\\s*-->.*?<!--\\s*END_SDK_${sdkMajorVersion}(_\d+)*\\s*-->`, 101 'gs' 102 ), 103 '' 104 ); 105} 106 107async function removeFromSettingsGradleAsync(abiName: string, settingsGradlePath: string) { 108 console.log( 109 `Removing ${chalk.green(`expoview-${abiName}`)} from ${chalk.magenta( 110 path.relative(EXPO_DIR, settingsGradlePath) 111 )}...` 112 ); 113 const sdkVersion = abiName.replace(/abi(\d+)_0_0/, 'sdk$1'); 114 await transformFileAsync(settingsGradlePath, new RegExp(`\\n\\s*"${abiName}",[^\\n]*`, 'g'), ''); 115 await transformFileAsync( 116 settingsGradlePath, 117 new RegExp(`\\nuseVendoredModulesForSettingsGradle\\('${sdkVersion}'\\)[^\\n]*`, 'g'), 118 '' 119 ); 120} 121 122async function removeFromBuildGradleAsync(abiName: string, buildGradlePath: string) { 123 console.log( 124 `Removing maven repository for ${chalk.green(`expoview-${abiName}`)} from ${chalk.magenta( 125 path.relative(EXPO_DIR, buildGradlePath) 126 )}...` 127 ); 128 await transformFileAsync( 129 buildGradlePath, 130 new RegExp(`\\s*maven\\s*{\\s*url\\s*".*?/expoview-${abiName}/maven"\\s*}[^\\n]*`), 131 '' 132 ); 133} 134 135async function removeFromSdkVersionsAsync(version: string, sdkVersionsPath: string) { 136 console.log( 137 `Removing ${chalk.cyan(version)} from ${chalk.magenta( 138 path.relative(EXPO_DIR, sdkVersionsPath) 139 )}...` 140 ); 141 await transformFileAsync(sdkVersionsPath, new RegExp(`"${version}",\s*`, 'g'), ''); 142} 143 144async function removeTestSuiteTestsAsync(version: string, testsFilePath: string) { 145 console.log( 146 `Removing test-suite tests from ${chalk.magenta(path.relative(EXPO_DIR, testsFilePath))}...` 147 ); 148 await transformFileAsync( 149 testsFilePath, 150 new RegExp(`\\s*(@\\w+\\s+)*@ExpoSdkVersionTest\\("${version}"\\)[^}]+}`), 151 '' 152 ); 153} 154 155async function findAndPrintVersionReferencesInSourceFilesAsync(version: string): Promise<boolean> { 156 const pattern = new RegExp( 157 `(${version.replace(/\./g, '[._]')}|(SDK|ABI).?${semver.major(version)})`, 158 'ig' 159 ); 160 let matchesCount = 0; 161 162 const files = await glob('**/{src/**/*.@(java|kt|xml),build.gradle}', { 163 cwd: ANDROID_DIR, 164 ignore: 'vendored/**/*', 165 }); 166 167 for (const file of files) { 168 const filePath = path.join(ANDROID_DIR, file); 169 const fileContent = await fs.readFile(filePath, 'utf8'); 170 const fileLines = fileContent.split(/\r\n?|\n/g); 171 let match; 172 173 while ((match = pattern.exec(fileContent)) != null) { 174 const index = pattern.lastIndex - match[0].length; 175 const lineNumberWithMatch = fileContent.substring(0, index).split(/\r\n?|\n/g).length - 1; 176 const firstLineInContext = Math.max(0, lineNumberWithMatch - 2); 177 const lastLineInContext = Math.min(lineNumberWithMatch + 2, fileLines.length); 178 179 ++matchesCount; 180 181 console.log( 182 `Found ${chalk.bold.green(match[0])} in ${chalk.magenta( 183 path.relative(EXPO_DIR, filePath) 184 )}:` 185 ); 186 187 for (let lineIndex = firstLineInContext; lineIndex <= lastLineInContext; lineIndex++) { 188 console.log( 189 `${chalk.gray(1 + lineIndex + ':')} ${fileLines[lineIndex].replace( 190 match[0], 191 chalk.bgMagenta(match[0]) 192 )}` 193 ); 194 } 195 console.log(); 196 } 197 } 198 return matchesCount > 0; 199} 200 201export async function removeVersionAsync(version: string) { 202 const abiName = `abi${version.replace(/\./g, '_')}`; 203 const sdkMajorVersion = `${semver.major(version)}`; 204 205 console.log(`Removing SDK version ${chalk.cyan(version)} for ${chalk.blue('Android')}...`); 206 207 // Remove expoview-abi*_0_0 library 208 await removeVersionedExpoviewAsync(versionedExpoviewAbiPath(abiName)); 209 await removeFromSettingsGradleAsync(abiName, settingsGradlePath); 210 await removeFromBuildGradleAsync(abiName, buildGradlePath); 211 212 // Remove code surrounded by BEGIN_SDK_* and END_SDK_* 213 await removeVersionReferencesFromFileAsync(sdkMajorVersion, expoviewBuildGradlePath); 214 await removeVersionReferencesFromFileAsync(sdkMajorVersion, appBuildGradlePath); 215 await removeVersionReferencesFromFileAsync(sdkMajorVersion, rnActivityPath); 216 await removeVersionReferencesFromFileAsync(sdkMajorVersion, expoviewConstantsPath); 217 218 // Remove test-suite tests from the app. 219 await removeTestSuiteTestsAsync(version, testSuiteTestsPath); 220 221 // Update AndroidManifests 222 await removeFromManifestAsync(sdkMajorVersion, appManifestPath); 223 await removeFromManifestAsync(sdkMajorVersion, templateManifestPath); 224 225 // Remove vendored modules 226 await removeVersionedVendoredModulesAsync(version); 227 228 // Remove SDK version from the list of supported SDKs 229 await removeFromSdkVersionsAsync(version, sdkVersionsPath); 230 231 console.log(`\nLooking for SDK references in source files...`); 232 233 if (await findAndPrintVersionReferencesInSourceFilesAsync(version)) { 234 console.log( 235 chalk.yellow(`Please review all of these references and remove them manually if possible!\n`) 236 ); 237 } 238} 239 240async function copyExpoModulesAsync(version: string) { 241 const packages = await getListOfPackagesAsync(); 242 for (const pkg of packages) { 243 if ( 244 pkg.isSupportedOnPlatform('android') && 245 pkg.isIncludedInExpoClientOnPlatform('android') && 246 pkg.isVersionableOnPlatform('android') 247 ) { 248 const abiVersion = `abi${version.replace(/\./g, '_')}`; 249 const targetDirectory = path.join(ANDROID_DIR, `versioned-abis/expoview-${abiVersion}`); 250 const sourceDirectory = path.join(pkg.path, pkg.androidSubdirectory); 251 const transforms = expoModulesTransforms(pkg.packageName, abiVersion); 252 253 const files = await searchFilesAsync(sourceDirectory, [ 254 './src/main/java/**', 255 './src/main/kotlin/**', 256 './src/main/AndroidManifest.xml', 257 ]); 258 259 for (const javaPkg of packagesToKeep) { 260 const javaPkgWithSlash = javaPkg.replace(/\./g, '/'); 261 const pathFromPackage = `./src/main/{java,kotlin}/${javaPkgWithSlash}{/**,.java,.kt}`; 262 for (const file of files) { 263 if (minimatch(file, pathFromPackage)) { 264 files.delete(file); 265 continue; 266 } 267 } 268 } 269 270 for (const sourceFile of files) { 271 await copyFileWithTransformsAsync({ 272 sourceFile, 273 targetDirectory, 274 sourceDirectory, 275 transforms, 276 }); 277 } 278 const temporaryPackageManifestPath = path.join( 279 targetDirectory, 280 'src/main/TemporaryExpoModuleAndroidManifest.xml' 281 ); 282 const mainManifestPath = path.join(targetDirectory, 'src/main/AndroidManifest.xml'); 283 await spawnAsync('java', [ 284 '-jar', 285 path.join(SCRIPT_DIR, 'android-manifest-merger-3898d3a.jar'), 286 '--main', 287 mainManifestPath, 288 '--libs', 289 temporaryPackageManifestPath, 290 '--placeholder', 291 'applicationId=${applicationId}', 292 '--out', 293 mainManifestPath, 294 '--log', 295 'WARNING', 296 ]); 297 await fs.remove(temporaryPackageManifestPath); 298 console.log(` ✅ Created versioned ${pkg.packageName}`); 299 } 300 } 301} 302 303async function addVersionedActivitesToManifests(version: string) { 304 const abiVersion = version.replace(/\./g, '_'); 305 const abiName = `abi${abiVersion}`; 306 const majorVersion = semver.major(version); 307 308 await transformFileAsync( 309 templateManifestPath, 310 new RegExp('<!-- ADD DEV SETTINGS HERE -->'), 311 `<!-- ADD DEV SETTINGS HERE --> 312 <!-- BEGIN_SDK_${majorVersion} --> 313 <activity android:name="${abiName}.com.facebook.react.devsupport.DevSettingsActivity"/> 314 <!-- END_SDK_${majorVersion} -->` 315 ); 316} 317 318async function registerNewVersionUnderSdkVersions(version: string) { 319 const fileString = await fs.readFile(sdkVersionsPath, 'utf8'); 320 let jsConfig; 321 // read the existing json config and add the new version to the sdkVersions array 322 try { 323 jsConfig = JSON.parse(fileString); 324 } catch (e) { 325 console.log('Error parsing existing sdkVersions.json file, writing a new one...', e); 326 console.log('The erroneous file contents was:', fileString); 327 jsConfig = { 328 sdkVersions: [], 329 }; 330 } 331 // apply changes 332 jsConfig.sdkVersions.push(version); 333 await fs.writeFile(sdkVersionsPath, JSON.stringify(jsConfig)); 334} 335 336async function cleanUpAsync(version: string) { 337 const abiVersion = version.replace(/\./g, '_'); 338 const abiName = `abi${abiVersion}`; 339 340 const versionedAbiSrcPath = path.join( 341 versionedExpoviewAbiPath(abiName), 342 'src/main/java', 343 abiName 344 ); 345 346 const filesToDelete: string[] = []; 347 348 // delete PrintDocumentAdapter*Callback.kt 349 // their package is `android.print` and therefore they are not changed by the versioning script 350 // so we will have duplicate classes 351 const printCallbackFiles = await glob( 352 path.join(versionedAbiSrcPath, 'expo/modules/print/*Callback.kt') 353 ); 354 for (const file of printCallbackFiles) { 355 const contents = await fs.readFile(file, 'utf8'); 356 if (!contents.includes(`package ${abiName}`)) { 357 filesToDelete.push(file); 358 } else { 359 console.log(`Skipping deleting ${file} because it appears to have been versioned`); 360 } 361 } 362 363 // delete versioned loader providers since we don't need them 364 filesToDelete.push(path.join(versionedAbiSrcPath, 'expo/loaders')); 365 366 console.log('Deleting the following files and directories:'); 367 console.log(filesToDelete); 368 369 for (const file of filesToDelete) { 370 await fs.remove(file); 371 } 372 373 // misc fixes for versioned code 374 const versionedExponentPackagePath = path.join( 375 versionedAbiSrcPath, 376 'host/exp/exponent/ExponentPackage.kt' 377 ); 378 await transformFileAsync( 379 versionedExponentPackagePath, 380 new RegExp('// WHEN_VERSIONING_REMOVE_FROM_HERE', 'g'), 381 '/* WHEN_VERSIONING_REMOVE_FROM_HERE' 382 ); 383 await transformFileAsync( 384 versionedExponentPackagePath, 385 new RegExp('// WHEN_VERSIONING_REMOVE_TO_HERE', 'g'), 386 'WHEN_VERSIONING_REMOVE_TO_HERE */' 387 ); 388 389 await transformFileAsync( 390 path.join(versionedAbiSrcPath, 'host/exp/exponent/VersionedUtils.kt'), 391 new RegExp('// DO NOT EDIT THIS COMMENT - used by versioning scripts[^,]+,[^,]+,'), 392 'null, null,' 393 ); 394 395 // replace abixx_x_x...R with abixx_x_x.host.exp.expoview.R 396 await spawnAsync( 397 `find ${versionedAbiSrcPath} -iname '*.java' -type f -print0 | ` + 398 `xargs -0 ${SED_PREFIX} 's/import ${abiName}\.[^;]*\.R;/import ${abiName}.host.exp.expoview.R;/g'`, 399 [], 400 { shell: true } 401 ); 402 await spawnAsync( 403 `find ${versionedAbiSrcPath} -iname '*.kt' -type f -print0 | ` + 404 `xargs -0 ${SED_PREFIX} 's/import ${abiName}\\..*\\.R$/import ${abiName}.host.exp.expoview.R/g'`, 405 [], 406 { shell: true } 407 ); 408 409 // add new versioned maven to build.gradle 410 await transformFileAsync( 411 buildGradlePath, 412 new RegExp('// For old expoviews to work'), 413 `// For old expoviews to work 414 maven { 415 url "$rootDir/versioned-abis/expoview-${abiName}/maven" 416 }` 417 ); 418} 419 420async function exportReactNdks() { 421 const versionedRN = path.join(versionedReactAndroidPath, '..'); 422 await spawnAsync(`./gradlew :ReactAndroid:packageReactNdkLibs`, [], { 423 shell: true, 424 cwd: versionedRN, 425 stdio: 'inherit', 426 env: { 427 ...process.env, 428 REACT_NATIVE_OVERRIDE_HERMES_DIR: versionedHermesPath, 429 }, 430 }); 431} 432 433async function exportReactNdksIfNeeded() { 434 const ndksPath = path.join(versionedReactAndroidPath, 'build', 'react-ndk', 'exported'); 435 const exists = await fs.pathExists(ndksPath); 436 if (!exists) { 437 await exportReactNdks(); 438 return; 439 } 440 441 const exportedSO = await glob(path.join(ndksPath, '**/*.so')); 442 if (exportedSO.length === 0) { 443 await exportReactNdks(); 444 } 445} 446 447export async function addVersionAsync(version: string) { 448 console.log(' 1/9: Updating android/versioned-react-native...'); 449 await updateVersionedReactNativeAsync(ANDROID_DIR, version); 450 console.log(' ✅ 1/9: Finished\n\n'); 451 452 console.log(' 2/9: Building versioned ReactAndroid AAR...'); 453 await spawnAsync('./android-build-aar.sh', [version], { 454 shell: true, 455 cwd: SCRIPT_DIR, 456 stdio: 'inherit', 457 }); 458 console.log(' ✅ 2/9: Finished\n\n'); 459 460 console.log(' 3/9: Creating versioned expoview package...'); 461 await copyExpoviewAsync(version, ANDROID_DIR); 462 console.log(' ✅ 3/9: Finished\n\n'); 463 464 console.log(' 4/9: Exporting react ndks if needed...'); 465 await exportReactNdksIfNeeded(); 466 console.log(' ✅ 4/9: Finished\n\n'); 467 468 console.log(' 5/9: Creating versioned expo-modules packages...'); 469 await copyExpoModulesAsync(version); 470 console.log(' ✅ 5/9: Finished\n\n'); 471 472 console.log(' 6/9: Versoning c++ libraries for expo-modules...'); 473 await versionCxxExpoModulesAsync(version); 474 console.log(' ✅ 6/9: Finished\n\n'); 475 476 console.log(' 7/9: Adding extra versioned activites to AndroidManifest...'); 477 await addVersionedActivitesToManifests(version); 478 console.log(' ✅ 7/9: Finished\n\n'); 479 480 console.log(' 8/9: Registering new version under sdkVersions config...'); 481 await registerNewVersionUnderSdkVersions(version); 482 console.log(' ✅ 8/9: Finished\n\n'); 483 484 console.log(' 9/9: Misc cleanup...'); 485 await cleanUpAsync(version); 486 console.log(' ✅ 9/9: Finished'); 487 488 const abiVersion = `abi${version.replace(/\./g, '_')}`; 489 const versionedAar = path.join( 490 versionedExpoviewAbiPath(abiVersion), 491 `maven/host/exp/reactandroid-${abiVersion}/1.0.0/reactandroid-${abiVersion}-1.0.0.aar` 492 ); 493 console.log( 494 '\n' + 495 chalk.yellow( 496 '################################################################################################################' 497 ) + 498 `\nIf you want to commit the versioned code to git, please also upload the versioned aar at ${chalk.cyan( 499 versionedAar 500 )} to:\n` + 501 chalk.cyan( 502 `https://github.com/expo/react-native/releases/download/sdk-${version}/reactandroid-${abiVersion}-1.0.0.aar` 503 ) + 504 '\n' + 505 chalk.yellow( 506 '################################################################################################################' 507 ) + 508 '\n' 509 ); 510} 511