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