1import { Command } from '@expo/commander';
2import fs from 'fs-extra';
3import got from 'got';
4import os from 'os';
5import path from 'path';
6import semver from 'semver';
7import stream from 'stream';
8import { promisify } from 'util';
9
10import { ANDROID_DIR } from '../Constants';
11import { getSDKVersionsAsync } from '../ProjectVersions';
12import { runWithSpinner } from '../Utils';
13
14const DOWNLOAD_BASE = 'https://github.com/expo/react-native/releases/download/';
15
16/**
17 * Gets file mtime
18 */
19async function getFileModifedAsync(filePath: string): Promise<string | null> {
20  let result: string | null = null;
21  try {
22    const stat = await fs.stat(filePath);
23    result = stat.mtime.toUTCString();
24  } catch {
25    result = null;
26  }
27  return result;
28}
29
30/**
31 * Downloads data and save to file.
32 *
33 * - Supports streaming
34 * - If original file is existed, trying to send `If-Modified-Since` header
35 */
36async function downloadFileAsync(url: string, outputPath: string) {
37  const headers = {};
38
39  const outputModified = await getFileModifedAsync(outputPath);
40  if (outputModified) {
41    headers['If-Modified-Since'] = outputModified;
42  }
43
44  const pipeline = promisify(stream.pipeline);
45  const fileName = path.basename(url);
46  const tmpFilePath = path.join(os.tmpdir(), fileName);
47  await pipeline(got.stream(url, { headers }), fs.createWriteStream(tmpFilePath));
48  const fileSize = (await fs.stat(tmpFilePath)).size;
49
50  if (fileSize === 0) {
51    // If-Modified-Since cache hit
52    await fs.remove(tmpFilePath);
53  } else {
54    await fs.move(tmpFilePath, outputPath, { overwrite: true });
55  }
56}
57
58/**
59 * Variant of `downloadFileAsync` without throwing exceptions even downloading failed.
60 * We need this when adding versioned code before uploading aar to GitHub.
61 */
62async function downloadFileNonThrowAsync(url: string, outputPath: string) {
63  try {
64    await downloadFileAsync(url, outputPath);
65  } catch {}
66}
67
68/**
69 * Download a versioned aar
70 */
71async function downloadVersionedAarAsync(sdkVersion: string) {
72  const abiVersion = `abi${sdkVersion.replace(/\./g, '_')}`;
73  const url = `${DOWNLOAD_BASE}sdk-${sdkVersion}/reactandroid-${abiVersion}-1.0.0.aar`;
74  const outputFile = path.join(
75    ANDROID_DIR,
76    `versioned-abis/expoview-${abiVersion}`,
77    `maven/host/exp/reactandroid-${abiVersion}/1.0.0/reactandroid-${abiVersion}-1.0.0.aar`
78  );
79  await runWithSpinner(
80    `Downloading versioned AAR for SDK ${sdkVersion}: ${url}`,
81    () => downloadFileNonThrowAsync(url, outputFile),
82    `Downloaded versioned AAR for SDK ${sdkVersion}`,
83    {
84      // Gradle does not handle the interactive spinner well. Let's use in plain text mode.
85      isEnabled: false,
86    }
87  );
88}
89
90async function action(): Promise<void> {
91  const sdkVersions = await getSDKVersionsAsync('android');
92  for (const sdkVersion of sdkVersions) {
93    if (semver.gte(sdkVersion, '48.0.0')) {
94      await downloadVersionedAarAsync(sdkVersion);
95    }
96  }
97}
98
99export default (program: Command) => {
100  program
101    .command('android-download-versioned-aars')
102    .description('Download versioned AAR files for Expo Go.')
103    .asyncAction(action);
104};
105