1*6dbdd20aSAndroid Build Coastguard Worker// Copyright (C) 2020 The Android Open Source Project 2*6dbdd20aSAndroid Build Coastguard Worker// 3*6dbdd20aSAndroid Build Coastguard Worker// Licensed under the Apache License, Version 2.0 (the "License"); 4*6dbdd20aSAndroid Build Coastguard Worker// you may not use this file except in compliance with the License. 5*6dbdd20aSAndroid Build Coastguard Worker// You may obtain a copy of the License at 6*6dbdd20aSAndroid Build Coastguard Worker// 7*6dbdd20aSAndroid Build Coastguard Worker// http://www.apache.org/licenses/LICENSE-2.0 8*6dbdd20aSAndroid Build Coastguard Worker// 9*6dbdd20aSAndroid Build Coastguard Worker// Unless required by applicable law or agreed to in writing, software 10*6dbdd20aSAndroid Build Coastguard Worker// distributed under the License is distributed on an "AS IS" BASIS, 11*6dbdd20aSAndroid Build Coastguard Worker// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12*6dbdd20aSAndroid Build Coastguard Worker// See the License for the specific language governing permissions and 13*6dbdd20aSAndroid Build Coastguard Worker// limitations under the License. 14*6dbdd20aSAndroid Build Coastguard Worker 15*6dbdd20aSAndroid Build Coastguard Workerimport {defer} from './deferred'; 16*6dbdd20aSAndroid Build Coastguard Workerimport {Time} from './time'; 17*6dbdd20aSAndroid Build Coastguard Worker 18*6dbdd20aSAndroid Build Coastguard Workerexport const BUCKET_NAME = 'perfetto-ui-data'; 19*6dbdd20aSAndroid Build Coastguard Workerexport const MIME_JSON = 'application/json; charset=utf-8'; 20*6dbdd20aSAndroid Build Coastguard Workerexport const MIME_BINARY = 'application/octet-stream'; 21*6dbdd20aSAndroid Build Coastguard Worker 22*6dbdd20aSAndroid Build Coastguard Workerexport interface GcsUploaderArgs { 23*6dbdd20aSAndroid Build Coastguard Worker /** 24*6dbdd20aSAndroid Build Coastguard Worker * The mime-type to use for the upload. If undefined uses 25*6dbdd20aSAndroid Build Coastguard Worker * application/octet-stream. 26*6dbdd20aSAndroid Build Coastguard Worker */ 27*6dbdd20aSAndroid Build Coastguard Worker mimeType?: string; 28*6dbdd20aSAndroid Build Coastguard Worker 29*6dbdd20aSAndroid Build Coastguard Worker /** 30*6dbdd20aSAndroid Build Coastguard Worker * The name to use for the uploaded file. By default it uses a hash of 31*6dbdd20aSAndroid Build Coastguard Worker * the passed data/blob and uses content-addressing. 32*6dbdd20aSAndroid Build Coastguard Worker */ 33*6dbdd20aSAndroid Build Coastguard Worker fileName?: string; 34*6dbdd20aSAndroid Build Coastguard Worker 35*6dbdd20aSAndroid Build Coastguard Worker /** An optional callback that is invoked upon upload progress (or failure) */ 36*6dbdd20aSAndroid Build Coastguard Worker onProgress?: (uploader: GcsUploader) => void; 37*6dbdd20aSAndroid Build Coastguard Worker} 38*6dbdd20aSAndroid Build Coastguard Worker 39*6dbdd20aSAndroid Build Coastguard Worker/** 40*6dbdd20aSAndroid Build Coastguard Worker * A utility class to handle uploads of possibly large files to 41*6dbdd20aSAndroid Build Coastguard Worker * Google Cloud Storage. 42*6dbdd20aSAndroid Build Coastguard Worker * It returns immediately if the file exists already 43*6dbdd20aSAndroid Build Coastguard Worker */ 44*6dbdd20aSAndroid Build Coastguard Workerexport class GcsUploader { 45*6dbdd20aSAndroid Build Coastguard Worker state: 'UPLOADING' | 'UPLOADED' | 'ERROR' = 'UPLOADING'; 46*6dbdd20aSAndroid Build Coastguard Worker error = ''; 47*6dbdd20aSAndroid Build Coastguard Worker totalSize = 0; 48*6dbdd20aSAndroid Build Coastguard Worker uploadedSize = 0; 49*6dbdd20aSAndroid Build Coastguard Worker uploadedUrl = ''; 50*6dbdd20aSAndroid Build Coastguard Worker uploadedFileName = ''; 51*6dbdd20aSAndroid Build Coastguard Worker 52*6dbdd20aSAndroid Build Coastguard Worker private args: GcsUploaderArgs; 53*6dbdd20aSAndroid Build Coastguard Worker private onProgress: (_: GcsUploader) => void; 54*6dbdd20aSAndroid Build Coastguard Worker private req: XMLHttpRequest; 55*6dbdd20aSAndroid Build Coastguard Worker private donePromise = defer<void>(); 56*6dbdd20aSAndroid Build Coastguard Worker private startTime = performance.now(); 57*6dbdd20aSAndroid Build Coastguard Worker 58*6dbdd20aSAndroid Build Coastguard Worker constructor(data: Blob | ArrayBuffer | string, args: GcsUploaderArgs) { 59*6dbdd20aSAndroid Build Coastguard Worker this.args = args; 60*6dbdd20aSAndroid Build Coastguard Worker this.onProgress = args.onProgress ?? ((_: GcsUploader) => {}); 61*6dbdd20aSAndroid Build Coastguard Worker this.req = new XMLHttpRequest(); 62*6dbdd20aSAndroid Build Coastguard Worker this.start(data); 63*6dbdd20aSAndroid Build Coastguard Worker } 64*6dbdd20aSAndroid Build Coastguard Worker 65*6dbdd20aSAndroid Build Coastguard Worker async start(data: Blob | ArrayBuffer | string) { 66*6dbdd20aSAndroid Build Coastguard Worker let fname = this.args.fileName; 67*6dbdd20aSAndroid Build Coastguard Worker if (fname === undefined) { 68*6dbdd20aSAndroid Build Coastguard Worker // If the file name is unspecified, hash the contents. 69*6dbdd20aSAndroid Build Coastguard Worker if (data instanceof Blob) { 70*6dbdd20aSAndroid Build Coastguard Worker fname = await hashFileStreaming(data); 71*6dbdd20aSAndroid Build Coastguard Worker } else { 72*6dbdd20aSAndroid Build Coastguard Worker fname = await sha1(data); 73*6dbdd20aSAndroid Build Coastguard Worker } 74*6dbdd20aSAndroid Build Coastguard Worker } 75*6dbdd20aSAndroid Build Coastguard Worker this.uploadedFileName = fname; 76*6dbdd20aSAndroid Build Coastguard Worker this.uploadedUrl = `https://storage.googleapis.com/${BUCKET_NAME}/${fname}`; 77*6dbdd20aSAndroid Build Coastguard Worker 78*6dbdd20aSAndroid Build Coastguard Worker // Check if the file has been uploaded already. If so, skip. 79*6dbdd20aSAndroid Build Coastguard Worker const res = await fetch( 80*6dbdd20aSAndroid Build Coastguard Worker `https://www.googleapis.com/storage/v1/b/${BUCKET_NAME}/o/${fname}`, 81*6dbdd20aSAndroid Build Coastguard Worker ); 82*6dbdd20aSAndroid Build Coastguard Worker if (res.status === 200) { 83*6dbdd20aSAndroid Build Coastguard Worker console.log( 84*6dbdd20aSAndroid Build Coastguard Worker `Skipping upload of ${this.uploadedUrl} because it exists already`, 85*6dbdd20aSAndroid Build Coastguard Worker ); 86*6dbdd20aSAndroid Build Coastguard Worker this.state = 'UPLOADED'; 87*6dbdd20aSAndroid Build Coastguard Worker this.donePromise.resolve(); 88*6dbdd20aSAndroid Build Coastguard Worker return; 89*6dbdd20aSAndroid Build Coastguard Worker } 90*6dbdd20aSAndroid Build Coastguard Worker 91*6dbdd20aSAndroid Build Coastguard Worker const reqUrl = 92*6dbdd20aSAndroid Build Coastguard Worker 'https://www.googleapis.com/upload/storage/v1/b/' + 93*6dbdd20aSAndroid Build Coastguard Worker `${BUCKET_NAME}/o?uploadType=media` + 94*6dbdd20aSAndroid Build Coastguard Worker `&name=${fname}&predefinedAcl=publicRead`; 95*6dbdd20aSAndroid Build Coastguard Worker this.req.onabort = (e: ProgressEvent) => this.onRpcEvent(e); 96*6dbdd20aSAndroid Build Coastguard Worker this.req.onerror = (e: ProgressEvent) => this.onRpcEvent(e); 97*6dbdd20aSAndroid Build Coastguard Worker this.req.upload.onprogress = (e: ProgressEvent) => this.onRpcEvent(e); 98*6dbdd20aSAndroid Build Coastguard Worker this.req.onloadend = (e: ProgressEvent) => this.onRpcEvent(e); 99*6dbdd20aSAndroid Build Coastguard Worker this.req.open('POST', reqUrl, /* async= */ true); 100*6dbdd20aSAndroid Build Coastguard Worker const mimeType = this.args.mimeType ?? MIME_BINARY; 101*6dbdd20aSAndroid Build Coastguard Worker this.req.setRequestHeader('Content-Type', mimeType); 102*6dbdd20aSAndroid Build Coastguard Worker this.req.send(data); 103*6dbdd20aSAndroid Build Coastguard Worker } 104*6dbdd20aSAndroid Build Coastguard Worker 105*6dbdd20aSAndroid Build Coastguard Worker waitForCompletion(): Promise<void> { 106*6dbdd20aSAndroid Build Coastguard Worker return this.donePromise; 107*6dbdd20aSAndroid Build Coastguard Worker } 108*6dbdd20aSAndroid Build Coastguard Worker 109*6dbdd20aSAndroid Build Coastguard Worker abort() { 110*6dbdd20aSAndroid Build Coastguard Worker if (this.state === 'UPLOADING') { 111*6dbdd20aSAndroid Build Coastguard Worker this.req.abort(); 112*6dbdd20aSAndroid Build Coastguard Worker } 113*6dbdd20aSAndroid Build Coastguard Worker } 114*6dbdd20aSAndroid Build Coastguard Worker 115*6dbdd20aSAndroid Build Coastguard Worker getEtaString() { 116*6dbdd20aSAndroid Build Coastguard Worker let str = `${Math.ceil((100 * this.uploadedSize) / this.totalSize)}%`; 117*6dbdd20aSAndroid Build Coastguard Worker str += ` (${(this.uploadedSize / 1e6).toFixed(2)} MB)`; 118*6dbdd20aSAndroid Build Coastguard Worker const elapsed = (performance.now() - this.startTime) / 1000; 119*6dbdd20aSAndroid Build Coastguard Worker const rate = this.uploadedSize / elapsed; 120*6dbdd20aSAndroid Build Coastguard Worker const etaSecs = Math.round((this.totalSize - this.uploadedSize) / rate); 121*6dbdd20aSAndroid Build Coastguard Worker str += ' - ETA: ' + Time.toTimecode(Time.fromSeconds(etaSecs)).dhhmmss; 122*6dbdd20aSAndroid Build Coastguard Worker return str; 123*6dbdd20aSAndroid Build Coastguard Worker } 124*6dbdd20aSAndroid Build Coastguard Worker 125*6dbdd20aSAndroid Build Coastguard Worker private onRpcEvent(e: ProgressEvent) { 126*6dbdd20aSAndroid Build Coastguard Worker let done = false; 127*6dbdd20aSAndroid Build Coastguard Worker switch (e.type) { 128*6dbdd20aSAndroid Build Coastguard Worker case 'progress': 129*6dbdd20aSAndroid Build Coastguard Worker this.uploadedSize = e.loaded; 130*6dbdd20aSAndroid Build Coastguard Worker this.totalSize = e.total; 131*6dbdd20aSAndroid Build Coastguard Worker break; 132*6dbdd20aSAndroid Build Coastguard Worker case 'abort': 133*6dbdd20aSAndroid Build Coastguard Worker this.state = 'ERROR'; 134*6dbdd20aSAndroid Build Coastguard Worker this.error = 'Upload aborted'; 135*6dbdd20aSAndroid Build Coastguard Worker break; 136*6dbdd20aSAndroid Build Coastguard Worker case 'error': 137*6dbdd20aSAndroid Build Coastguard Worker this.state = 'ERROR'; 138*6dbdd20aSAndroid Build Coastguard Worker this.error = `${this.req.status} - ${this.req.statusText}`; 139*6dbdd20aSAndroid Build Coastguard Worker break; 140*6dbdd20aSAndroid Build Coastguard Worker case 'loadend': 141*6dbdd20aSAndroid Build Coastguard Worker done = true; 142*6dbdd20aSAndroid Build Coastguard Worker if (this.req.status === 200) { 143*6dbdd20aSAndroid Build Coastguard Worker this.state = 'UPLOADED'; 144*6dbdd20aSAndroid Build Coastguard Worker } else if (this.state === 'UPLOADING') { 145*6dbdd20aSAndroid Build Coastguard Worker this.state = 'ERROR'; 146*6dbdd20aSAndroid Build Coastguard Worker this.error = `${this.req.status} - ${this.req.statusText}`; 147*6dbdd20aSAndroid Build Coastguard Worker } 148*6dbdd20aSAndroid Build Coastguard Worker break; 149*6dbdd20aSAndroid Build Coastguard Worker default: 150*6dbdd20aSAndroid Build Coastguard Worker return; 151*6dbdd20aSAndroid Build Coastguard Worker } 152*6dbdd20aSAndroid Build Coastguard Worker this.onProgress(this); 153*6dbdd20aSAndroid Build Coastguard Worker if (done) { 154*6dbdd20aSAndroid Build Coastguard Worker this.donePromise.resolve(); 155*6dbdd20aSAndroid Build Coastguard Worker } 156*6dbdd20aSAndroid Build Coastguard Worker } 157*6dbdd20aSAndroid Build Coastguard Worker} 158*6dbdd20aSAndroid Build Coastguard Worker 159*6dbdd20aSAndroid Build Coastguard Worker/** 160*6dbdd20aSAndroid Build Coastguard Worker * Computes the SHA-1 of a string or ArrayBuffer(View) 161*6dbdd20aSAndroid Build Coastguard Worker * @param data a string or ArrayBuffer to hash. 162*6dbdd20aSAndroid Build Coastguard Worker */ 163*6dbdd20aSAndroid Build Coastguard Workerasync function sha1(data: string | ArrayBuffer): Promise<string> { 164*6dbdd20aSAndroid Build Coastguard Worker let buffer: ArrayBuffer; 165*6dbdd20aSAndroid Build Coastguard Worker if (typeof data === 'string') { 166*6dbdd20aSAndroid Build Coastguard Worker buffer = new TextEncoder().encode(data); 167*6dbdd20aSAndroid Build Coastguard Worker } else { 168*6dbdd20aSAndroid Build Coastguard Worker buffer = data; 169*6dbdd20aSAndroid Build Coastguard Worker } 170*6dbdd20aSAndroid Build Coastguard Worker const digest = await crypto.subtle.digest('SHA-1', buffer); 171*6dbdd20aSAndroid Build Coastguard Worker return digestToHex(digest); 172*6dbdd20aSAndroid Build Coastguard Worker} 173*6dbdd20aSAndroid Build Coastguard Worker 174*6dbdd20aSAndroid Build Coastguard Worker/** 175*6dbdd20aSAndroid Build Coastguard Worker * Converts a hash for the given file in streaming mode, without loading the 176*6dbdd20aSAndroid Build Coastguard Worker * whole file into memory. The result is "a" SHA-1 but is not the same of 177*6dbdd20aSAndroid Build Coastguard Worker * `shasum -a 1 file`. The reason for this is that the crypto APIs support 178*6dbdd20aSAndroid Build Coastguard Worker * only one-shot digest computation and lack the usual update() + digest() 179*6dbdd20aSAndroid Build Coastguard Worker * chunked API. So we end up computing a SHA-1 of the concatenation of the 180*6dbdd20aSAndroid Build Coastguard Worker * SHA-1 of each chunk. 181*6dbdd20aSAndroid Build Coastguard Worker * Speed: ~800 MB/s on a M2 Macbook Air 2023. 182*6dbdd20aSAndroid Build Coastguard Worker * @param file The file to hash. 183*6dbdd20aSAndroid Build Coastguard Worker * @returns A hex-encoded string containing the hash of the file. 184*6dbdd20aSAndroid Build Coastguard Worker */ 185*6dbdd20aSAndroid Build Coastguard Workerasync function hashFileStreaming(file: Blob): Promise<string> { 186*6dbdd20aSAndroid Build Coastguard Worker const CHUNK_SIZE = 32 * 1024 * 1024; // 32MB 187*6dbdd20aSAndroid Build Coastguard Worker const totalChunks = Math.ceil(file.size / CHUNK_SIZE); 188*6dbdd20aSAndroid Build Coastguard Worker let chunkDigests = ''; 189*6dbdd20aSAndroid Build Coastguard Worker 190*6dbdd20aSAndroid Build Coastguard Worker for (let i = 0; i < totalChunks; i++) { 191*6dbdd20aSAndroid Build Coastguard Worker const start = i * CHUNK_SIZE; 192*6dbdd20aSAndroid Build Coastguard Worker const end = Math.min(start + CHUNK_SIZE, file.size); 193*6dbdd20aSAndroid Build Coastguard Worker const chunk = await file.slice(start, end).arrayBuffer(); 194*6dbdd20aSAndroid Build Coastguard Worker const digest = await crypto.subtle.digest('SHA-1', chunk); 195*6dbdd20aSAndroid Build Coastguard Worker chunkDigests += digestToHex(digest); 196*6dbdd20aSAndroid Build Coastguard Worker } 197*6dbdd20aSAndroid Build Coastguard Worker return sha1(chunkDigests); 198*6dbdd20aSAndroid Build Coastguard Worker} 199*6dbdd20aSAndroid Build Coastguard Worker 200*6dbdd20aSAndroid Build Coastguard Worker/** 201*6dbdd20aSAndroid Build Coastguard Worker * Converts the return value of crypto.digest() to a hex string. 202*6dbdd20aSAndroid Build Coastguard Worker * @param digest an array of bytes containing the digest 203*6dbdd20aSAndroid Build Coastguard Worker * @returns hex-encoded string of the digest. 204*6dbdd20aSAndroid Build Coastguard Worker */ 205*6dbdd20aSAndroid Build Coastguard Workerfunction digestToHex(digest: ArrayBuffer): string { 206*6dbdd20aSAndroid Build Coastguard Worker return Array.from(new Uint8Array(digest)) 207*6dbdd20aSAndroid Build Coastguard Worker .map((x) => x.toString(16).padStart(2, '0')) 208*6dbdd20aSAndroid Build Coastguard Worker .join(''); 209*6dbdd20aSAndroid Build Coastguard Worker} 210