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