xref: /aosp_15_r20/external/perfetto/ui/src/base/gcs_uploader.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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