1import spawnAsync from '@expo/spawn-async'; 2import chalk from 'chalk'; 3import fs from 'fs-extra'; 4import inquirer from 'inquirer'; 5import path from 'path'; 6import readline from 'readline'; 7 8import * as Directories from '../Directories'; 9import * as Packages from '../Packages'; 10import * as ProjectVersions from '../ProjectVersions'; 11 12type ActionOptions = { 13 sdkVersion: string; 14 packages?: string; 15}; 16 17type Package = { 18 name: string; 19 sourceDir: string; 20 buildDirRelative: string; 21}; 22 23// There are a few packages that we want to exclude from shell app builds; they don't follow any 24// easy pattern so we just keep track of them manually here. 25export const EXCLUDED_PACKAGE_SLUGS = [ 26 'expo-dev-menu', 27 'expo-dev-menu-interface', 28 'expo-module-template', 29 'unimodules-test-core', 30 'unimodules-core', 31 'unimodules-react-native-adapter', 32]; 33 34const EXPO_ROOT_DIR = Directories.getExpoRepositoryRootDir(); 35const ANDROID_DIR = Directories.getAndroidDir(); 36 37const REACT_ANDROID_PKG = { 38 name: 'ReactAndroid', 39 sourceDir: path.join(ANDROID_DIR, 'ReactAndroid'), 40 buildDirRelative: path.join('com', 'facebook', 'react'), 41}; 42const EXPOVIEW_PKG = { 43 name: 'expoview', 44 sourceDir: path.join(ANDROID_DIR, 'expoview'), 45 buildDirRelative: path.join('host', 'exp', 'exponent', 'expoview'), 46}; 47 48async function _findUnimodules(pkgDir: string): Promise<Package[]> { 49 const unimodules: Package[] = []; 50 51 const packages = await Packages.getListOfPackagesAsync(); 52 for (const pkg of packages) { 53 if (!pkg.isSupportedOnPlatform('android') || !pkg.androidPackageName) continue; 54 unimodules.push({ 55 name: pkg.packageSlug, 56 sourceDir: path.join(pkg.path, pkg.androidSubdirectory), 57 buildDirRelative: `${pkg.androidPackageName.replace(/\./g, '/')}/${pkg.packageSlug}`, 58 }); 59 } 60 61 return unimodules; 62} 63 64async function _isPackageUpToDate(sourceDir: string, buildDir: string): Promise<boolean> { 65 try { 66 const sourceCommits = await _gitLogAsync(sourceDir); 67 const buildCommits = await _gitLogAsync(buildDir); 68 69 const latestSourceCommitSha = sourceCommits.lines[0].split(' ')[0]; 70 const latestBuildCommitSha = buildCommits.lines[0].split(' ')[0]; 71 72 // throws if source commit is not an ancestor of build commit 73 await spawnAsync( 74 'git', 75 ['merge-base', '--is-ancestor', latestSourceCommitSha, latestBuildCommitSha], 76 { 77 cwd: EXPO_ROOT_DIR, 78 } 79 ); 80 return true; 81 } catch (e) { 82 return false; 83 } 84} 85 86async function _gitLogAsync(path: string): Promise<{ lines: string[] }> { 87 const child = await spawnAsync('git', ['log', `--pretty=oneline`, '--', path], { 88 stdio: 'pipe', 89 cwd: EXPO_ROOT_DIR, 90 }); 91 92 return { 93 lines: child.stdout 94 .trim() 95 .split(/\r?\n/g) 96 .filter((a) => a), 97 }; 98} 99 100async function _getSuggestedPackagesToBuild(packages: Package[]): Promise<string[]> { 101 let packagesToBuild: string[] = []; 102 for (const pkg of packages) { 103 const isUpToDate = await _isPackageUpToDate( 104 pkg.sourceDir, 105 path.join(EXPO_ROOT_DIR, 'android', 'maven', pkg.buildDirRelative) 106 ); 107 if (!isUpToDate) { 108 packagesToBuild.push(pkg.name); 109 } 110 } 111 return packagesToBuild; 112} 113 114async function _regexFileAsync( 115 filename: string, 116 regex: RegExp | string, 117 replace: string 118): Promise<void> { 119 let file = await fs.readFile(filename); 120 let fileString = file.toString(); 121 await fs.writeFile(filename, fileString.replace(regex, replace)); 122} 123 124let savedFiles = {}; 125async function _stashFilesAsync(filenames: string[]): Promise<void> { 126 for (const filename of filenames) { 127 let file = await fs.readFile(filename); 128 savedFiles[filename] = file.toString(); 129 } 130} 131 132async function _restoreFilesAsync(): Promise<void> { 133 for (const filename in savedFiles) { 134 await fs.writeFile(filename, savedFiles[filename]); 135 delete savedFiles[filename]; 136 } 137} 138 139async function _commentWhenDistributing(filenames: string[]): Promise<void> { 140 for (const filename of filenames) { 141 await _regexFileAsync( 142 filename, 143 /\/\/ WHEN_DISTRIBUTING_REMOVE_FROM_HERE/g, 144 '/* WHEN_DISTRIBUTING_REMOVE_FROM_HERE' 145 ); 146 await _regexFileAsync( 147 filename, 148 /\/\ WHEN_DISTRIBUTING_REMOVE_TO_HERE/g, 149 'WHEN_DISTRIBUTING_REMOVE_TO_HERE */' 150 ); 151 } 152} 153 154async function _uncommentWhenDistributing(filenames: string[]): Promise<void> { 155 for (const filename of filenames) { 156 await _regexFileAsync(filename, '/* UNCOMMENT WHEN DISTRIBUTING', ''); 157 await _regexFileAsync(filename, 'END UNCOMMENT WHEN DISTRIBUTING */', ''); 158 } 159} 160 161async function _updateExpoViewAsync(packages: Package[], sdkVersion: string): Promise<number> { 162 let appBuildGradle = path.join(ANDROID_DIR, 'app', 'build.gradle'); 163 let rootBuildGradle = path.join(ANDROID_DIR, 'build.gradle'); 164 let expoViewBuildGradle = path.join(ANDROID_DIR, 'expoview', 'build.gradle'); 165 const settingsGradle = path.join(ANDROID_DIR, 'settings.gradle'); 166 const constantsJava = path.join( 167 ANDROID_DIR, 168 'expoview/src/main/java/host/exp/exponent/Constants.java' 169 ); 170 const multipleVersionReactNativeActivity = path.join( 171 ANDROID_DIR, 172 'expoview/src/versioned/java/host/exp/exponent/experience/MultipleVersionReactNativeActivity.java' 173 ); 174 175 // Modify permanently 176 await _regexFileAsync(expoViewBuildGradle, /version = '[\d.]+'/, `version = '${sdkVersion}'`); 177 await _regexFileAsync( 178 expoViewBuildGradle, 179 /api 'com.facebook.react:react-native:[\d.]+'/, 180 `api 'com.facebook.react:react-native:${sdkVersion}'` 181 ); 182 await _regexFileAsync( 183 path.join(ANDROID_DIR, 'ReactAndroid', 'release.gradle'), 184 /version = '[\d.]+'/, 185 `version = '${sdkVersion}'` 186 ); 187 await _regexFileAsync( 188 path.join(ANDROID_DIR, 'app', 'build.gradle'), 189 /host.exp.exponent:expoview:[\d.]+/, 190 `host.exp.exponent:expoview:${sdkVersion}` 191 ); 192 193 await _stashFilesAsync([ 194 appBuildGradle, 195 rootBuildGradle, 196 expoViewBuildGradle, 197 multipleVersionReactNativeActivity, 198 constantsJava, 199 settingsGradle, 200 ]); 201 202 // Modify temporarily 203 await _regexFileAsync( 204 constantsJava, 205 /TEMPORARY_ABI_VERSION\s*=\s*null/, 206 `TEMPORARY_ABI_VERSION = "${sdkVersion}"` 207 ); 208 await _uncommentWhenDistributing([appBuildGradle, expoViewBuildGradle]); 209 await _commentWhenDistributing([ 210 constantsJava, 211 rootBuildGradle, 212 expoViewBuildGradle, 213 multipleVersionReactNativeActivity, 214 ]); 215 216 // Clear maven local so that we don't end up with multiple versions 217 console.log(' ❌ Clearing old package versions...'); 218 219 for (const pkg of packages) { 220 await fs.remove(path.join(process.env.HOME!, '.m2', 'repository', pkg.buildDirRelative)); 221 await fs.remove(path.join(ANDROID_DIR, 'maven', pkg.buildDirRelative)); 222 await fs.remove(path.join(pkg.sourceDir, 'build')); 223 } 224 225 // hacky workaround for weird issue where some packages need to be built twice after cleaning 226 // in order to have .so libs included in the aar 227 const reactAndroidIndex = packages.findIndex((pkg) => pkg.name === REACT_ANDROID_PKG.name); 228 if (reactAndroidIndex > -1) { 229 packages.splice(reactAndroidIndex, 0, REACT_ANDROID_PKG); 230 } 231 const expoviewIndex = packages.findIndex((pkg) => pkg.name === EXPOVIEW_PKG.name); 232 if (expoviewIndex > -1) { 233 packages.splice(expoviewIndex, 0, EXPOVIEW_PKG); 234 } 235 236 let failedPackages: string[] = []; 237 for (const pkg of packages) { 238 process.stdout.write(` Building ${pkg.name}...`); 239 try { 240 await spawnAsync('./gradlew', [`:${pkg.name}:uploadArchives`], { 241 cwd: ANDROID_DIR, 242 }); 243 readline.clearLine(process.stdout, 0); 244 readline.cursorTo(process.stdout, 0); 245 process.stdout.write(` ✅ Finished building ${pkg.name}\n`); 246 } catch (e) { 247 if ( 248 e.status === 130 || 249 e.signal === 'SIGINT' || 250 e.status === 137 || 251 e.signal === 'SIGKILL' || 252 e.status === 143 || 253 e.signal === 'SIGTERM' 254 ) { 255 throw e; 256 } else { 257 failedPackages.push(pkg.name); 258 readline.clearLine(process.stdout, 0); 259 readline.cursorTo(process.stdout, 0); 260 process.stdout.write(` ❌ Failed to build ${pkg.name}:\n`); 261 console.error(chalk.red(e.message)); 262 console.error(chalk.red(e.stderr)); 263 } 264 } 265 } 266 267 await _restoreFilesAsync(); 268 269 console.log(' Copying newly built packages...'); 270 271 await fs.mkdirs(path.join(ANDROID_DIR, 'maven/com/facebook')); 272 await fs.mkdirs(path.join(ANDROID_DIR, 'maven/host/exp/exponent')); 273 await fs.mkdirs(path.join(ANDROID_DIR, 'maven/org/unimodules')); 274 275 for (const pkg of packages) { 276 if (failedPackages.includes(pkg.name)) { 277 continue; 278 } 279 await fs.copy( 280 path.join(process.env.HOME!, '.m2', 'repository', pkg.buildDirRelative), 281 path.join(ANDROID_DIR, 'maven', pkg.buildDirRelative) 282 ); 283 } 284 285 if (failedPackages.length) { 286 console.log(' ❌ The following packages failed to build:'); 287 console.log(failedPackages); 288 console.log( 289 `You will need to fix the compilation errors show in the logs above and then run \`et abp -s ${sdkVersion} -p ${failedPackages.join( 290 ',' 291 )}\`` 292 ); 293 } 294 295 return failedPackages.length; 296} 297 298async function action(options: ActionOptions) { 299 process.on('SIGINT', _exitHandler); 300 process.on('SIGTERM', _exitHandler); 301 302 const detachableUniversalModules = ( 303 await _findUnimodules(path.join(EXPO_ROOT_DIR, 'packages')) 304 ).filter((unimodule) => !EXCLUDED_PACKAGE_SLUGS.includes(unimodule.name)); 305 306 // packages must stay in this order -- 307 // ReactAndroid MUST be first and expoview MUST be last 308 const packages: Package[] = [REACT_ANDROID_PKG, ...detachableUniversalModules, EXPOVIEW_PKG]; 309 let packagesToBuild: string[] = []; 310 311 const expoviewBuildGradle = await fs.readFile(path.join(ANDROID_DIR, 'expoview', 'build.gradle')); 312 const match = expoviewBuildGradle 313 .toString() 314 .match(/api 'com.facebook.react:react-native:([\d.]+)'/); 315 if (!match || !match[1]) { 316 throw new Error( 317 'Could not find SDK version in android/expoview/build.gradle: unexpected format' 318 ); 319 } 320 321 if (match[1] !== options.sdkVersion) { 322 console.log( 323 " It looks like you're adding a new SDK version. Ignoring the `--packages` option and rebuilding all packages..." 324 ); 325 packagesToBuild = packages.map((pkg) => pkg.name); 326 } else if (options.packages) { 327 if (options.packages === 'all') { 328 packagesToBuild = packages.map((pkg) => pkg.name); 329 } else if (options.packages === 'suggested') { 330 console.log(' Gathering data about packages...'); 331 packagesToBuild = await _getSuggestedPackagesToBuild(packages); 332 } else { 333 const packageNames = options.packages.split(','); 334 packagesToBuild = packages 335 .map((pkg) => pkg.name) 336 .filter((pkgName) => packageNames.includes(pkgName)); 337 } 338 console.log(' Rebuilding the following packages:'); 339 console.log(packagesToBuild); 340 } else { 341 // gather suggested package data and then show prompts 342 console.log(' Gathering data...'); 343 344 packagesToBuild = await _getSuggestedPackagesToBuild(packages); 345 346 console.log(' ️ It appears that the following packages need to be rebuilt:'); 347 console.log(packagesToBuild); 348 349 const { option } = await inquirer.prompt<{ option: string }>([ 350 { 351 type: 'list', 352 name: 'option', 353 message: 'What would you like to do?', 354 choices: [ 355 { value: 'suggested', name: 'Build the suggested packages only' }, 356 { value: 'all', name: 'Build all packages' }, 357 { value: 'choose', name: 'Choose packages manually' }, 358 ], 359 }, 360 ]); 361 362 if (option === 'all') { 363 packagesToBuild = packages.map((pkg) => pkg.name); 364 } else if (option === 'choose') { 365 const result = await inquirer.prompt<{ packagesToBuild: string[] }>([ 366 { 367 type: 'checkbox', 368 name: 'packagesToBuild', 369 message: 'Choose which packages to build\n ● selected ○ unselected\n', 370 choices: packages.map((pkg) => pkg.name), 371 default: packagesToBuild, 372 pageSize: Math.min(packages.length, (process.stdout.rows || 100) - 2), 373 }, 374 ]); 375 packagesToBuild = result.packagesToBuild; 376 } 377 } 378 379 try { 380 const failedPackagesCount = await _updateExpoViewAsync( 381 packages.filter((pkg) => packagesToBuild.includes(pkg.name)), 382 options.sdkVersion 383 ); 384 if (failedPackagesCount > 0) { 385 process.exitCode = 1; 386 } 387 } catch (e) { 388 await _exitHandler(); 389 throw e; 390 } 391} 392 393async function _exitHandler(): Promise<void> { 394 if (Object.keys(savedFiles).length) { 395 console.log('Exited early, cleaning up...'); 396 await _restoreFilesAsync(); 397 } 398} 399 400export default (program: any) => { 401 program 402 .command('android-build-packages') 403 .alias('abp') 404 .description('Builds all Android AAR packages for Turtle') 405 .option('-s, --sdkVersion [string]', '[optional] SDK version') 406 .option( 407 '-p, --packages [string]', 408 '[optional] packages to build. May be `all`, `suggested`, or a comma-separate list of package names.' 409 ) 410 .asyncAction(async (options: Partial<ActionOptions>) => { 411 const sdkVersion = 412 options.sdkVersion ?? (await ProjectVersions.getNewestSDKVersionAsync('android')); 413 414 if (!sdkVersion) { 415 throw new Error('Could not infer SDK version, please run with `--sdkVersion SDK_VERSION`'); 416 } 417 418 await action({ ...options, sdkVersion }); 419 }); 420}; 421