1import chalk from 'chalk'; 2import { pathExists, readJSON, unlink, writeJSON } from 'fs-extra'; 3import glob from 'glob-promise'; 4import ora from 'ora'; 5import * as path from 'path'; 6 7import * as Directories from '../Directories'; 8import logger from '../Logger'; 9import { spawnAsync, SpawnResult } from '../Utils'; 10import { 11 AndroidProjectReport, 12 GradleDependency, 13 RawGradleDependency, 14 RawGradleReport, 15} from './types'; 16 17export const REVISIONS = ['release', 'milestone', 'integration'] as const; 18 19export type Revision = typeof REVISIONS[number]; 20 21export interface GradleTaskOptions { 22 revision: Revision; 23 clearCache: boolean; 24} 25 26function flatSingle<T>(arr: T[][]) { 27 return arr.flatMap((it) => it); 28} 29 30function generateReportCacheFilePath(dateTimestamp: Date, gradleTaskOptions: GradleTaskOptions) { 31 const date = `${dateTimestamp.getUTCFullYear()}.${dateTimestamp.getUTCMonth()}.${dateTimestamp.getUTCDate()}`; 32 return `${Directories.getExpotoolsDir()}/cache/android-gradle-updatesReport.${ 33 gradleTaskOptions.revision 34 }.${date}.cache.json`; 35} 36 37async function readCachedReports(reportFilename: string): Promise<AndroidProjectReport[] | null> { 38 if (!(await pathExists(reportFilename))) { 39 return null; 40 } 41 return readJSON(reportFilename); 42} 43 44async function cacheReports(reportFilename: string, reports: AndroidProjectReport[]) { 45 await writeJSON(reportFilename, reports); 46} 47 48async function clearCachedReports(reportFilename: string) { 49 if (await pathExists(reportFilename)) { 50 await unlink(reportFilename); 51 } 52} 53 54/** 55 * Checks for gradle executable in provided android project directory. 56 */ 57async function determineGradleWrapperCommand(androidProjectDir: string): Promise<string> { 58 const gradleWrapperFilename = process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'; 59 const gradleWrapperCommand = path.join(androidProjectDir, gradleWrapperFilename); 60 if (!(await pathExists(gradleWrapperCommand))) { 61 throw new Error(`Gradle ${gradleWrapperCommand} does not exist.`); 62 } 63 return gradleWrapperCommand; 64} 65 66/** 67 * Executes `gradle dependencyUpdates` task that generates gradle dependencies updates report in 68 * `build/dependencyUpdates.json` files. 69 */ 70async function executeGradleTask( 71 androidProjectDir: string, 72 gradleTaskOptions: GradleTaskOptions 73): Promise<SpawnResult | undefined> { 74 const gradleWrapperCommand = await determineGradleWrapperCommand(androidProjectDir); 75 76 const gradleInitScriptCommand = `--init-script=${path.join( 77 __dirname, 78 '../../src/android-update-native-dependencies', 79 'initScript.gradle' 80 )}`; 81 const gradleCommandArguments = [ 82 'dependencyUpdates', 83 gradleInitScriptCommand, 84 '-DoutputFormatter=json', 85 `-DoutputDir=build/dependencyUpdates`, 86 `-Drevision=${gradleTaskOptions.revision}`, 87 ]; 88 const spinner = ora({ 89 spinner: 'dots', 90 text: `Executing gradle command ${chalk.yellow( 91 `${gradleWrapperCommand} ${gradleCommandArguments.join(' ')}` 92 )}. This might take a while.`, 93 }); 94 95 spinner.start(); 96 try { 97 const result = await spawnAsync(gradleWrapperCommand, gradleCommandArguments, { 98 cwd: androidProjectDir, 99 }); 100 if (result.status !== 0) { 101 throw result.stderr; 102 } 103 spinner.succeed(); 104 return result; 105 } catch (error) { 106 logger.error('Gradle process failed with an error.', error); 107 spinner.fail(); 108 } 109 return undefined; 110} 111 112/** 113 * Reads gradle reports and converts it into Android report 114 */ 115async function readGradleReportAndConvertIntoAndroidReport( 116 reportPath: string 117): Promise<AndroidProjectReport> { 118 const mapRawGradleDependency = ({ 119 group, 120 name, 121 available, 122 version, 123 projectUrl, 124 }: RawGradleDependency): GradleDependency => ({ 125 group, 126 name, 127 fullName: `${group}:${name}`, 128 availableVersion: available?.release ?? available?.milestone ?? available?.integration ?? null, 129 currentVersion: version, 130 projectUrl, 131 }); 132 133 const findChangelogFilePath = async (reportPath: string): Promise<string | null> => { 134 const changelogPath = path.resolve(reportPath, '../../../../CHANGELOG.md'); 135 if (!reportPath.includes('/packages/')) { 136 return null; 137 } 138 if (!(await pathExists(changelogPath))) { 139 return null; 140 } 141 return changelogPath; 142 }; 143 144 const findGradleFilePath = async (reportPath: string): Promise<string> => { 145 const gradleBuildGroovy = path.resolve(reportPath, '../../../build.gradle'); 146 const gradleBuildKotlin = path.resolve(reportPath, '../../../build.gradle.kts'); 147 if (await pathExists(gradleBuildGroovy)) { 148 return gradleBuildGroovy; 149 } 150 if (await pathExists(gradleBuildKotlin)) { 151 return gradleBuildKotlin; 152 } 153 throw new Error(`Failed to locate gradle.build(.kts)? for report: ${reportPath}`); 154 }; 155 156 const rawGradleUpdatesReport = (await readJSON(reportPath)) as RawGradleReport; 157 158 const gradleFilePath = await findGradleFilePath(reportPath); 159 const projectPath = path.dirname(gradleFilePath).endsWith('android') 160 ? path.resolve(path.dirname(gradleFilePath), '..') 161 : path.dirname(gradleFilePath); 162 const projectName = projectPath.includes('/packages/') 163 ? path.relative(Directories.getPackagesDir(), projectPath) 164 : path.relative(Directories.getExpoRepositoryRootDir(), projectPath); 165 166 return { 167 gradleReport: { 168 current: rawGradleUpdatesReport.current.dependencies.map(mapRawGradleDependency), 169 exceeded: rawGradleUpdatesReport.exceeded.dependencies.map(mapRawGradleDependency), 170 unresolved: rawGradleUpdatesReport.unresolved.dependencies.map(mapRawGradleDependency), 171 outdated: rawGradleUpdatesReport.outdated.dependencies.map(mapRawGradleDependency), 172 }, 173 gradleFilePath, 174 rawGradleReport: rawGradleUpdatesReport, 175 projectPath, 176 projectName, 177 changelogPath: await findChangelogFilePath(reportPath), 178 }; 179} 180 181async function readAndConvertReports(): Promise<AndroidProjectReport[]> { 182 const findGradleReportsFiles = async (cwd: string): Promise<string[]> => { 183 const result = await glob('**/build/dependencyUpdates/report.json', { 184 cwd, 185 ignore: ['**/node_modules, **/ios'], 186 }); 187 return Promise.all(result.map(async (el) => path.resolve(cwd, el))); 188 }; 189 190 const gradleReportsPaths: string[] = flatSingle( 191 await Promise.all( 192 [ 193 Directories.getPackagesDir(), 194 Directories.getAndroidDir(), 195 path.join(Directories.getAppsDir(), 'bare-expo/android'), 196 ].map(findGradleReportsFiles) 197 ) 198 ); 199 200 const androidProjectUpdatesReport: AndroidProjectReport[] = await Promise.all( 201 gradleReportsPaths.map(readGradleReportAndConvertIntoAndroidReport) 202 ); 203 204 return androidProjectUpdatesReport.sort((a, b) => a.projectName.localeCompare(b.projectName)); 205} 206 207/** 208 * Gets Android project report by: 209 * - running gradle task that generates json files describing gradle status unless 210 * there's report available for a current day 211 * - reading these json reports files and converting them into Android project report 212 * 213 * Date timestamping is used to prevent time-consuming gradle task reruns 214 * and caching accumulated gradle reports in json file. 215 */ 216export async function getAndroidProjectReports( 217 options: GradleTaskOptions 218): Promise<AndroidProjectReport[]> { 219 const timestamp = new Date(); 220 const reportCacheFilePath = generateReportCacheFilePath(timestamp, options); 221 222 if (options.clearCache) { 223 await clearCachedReports(reportCacheFilePath); 224 logger.log('\n Cleared cached gradle task reports.'); 225 } 226 227 const cachedReports = await readCachedReports(reportCacheFilePath); 228 if (cachedReports) { 229 logger.info(' Using cached gradle updates reports.'); 230 return cachedReports; 231 } 232 233 for (const androidProjectPath of [ 234 Directories.getAndroidDir(), 235 path.join(Directories.getAppsDir(), 'bare-expo/android'), 236 ]) { 237 await executeGradleTask(androidProjectPath, options); 238 } 239 240 const reports = await readAndConvertReports(); 241 242 await cacheReports(reportCacheFilePath, reports); 243 return reports; 244} 245