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 'expo-modules-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 { 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 const 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 const file = await fs.readFile(filename); 122 const fileString = file.toString(); 123 await fs.writeFile(filename, fileString.replace(regex, replace)); 124} 125 126const savedFiles = {}; 127async function _stashFilesAsync(filenames: string[]): Promise<void> { 128 for (const filename of filenames) { 129 const 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 const appBuildGradle = path.join(ANDROID_DIR, 'app', 'build.gradle'); 165 const rootBuildGradle = path.join(ANDROID_DIR, 'build.gradle'); 166 const 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', 'build.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 const failedPackages: string[] = []; 239 for (const pkg of packages) { 240 process.stdout.write(` Building ${pkg.name}...`); 241 try { 242 await spawnAsync('./gradlew', [`:${pkg.name}:publish`], { 243 cwd: ANDROID_DIR, 244 }); 245 readline.clearLine(process.stdout, 0); 246 readline.cursorTo(process.stdout, 0); 247 process.stdout.write(` ✅ Finished building ${pkg.name}\n`); 248 } catch (e) { 249 if ( 250 e.status === 130 || 251 e.signal === 'SIGINT' || 252 e.status === 137 || 253 e.signal === 'SIGKILL' || 254 e.status === 143 || 255 e.signal === 'SIGTERM' 256 ) { 257 throw e; 258 } else { 259 failedPackages.push(pkg.name); 260 readline.clearLine(process.stdout, 0); 261 readline.cursorTo(process.stdout, 0); 262 process.stdout.write(` ❌ Failed to build ${pkg.name}:\n`); 263 console.error(chalk.red(e.message)); 264 console.error(chalk.red(e.stderr)); 265 } 266 } 267 } 268 269 await _restoreFilesAsync(); 270 271 console.log(' Copying newly built packages...'); 272 273 await fs.mkdirs(path.join(ANDROID_DIR, 'maven/com/facebook')); 274 await fs.mkdirs(path.join(ANDROID_DIR, 'maven/host/exp/exponent')); 275 await fs.mkdirs(path.join(ANDROID_DIR, 'maven/org/unimodules')); 276 277 for (const pkg of packages) { 278 if (failedPackages.includes(pkg.name)) { 279 continue; 280 } 281 await fs.copy( 282 path.join(process.env.HOME!, '.m2', 'repository', pkg.buildDirRelative), 283 path.join(ANDROID_DIR, 'maven', pkg.buildDirRelative) 284 ); 285 } 286 287 if (failedPackages.length) { 288 console.log(' ❌ The following packages failed to build:'); 289 console.log(failedPackages); 290 console.log( 291 `You will need to fix the compilation errors show in the logs above and then run \`et abp -s ${sdkVersion} -p ${failedPackages.join( 292 ',' 293 )}\`` 294 ); 295 } 296 297 return failedPackages.length; 298} 299 300async function action(options: ActionOptions) { 301 process.on('SIGINT', _exitHandler); 302 process.on('SIGTERM', _exitHandler); 303 304 const detachableUniversalModules = ( 305 await _findUnimodules(path.join(EXPO_ROOT_DIR, 'packages')) 306 ).filter((unimodule) => !EXCLUDED_PACKAGE_SLUGS.includes(unimodule.name)); 307 308 // packages must stay in this order -- 309 // ReactAndroid MUST be first and expoview MUST be last 310 const packages: Package[] = [REACT_ANDROID_PKG, ...detachableUniversalModules, EXPOVIEW_PKG]; 311 let packagesToBuild: string[] = []; 312 313 const expoviewBuildGradle = await fs.readFile(path.join(ANDROID_DIR, 'expoview', 'build.gradle')); 314 const match = expoviewBuildGradle 315 .toString() 316 .match(/api 'com.facebook.react:react-native:([\d.]+)'/); 317 if (!match || !match[1]) { 318 throw new Error( 319 'Could not find SDK version in android/expoview/build.gradle: unexpected format' 320 ); 321 } 322 323 if (match[1] !== options.sdkVersion) { 324 console.log( 325 " It looks like you're adding a new SDK version. Ignoring the `--packages` option and rebuilding all packages..." 326 ); 327 packagesToBuild = packages.map((pkg) => pkg.name); 328 } else if (options.packages) { 329 if (options.packages === 'all') { 330 packagesToBuild = packages.map((pkg) => pkg.name); 331 } else if (options.packages === 'suggested') { 332 console.log(' Gathering data about packages...'); 333 packagesToBuild = await _getSuggestedPackagesToBuild(packages); 334 } else { 335 const packageNames = options.packages.split(','); 336 packagesToBuild = packages 337 .map((pkg) => pkg.name) 338 .filter((pkgName) => packageNames.includes(pkgName)); 339 } 340 console.log(' Rebuilding the following packages:'); 341 console.log(packagesToBuild); 342 } else { 343 // gather suggested package data and then show prompts 344 console.log(' Gathering data...'); 345 346 packagesToBuild = await _getSuggestedPackagesToBuild(packages); 347 348 console.log(' ️ It appears that the following packages need to be rebuilt:'); 349 console.log(packagesToBuild); 350 351 const { option } = await inquirer.prompt<{ option: string }>([ 352 { 353 type: 'list', 354 name: 'option', 355 message: 'What would you like to do?', 356 choices: [ 357 { value: 'suggested', name: 'Build the suggested packages only' }, 358 { value: 'all', name: 'Build all packages' }, 359 { value: 'choose', name: 'Choose packages manually' }, 360 ], 361 }, 362 ]); 363 364 if (option === 'all') { 365 packagesToBuild = packages.map((pkg) => pkg.name); 366 } else if (option === 'choose') { 367 const result = await inquirer.prompt<{ packagesToBuild: string[] }>([ 368 { 369 type: 'checkbox', 370 name: 'packagesToBuild', 371 message: 'Choose which packages to build\n ● selected ○ unselected\n', 372 choices: packages.map((pkg) => pkg.name), 373 default: packagesToBuild, 374 pageSize: Math.min(packages.length, (process.stdout.rows || 100) - 2), 375 }, 376 ]); 377 packagesToBuild = result.packagesToBuild; 378 } 379 } 380 381 try { 382 const failedPackagesCount = await _updateExpoViewAsync( 383 packages.filter((pkg) => packagesToBuild.includes(pkg.name)), 384 options.sdkVersion 385 ); 386 if (failedPackagesCount > 0) { 387 process.exitCode = 1; 388 } 389 } catch (e) { 390 await _exitHandler(); 391 throw e; 392 } 393} 394 395async function _exitHandler(): Promise<void> { 396 if (Object.keys(savedFiles).length) { 397 console.log('Exited early, cleaning up...'); 398 await _restoreFilesAsync(); 399 } 400} 401 402export default (program: any) => { 403 program 404 .command('android-build-packages') 405 .alias('abp') 406 .description('Builds all Android AAR packages for Turtle') 407 .option('-s, --sdkVersion [string]', '[optional] SDK version') 408 .option( 409 '-p, --packages [string]', 410 '[optional] packages to build. May be `all`, `suggested`, or a comma-separate list of package names.' 411 ) 412 .asyncAction(async (options: Partial<ActionOptions>) => { 413 const sdkVersion = 414 options.sdkVersion ?? (await ProjectVersions.getNewestSDKVersionAsync('android')); 415 416 if (!sdkVersion) { 417 throw new Error('Could not infer SDK version, please run with `--sdkVersion SDK_VERSION`'); 418 } 419 420 await action({ ...options, sdkVersion }); 421 }); 422}; 423