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