improve device image downloading
- define a default download directory - obtain image URL and checksum from the Git-tracked local build index instead of fetching it from remote server - default to downloading images for the current build ID - download image to a temporary file first, mark it as complete only after checksum verification
This commit is contained in:
parent
e29dbb7aea
commit
694afd844b
5 changed files with 127 additions and 120 deletions
|
@ -1,75 +1,61 @@
|
||||||
import { Command, flags } from '@oclif/command'
|
import { Command, flags } from '@oclif/command'
|
||||||
import { promises as fs } from 'fs'
|
|
||||||
import chalk from 'chalk'
|
|
||||||
import { downloadFile, ImageType, IndexCache } from '../images/download'
|
|
||||||
|
|
||||||
const IMAGE_TYPE_MAP: { [type: string]: ImageType } = {
|
import { DEVICE_CONFIG_FLAGS, loadDeviceConfigs, resolveBuildId } from '../config/device'
|
||||||
factory: ImageType.Factory,
|
import { IMAGE_DOWNLOAD_DIR } from '../config/paths'
|
||||||
ota: ImageType.Ota,
|
import { ImageType, loadBuildIndex } from '../images/build-index'
|
||||||
vendor: ImageType.Vendor,
|
import { DeviceImage } from '../images/device-image'
|
||||||
}
|
import { downloadDeviceImages } from '../images/download'
|
||||||
|
|
||||||
export default class Download extends Command {
|
export default class Download extends Command {
|
||||||
static description = 'download device factory images, OTAs, and/or vendor packages'
|
static description = 'download device factory images, OTAs, and/or vendor packages. Default output location is ' +
|
||||||
|
IMAGE_DOWNLOAD_DIR + '. To override it, use ADEVTOOL_IMG_DOWNLOAD_DIR environment variable.'
|
||||||
|
|
||||||
static flags = {
|
static flags = {
|
||||||
help: flags.help({ char: 'h' }),
|
|
||||||
type: flags.string({
|
type: flags.string({
|
||||||
char: 't',
|
char: 't',
|
||||||
options: ['factory', 'ota', 'vendor'],
|
description: 'type(s) of images to download: factory | ota | vendor/$NAME (e.g. vendor/qcom, vendor/google_devices)',
|
||||||
description: 'type(s) of images to download',
|
|
||||||
default: ['factory'],
|
default: ['factory'],
|
||||||
multiple: true,
|
multiple: true,
|
||||||
}),
|
}),
|
||||||
buildId: flags.string({
|
buildId: flags.string({
|
||||||
char: 'b',
|
char: 'b',
|
||||||
description: 'build ID(s) of the images to download',
|
description: 'build ID(s) of images to download, defaults to the current build ID',
|
||||||
required: true,
|
required: false,
|
||||||
multiple: true,
|
|
||||||
default: ['latest'],
|
|
||||||
}),
|
|
||||||
device: flags.string({
|
|
||||||
char: 'd',
|
|
||||||
description: 'device(s) to download images for',
|
|
||||||
required: true,
|
|
||||||
multiple: true,
|
multiple: true,
|
||||||
}),
|
}),
|
||||||
|
...DEVICE_CONFIG_FLAGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
static args = [{ name: 'out', description: 'directory to save downloaded files in', required: true }]
|
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
let {
|
let { flags } = this.parse(Download)
|
||||||
flags,
|
|
||||||
args: { out },
|
|
||||||
} = this.parse(Download)
|
|
||||||
|
|
||||||
await fs.mkdir(out, { recursive: true })
|
let index = loadBuildIndex()
|
||||||
|
let deviceConfigs = loadDeviceConfigs(flags.devices)
|
||||||
|
|
||||||
this.log(chalk.bold(chalk.yellowBright("By downloading images, you agree to Google's terms and conditions:")))
|
let images: DeviceImage[] = []
|
||||||
this.log(
|
|
||||||
chalk.yellow(` - https://developers.google.com/android/images#legal
|
|
||||||
- https://developers.google.com/android/ota#legal
|
|
||||||
- https://policies.google.com/terms
|
|
||||||
`),
|
|
||||||
)
|
|
||||||
|
|
||||||
let cache: IndexCache = {}
|
let types = flags.type.map(s => s as ImageType)
|
||||||
for (let device of flags.device) {
|
|
||||||
this.log(chalk.greenBright(`\n${device}`))
|
|
||||||
|
|
||||||
for (let type of flags.type) {
|
for (let config of await deviceConfigs) {
|
||||||
let typeEnum = IMAGE_TYPE_MAP[type]
|
for (let type of types) {
|
||||||
if (typeEnum == undefined) {
|
let buildIds = flags.buildId ?? [config.device.build_id]
|
||||||
throw new Error(`Unknown type ${type}`)
|
|
||||||
}
|
|
||||||
let prettyType = type == 'ota' ? 'OTA' : type.charAt(0).toUpperCase() + type.slice(1)
|
|
||||||
|
|
||||||
for (let buildId of flags.buildId) {
|
for (let buildIdStr of buildIds) {
|
||||||
this.log(chalk.bold(chalk.blueBright(` ${prettyType} - ${buildId.toUpperCase()}`)))
|
let buildId = resolveBuildId(buildIdStr, config)
|
||||||
await downloadFile(typeEnum, buildId, device, out, cache)
|
let image = DeviceImage.get(await index, config, buildId, type)
|
||||||
|
images.push(image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let missingImages = await DeviceImage.getMissing(images)
|
||||||
|
|
||||||
|
if (missingImages.length > 0) {
|
||||||
|
await downloadDeviceImages(missingImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let image of images) {
|
||||||
|
this.log(`${image.toString()}: '${image.getPath()}'`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,3 +17,5 @@ export const DEVICE_CONFIG_DIR = path.join(CONFIG_DIR, 'device')
|
||||||
export const BUILD_INDEX_DIR = path.join(CONFIG_DIR, 'build-index')
|
export const BUILD_INDEX_DIR = path.join(CONFIG_DIR, 'build-index')
|
||||||
export const BUILD_INDEX_FILE = path.join(BUILD_INDEX_DIR, 'build-index.yml')
|
export const BUILD_INDEX_FILE = path.join(BUILD_INDEX_DIR, 'build-index.yml')
|
||||||
export const MAIN_BUILD_INDEX_PART = path.join(BUILD_INDEX_DIR, 'build-index-main.yml')
|
export const MAIN_BUILD_INDEX_PART = path.join(BUILD_INDEX_DIR, 'build-index-main.yml')
|
||||||
|
|
||||||
|
export const IMAGE_DOWNLOAD_DIR = process.env['ADEVTOOL_IMG_DOWNLOAD_DIR'] ?? path.join(ADEVTOOL_DIR, 'dl')
|
||||||
|
|
|
@ -2,6 +2,7 @@ import assert from 'assert'
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { DeviceConfig, getDeviceBuildId } from '../config/device'
|
import { DeviceConfig, getDeviceBuildId } from '../config/device'
|
||||||
|
import { IMAGE_DOWNLOAD_DIR } from '../config/paths'
|
||||||
import { BuildIndex, DEFAULT_BASE_DOWNLOAD_URL, ImageType } from './build-index'
|
import { BuildIndex, DEFAULT_BASE_DOWNLOAD_URL, ImageType } from './build-index'
|
||||||
|
|
||||||
export class DeviceImage {
|
export class DeviceImage {
|
||||||
|
@ -15,6 +16,18 @@ export class DeviceImage {
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPath() {
|
||||||
|
return path.join(IMAGE_DOWNLOAD_DIR, this.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async isPresent() {
|
||||||
|
try {
|
||||||
|
return (await fs.stat(this.getPath())).isFile()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static get(index: BuildIndex, deviceConfig: DeviceConfig, buildId: string, type: ImageType) {
|
static get(index: BuildIndex, deviceConfig: DeviceConfig, buildId: string, type: ImageType) {
|
||||||
let deviceBuildId = getDeviceBuildId(deviceConfig, buildId)
|
let deviceBuildId = getDeviceBuildId(deviceConfig, buildId)
|
||||||
let buildProps = index.get(deviceBuildId)
|
let buildProps = index.get(deviceBuildId)
|
||||||
|
@ -44,6 +57,17 @@ export class DeviceImage {
|
||||||
return new DeviceImage(deviceConfig, type, buildId, fileName, sha256, url)
|
return new DeviceImage(deviceConfig, type, buildId, fileName, sha256, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getMissing(arr: DeviceImage[]) {
|
||||||
|
let res: DeviceImage[] = []
|
||||||
|
let imagePresence = arr.map(i => ({image: i, isPresent: i.isPresent()}))
|
||||||
|
for (let e of imagePresence) {
|
||||||
|
if (!(await e.isPresent)) {
|
||||||
|
res.push(e.image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
toString() {
|
toString() {
|
||||||
return `'${this.deviceConfig.device.name} ${this.buildId} ${this.type}'`
|
return `'${this.deviceConfig.device.name} ${this.buildId} ${this.type}'`
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,86 +1,48 @@
|
||||||
import { createWriteStream } from 'fs'
|
import assert from 'assert'
|
||||||
|
import chalk from 'chalk'
|
||||||
import cliProgress from 'cli-progress'
|
import cliProgress from 'cli-progress'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { createWriteStream, existsSync, promises as fs } from 'fs'
|
||||||
import fetch from 'node-fetch'
|
import fetch from 'node-fetch'
|
||||||
import { promises as stream } from 'stream'
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import _ from 'lodash'
|
import { promises as stream } from 'stream'
|
||||||
|
import { IMAGE_DOWNLOAD_DIR } from '../config/paths'
|
||||||
|
|
||||||
const DEV_INDEX_URL = 'https://developers.google.com/android'
|
import { maybePlural } from '../util/cli'
|
||||||
const DEV_COOKIE = 'devsite_wall_acks=nexus-image-tos,nexus-ota-tos'
|
import { ImageType } from './build-index'
|
||||||
const DL_URL_PREFIX = 'https://dl.google.com/dl/android/aosp/'
|
import { DeviceImage } from './device-image'
|
||||||
|
|
||||||
interface ImageTypeInfo {
|
export async function downloadDeviceImages(images: DeviceImage[], showTncNotice = true) {
|
||||||
indexPath: string
|
await fs.mkdir(IMAGE_DOWNLOAD_DIR, { recursive: true })
|
||||||
filePattern: string
|
if (showTncNotice) {
|
||||||
|
logTermsAndConditionsNotice(images)
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ImageType {
|
for (let image of images) {
|
||||||
Ota = 'ota',
|
console.log(chalk.bold(chalk.blueBright(`\n${image.deviceConfig.device.name} ${image.buildId} ${image.type}`)))
|
||||||
Factory = 'factory',
|
await downloadImage(image, IMAGE_DOWNLOAD_DIR)
|
||||||
Vendor = 'vendor',
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IndexCache = { [type in ImageType]?: string }
|
export async function downloadMissingDeviceImages(images: DeviceImage[]) {
|
||||||
|
let missingImages = await DeviceImage.getMissing(images)
|
||||||
|
|
||||||
const IMAGE_TYPES: Record<ImageType, ImageTypeInfo> = {
|
if (missingImages.length > 0) {
|
||||||
[ImageType.Factory]: {
|
console.log(`Missing image${maybePlural(missingImages)}: ${DeviceImage.arrayToString(missingImages)}`)
|
||||||
indexPath: 'images',
|
await downloadDeviceImages(missingImages)
|
||||||
filePattern: 'DEVICE-BUILDID',
|
}
|
||||||
},
|
|
||||||
[ImageType.Ota]: {
|
|
||||||
indexPath: 'ota',
|
|
||||||
filePattern: 'DEVICE-ota-BUILDID',
|
|
||||||
},
|
|
||||||
[ImageType.Vendor]: {
|
|
||||||
indexPath: 'drivers',
|
|
||||||
filePattern: 'google_devices-DEVICE-BUILDID',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUrl(type: ImageType, buildId: string, device: string, cache: IndexCache) {
|
async function downloadImage(image: DeviceImage, outDir: string) {
|
||||||
let { indexPath, filePattern } = IMAGE_TYPES[type]
|
let tmpOutFile = path.join(outDir, image.fileName + '.tmp')
|
||||||
|
await fs.rm(tmpOutFile, { force: true })
|
||||||
|
|
||||||
let index = cache[type]
|
let completeOutFile = path.join(outDir, image.fileName)
|
||||||
if (index == undefined) {
|
assert(!existsSync(completeOutFile), completeOutFile + ' already exists')
|
||||||
let resp = await fetch(`${DEV_INDEX_URL}/${indexPath}`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: DEV_COOKIE,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
index = await resp.text()
|
console.log(` ${image.url}`)
|
||||||
cache[type] = index
|
|
||||||
}
|
|
||||||
|
|
||||||
let filePrefix = filePattern
|
let resp = await fetch(image.url)
|
||||||
.replace('DEVICE', device)
|
|
||||||
.replace('BUILDID', buildId == 'latest' ? '' : `${buildId.toLowerCase()}-`)
|
|
||||||
let urlPrefix = DL_URL_PREFIX + filePrefix
|
|
||||||
|
|
||||||
let pattern = new RegExp(`"(${_.escapeRegExp(urlPrefix)}.+?)"`, 'g')
|
|
||||||
let matches = Array.from(index.matchAll(pattern))
|
|
||||||
if (matches.length == 0) {
|
|
||||||
throw new Error(`Image not found: ${type}, ${buildId}, ${device}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buildId == 'latest') {
|
|
||||||
return matches[matches.length - 1][1]
|
|
||||||
}
|
|
||||||
return matches[0][1]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadFile(
|
|
||||||
type: ImageType,
|
|
||||||
buildId: string,
|
|
||||||
device: string,
|
|
||||||
outDir: string,
|
|
||||||
cache: IndexCache = {},
|
|
||||||
) {
|
|
||||||
let url = await getUrl(type, buildId, device, cache)
|
|
||||||
|
|
||||||
console.log(` ${url}`)
|
|
||||||
let resp = await fetch(url)
|
|
||||||
let name = path.basename(url)
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`Error ${resp.status}: ${resp.statusText}`)
|
throw new Error(`Error ${resp.status}: ${resp.statusText}`)
|
||||||
}
|
}
|
||||||
|
@ -94,11 +56,38 @@ export async function downloadFile(
|
||||||
let progress = 0
|
let progress = 0
|
||||||
let totalSize = parseInt(resp.headers.get('content-length') ?? '0') / 1e6
|
let totalSize = parseInt(resp.headers.get('content-length') ?? '0') / 1e6
|
||||||
bar.start(Math.round(totalSize), 0)
|
bar.start(Math.round(totalSize), 0)
|
||||||
|
|
||||||
|
let sha256 = createHash('sha256')
|
||||||
|
|
||||||
resp.body!.on('data', chunk => {
|
resp.body!.on('data', chunk => {
|
||||||
|
sha256.update(chunk)
|
||||||
progress += chunk.length / 1e6
|
progress += chunk.length / 1e6
|
||||||
bar.update(Math.round(progress))
|
bar.update(Math.round(progress))
|
||||||
})
|
})
|
||||||
|
|
||||||
await stream.pipeline(resp.body!, createWriteStream(`${outDir}/${name}`))
|
await stream.pipeline(resp.body!, createWriteStream(tmpOutFile))
|
||||||
bar.stop()
|
bar.stop()
|
||||||
|
|
||||||
|
let sha256Digest: string = sha256.digest('hex')
|
||||||
|
console.log('SHA-256: ' + sha256Digest)
|
||||||
|
assert(sha256Digest === image.sha256, 'SHA256 mismatch, expected ' + image.sha256)
|
||||||
|
|
||||||
|
await fs.rename(tmpOutFile, completeOutFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTermsAndConditionsNotice(images: DeviceImage[]) {
|
||||||
|
if (images.filter(i => i.type === ImageType.Factory || i.type === ImageType.Ota).length == 0) {
|
||||||
|
// vendor images show T&C notice themselves as part of unpacking
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.bold('\nBy downloading images, you agree to Google\'s terms and conditions:'))
|
||||||
|
|
||||||
|
let msg = ' - Factory images: https://developers.google.com/android/images#legal\n'
|
||||||
|
if (images.find(i => i.type === ImageType.Ota) !== undefined) {
|
||||||
|
msg += ' - OTA images: https://developers.google.com/android/ota#legal\n'
|
||||||
|
}
|
||||||
|
msg += ' - Beta factory/OTA images: https://developer.android.com/studio/terms\n'
|
||||||
|
|
||||||
|
console.log(msg)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,12 @@ export async function withSpinner<Return>(action: string, callback: (spinner: or
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function maybePlural<T>(arr: ArrayLike<T>, singleEnding = '', multiEnding = 's') {
|
||||||
|
let len = arr.length
|
||||||
|
assert(len > 0)
|
||||||
|
return len > 1 ? multiEnding : singleEnding
|
||||||
|
}
|
||||||
|
|
||||||
export function showGitDiff(repoPath: string, filePath?: string) {
|
export function showGitDiff(repoPath: string, filePath?: string) {
|
||||||
let args = ['-C', repoPath, `diff`]
|
let args = ['-C', repoPath, `diff`]
|
||||||
if (filePath !== undefined) {
|
if (filePath !== undefined) {
|
||||||
|
|
Loading…
Reference in a new issue