ui: Google drive integration

Change-Id: Ibe958129cc7178ca9b1abb08a5d6f7318763df92
diff --git a/ui/package.json b/ui/package.json
index 6dd1a28..3ba6520 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -35,6 +35,7 @@
     "esbuild": "^0.21.5",
     "events": "^3.3.0",
     "fzf": "^0.5.2",
+    "gapi-script": "^1.2.0",
     "hsluv": "^0.1.0",
     "immer": "^10.1.1",
     "jsbn-rsa": "^1.0.4",
@@ -55,6 +56,11 @@
     "@playwright/test": "^1.47.0",
     "@rollup/plugin-commonjs": "^26.0.1",
     "@rollup/plugin-node-resolve": "^15.2.3",
+    "@types/gapi": "^0.0.47",
+    "@types/gapi.auth2": "^0.0.61",
+    "@types/gapi.client.drive-v3": "^0.0.5",
+    "@types/google-one-tap": "^1.2.6",
+    "@types/google.picker": "^0.0.51",
     "@types/jest": "^29.5.12",
     "@typescript-eslint/eslint-plugin": "^7.14.1",
     "@typescript-eslint/parser": "^7.14.1",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index c088e25..c90911a 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -89,6 +89,9 @@
   fzf:
     specifier: ^0.5.2
     version: 0.5.2
+  gapi-script:
+    specifier: ^1.2.0
+    version: 1.2.0
   hsluv:
     specifier: ^0.1.0
     version: 0.1.0
@@ -145,6 +148,21 @@
   '@rollup/plugin-node-resolve':
     specifier: ^15.2.3
     version: 15.2.3(rollup@2.79.1)
+  '@types/gapi':
+    specifier: ^0.0.47
+    version: 0.0.47
+  '@types/gapi.auth2':
+    specifier: ^0.0.61
+    version: 0.0.61
+  '@types/gapi.client.drive-v3':
+    specifier: ^0.0.5
+    version: 0.0.5
+  '@types/google-one-tap':
+    specifier: ^1.2.6
+    version: 1.2.6
+  '@types/google.picker':
+    specifier: ^0.0.51
+    version: 0.0.51
   '@types/jest':
     specifier: ^29.5.12
     version: 29.5.12
@@ -1448,6 +1466,20 @@
     resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
     dev: false
 
+  /@maxim_mazurok/gapi.client.discovery-v1@0.1.20200806:
+    resolution: {integrity: sha512-Wl6UfmZVDdWbY3PUu8E2ULk9RPLjnMqp/iOA4tcK8Ne+U/GmlnWP/e34IaZNGArfl7iXJNOG+/3Rj9L9jQyF9Q==}
+    dependencies:
+      '@types/gapi.client': 1.0.8
+      '@types/gapi.client.discovery-v1': 0.0.4
+    dev: true
+
+  /@maxim_mazurok/gapi.client.drive-v3@0.0.20250811:
+    resolution: {integrity: sha512-hexYbwEHQRdd8oY+SRZwOI3rp5OaOqGxKmbvKSizxPj73ntKJtIJN2SOMXFebfR8wRjZ+y8mEEmZl4GP9VFZTg==}
+    dependencies:
+      '@types/gapi.client': 1.0.8
+      '@types/gapi.client.discovery-v1': 0.0.4
+    dev: true
+
   /@mdn/browser-compat-data@5.5.35:
     resolution: {integrity: sha512-APtxt3S+a64EcXpG7E3a0bLx+CPqEcgN45FY/GEmbBBgX51AGIPkkYFy0JQDuOR0MFCozjo50q5Im74jflrkiQ==}
     dev: true
@@ -1764,10 +1796,44 @@
     resolution: {integrity: sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==}
     dev: false
 
+  /@types/gapi.auth2@0.0.61:
+    resolution: {integrity: sha512-cn+omiRoE/LTxZncnVl1QhcLggOT0sJ8Yz9RXIsw5R2zLyRf+0o6kaZzJ/Gr3Sxz6i7J/+PbXAF8yeZipCaiWw==}
+    dependencies:
+      '@types/gapi': 0.0.47
+    dev: true
+
+  /@types/gapi.client.discovery-v1@0.0.4:
+    resolution: {integrity: sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==}
+    dependencies:
+      '@maxim_mazurok/gapi.client.discovery-v1': 0.1.20200806
+    dev: true
+
+  /@types/gapi.client.drive-v3@0.0.5:
+    resolution: {integrity: sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==}
+    dependencies:
+      '@maxim_mazurok/gapi.client.drive-v3': 0.0.20250811
+    dev: true
+
+  /@types/gapi.client@1.0.8:
+    resolution: {integrity: sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==}
+    dev: true
+
+  /@types/gapi@0.0.47:
+    resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==}
+    dev: true
+
   /@types/geojson@7946.0.4:
     resolution: {integrity: sha512-MHmwBtCb7OCv1DSivz2UNJXPGU/1btAWRKlqJ2saEhVJkpkvqHMMaOpKg0v4sAbDWSQekHGvPVMM8nQ+Jen03Q==}
     dev: false
 
+  /@types/google-one-tap@1.2.6:
+    resolution: {integrity: sha512-REmJsXVHvKb/sgI8DF+7IesMbDbcsEokHBqxU01ENZ8d98UPWdRLhUCtxEm9bhNFFg6PJGy7PNFdvovp0hK3jA==}
+    dev: true
+
+  /@types/google.picker@0.0.51:
+    resolution: {integrity: sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==}
+    dev: true
+
   /@types/graceful-fs@4.1.6:
     resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==}
     dependencies:
@@ -3578,6 +3644,10 @@
     resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==}
     dev: false
 
+  /gapi-script@1.2.0:
+    resolution: {integrity: sha512-NKTVKiIwFdkO1j1EzcrWu/Pz7gsl1GmBmgh+qhuV2Ytls04W/Eg5aiBL91SCiBM9lU0PMu7p1hTVxhh1rPT5Lw==}
+    dev: false
+
   /gaxios@6.7.1:
     resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
     engines: {node: '>=14'}
diff --git a/ui/src/assets/index.html b/ui/src/assets/index.html
index 5867c1e..3b4c1fd 100644
--- a/ui/src/assets/index.html
+++ b/ui/src/assets/index.html
@@ -3,6 +3,8 @@
 <head>
   <meta charset="utf-8">
   <title>Perfetto UI</title>
+  <script src="https://apis.google.com/js/api.js"></script>
+  <script src="https://accounts.google.com/gsi/client" async defer></script>
   <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
   <link rel="shortcut icon" id="favicon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=">
 </head>
diff --git a/ui/src/core_plugins/dev.perfetto.GoogleDrive/gdrive_client.ts b/ui/src/core_plugins/dev.perfetto.GoogleDrive/gdrive_client.ts
new file mode 100644
index 0000000..7382811
--- /dev/null
+++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/gdrive_client.ts
@@ -0,0 +1,346 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {assertExists} from '../../base/logging';
+import {errResult, okResult, Result} from '../../base/result';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type TokenClient = any;
+
+export const SCOPES =
+  'https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/drive.install';
+
+export interface PickerResponse {
+  readonly docs: google.picker.DocumentObject[];
+}
+
+export interface PickerConfig {
+  readonly view: google.picker.DocsView;
+  readonly title?: string;
+}
+
+export interface GoogleDriveFile {
+  readonly id: string;
+  readonly name: string;
+  readonly blob: Blob;
+}
+
+export type AuthenticationResponse =
+  | {
+      readonly response: 'success';
+      readonly accessToken: string;
+    }
+  | {
+      readonly response: 'popup_blocked';
+    }
+  | {
+      readonly response: 'popup_closed';
+    };
+
+export class GoogleDriveClient {
+  private clientInitPromise: Promise<void> | undefined;
+  private pendingToken: Promise<AuthenticationResponse> | undefined;
+
+  constructor(
+    private readonly apiKey: string,
+    private readonly clientId: string,
+    private readonly appId: string,
+  ) {}
+
+  async picker(
+    token: string,
+    config: PickerConfig,
+  ): Promise<PickerResponse | undefined> {
+    return await new Promise<PickerResponse | undefined>((resolve, reject) => {
+      const pickerBuilder = new google.picker.PickerBuilder()
+        .setAppId(this.appId)
+        .setOAuthToken(token)
+        .setDeveloperKey(this.apiKey)
+        .setOrigin(window.location.protocol + '//' + window.location.host)
+        .addView(config.view)
+        .setCallback((data) => {
+          switch (data.action) {
+            case google.picker.Action.PICKED:
+              resolve({docs: assertExists(data.docs)});
+              debugger;
+              break;
+            case google.picker.Action.CANCEL:
+              resolve(undefined);
+              break;
+            case google.picker.Action.ERROR:
+              reject(new Error('Something went wrong with the picker'));
+              break;
+          }
+        });
+
+      if (config.title) {
+        pickerBuilder.setTitle(config.title);
+      }
+
+      const picker = pickerBuilder.build();
+      picker.setVisible(true);
+    });
+  }
+
+  // Request access to a specific file by ID via the picker API.
+  async requestFileAccess(
+    token: string,
+    fileId: string,
+  ): Promise<google.picker.DocumentObject[] | undefined> {
+    await this.gapiLoad('picker');
+    const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
+      .setMode(google.picker.DocsViewMode.LIST)
+      .setFileIds(fileId);
+    const pickerResult = await this.picker(token, {view});
+    return pickerResult?.docs;
+  }
+
+  // Open the Google Drive file picker to select a file.
+  async pickFile(
+    token: string,
+  ): Promise<google.picker.DocumentObject[] | undefined> {
+    await this.gapiLoad('picker');
+    const view = new google.picker.DocsView(google.picker.ViewId.DOCS).setMode(
+      google.picker.DocsViewMode.LIST,
+    );
+    const pickerResult = await this.picker(token, {view});
+    return pickerResult?.docs;
+  }
+
+  // Pick a folder using the Google Drive file picker.
+  async pickFolder(
+    token: string,
+  ): Promise<google.picker.DocumentObject | undefined> {
+    await this.gapiLoad('picker');
+    const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS)
+      .setMode(google.picker.DocsViewMode.LIST)
+      .setIncludeFolders(true)
+      .setSelectFolderEnabled(true)
+      .setMimeTypes('application/vnd.google-apps.folder');
+    const pickerResult = await this.picker(token, {
+      view,
+      title: 'Select folder',
+    });
+    return pickerResult?.docs[0]!;
+  }
+
+  async openFile(
+    token: string,
+    fileId: string,
+  ): Promise<Result<GoogleDriveFile>> {
+    console.log(token);
+
+    try {
+      const response = await gapi.client.drive.files.get({
+        fileId: fileId,
+        alt: 'media',
+      });
+
+      // The response body is a string, but it represents binary data.
+      // We can convert it to an ArrayBuffer by accessing the character codes.
+      const body = response.body;
+      const buffer = new ArrayBuffer(body.length);
+      const view = new Uint8Array(buffer);
+      for (let i = 0; i < body.length; i++) {
+        view[i] = body.charCodeAt(i);
+      }
+      return okResult({
+        id: fileId,
+        name: 'Google Drive Trace',
+        blob: new Blob([buffer]),
+      });
+    } catch (error) {
+      return errResult(error);
+    }
+  }
+
+  async openSharingDialog(token: string, fileId: string) {
+    await this.gapiLoad('drive-share');
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const s = new (gapi as any).drive.share.ShareClient();
+    s.setOAuthToken(token);
+    s.setItemIds([fileId]);
+    s.showSettingsDialog();
+  }
+
+  async uploadFile(
+    token: string,
+    traceBlob: Blob,
+    parentId: string = 'root',
+    fileName: string,
+  ): Promise<Result<string>> {
+    console.log('Uploading file to Google Drive:', {fileName, parentId});
+    const traceBuffer = await traceBlob.arrayBuffer();
+
+    const metadata = {
+      name: fileName,
+      mimeType: 'application/octet-stream',
+      parents: [parentId],
+    };
+
+    const boundary = '-------314159265358979323846';
+    const delimiter = `\r\n--${boundary}\r\n`;
+    const closeDelim = `\r\n--${boundary}--`;
+
+    const metadataPart =
+      delimiter +
+      'Content-Type: application/json; charset=UTF-8\r\n\r\n' +
+      JSON.stringify(metadata);
+
+    const mediaPart =
+      delimiter + 'Content-Type: application/octet-stream\r\n\r\n';
+
+    const body = new Blob([
+      new TextEncoder().encode(metadataPart),
+      new TextEncoder().encode(mediaPart),
+      traceBuffer,
+      new TextEncoder().encode(closeDelim),
+    ]);
+
+    try {
+      const response = await fetch(
+        'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',
+        {
+          method: 'POST',
+          headers: {
+            'Content-Type': `multipart/related; boundary=${boundary}`,
+            'Authorization': `Bearer ${token}`,
+          },
+          body,
+        },
+      );
+      const result = await response.json();
+      const fileId = result.id;
+      console.log('File uploaded successfully:', result);
+      return okResult(fileId);
+    } catch (error) {
+      console.error('Error uploading file:', error);
+      return errResult('Upload failed');
+    }
+  }
+
+  async authenticate(): Promise<AuthenticationResponse> {
+    await this.maybeInitClient();
+
+    if (!this.pendingToken) {
+      this.pendingToken = new Promise<AuthenticationResponse>(
+        async (resolve, reject) => {
+          const cachedToken = localStorage.getItem('driveToken');
+          if (cachedToken !== null) {
+            if (await this.isTokenValid(cachedToken)) {
+              console.log('Using cached Google Drive token');
+              // We need to do this when reusing a cached token, but not when
+              // getting a new one. Maybe the tokenClient does this automatically
+              // under the hood?
+              gapi.client.setToken({access_token: cachedToken});
+              resolve({response: 'success', accessToken: cachedToken});
+              return;
+            } else {
+              localStorage.removeItem('driveToken');
+              console.log(
+                'Cached Google Drive token is invalid, fetching new one',
+              );
+            }
+          }
+
+          // HACK: The 'google.accounts' object is not available in the type definitions
+          // until '@types/google-one-tap' is installed. We cast to 'any' to bypass
+          // the type checker.
+          const tokenClient: TokenClient =
+            // eslint-disable-next-line @typescript-eslint/no-explicit-any
+            (window as any).google.accounts.oauth2.initTokenClient({
+              client_id: this.clientId,
+              scope: SCOPES,
+              // TODO(stevegolton): Add proper types once @types/google-one-tap is installed.
+              // eslint-disable-next-line @typescript-eslint/no-explicit-any
+              callback: (tokenResponse: any) => {
+                console.log('Token response', tokenResponse);
+                if (Boolean(tokenResponse.error)) {
+                  console.error('OAuth Error:', tokenResponse.error);
+                  return;
+                }
+                const accessToken = tokenResponse.access_token;
+                // Resolve any pending promises waiting for the new token.
+                if (accessToken != null) {
+                  localStorage.setItem('driveToken', accessToken);
+                  resolve({response: 'success', accessToken: accessToken});
+                }
+              },
+              // eslint-disable-next-line @typescript-eslint/no-explicit-any
+              error_callback: (foo: any) => {
+                if (foo.type === 'popup_failed_to_open') {
+                  resolve({response: 'popup_blocked'});
+                } else if (foo.type === 'popup_closed') {
+                  resolve({response: 'popup_closed'});
+                } else {
+                  // Improve this message
+                  reject(new Error('Something went wrong'));
+                }
+                // Clear the pending promise so that future calls to
+                // authenticate() can try again.
+                this.pendingToken = undefined;
+              },
+            });
+          tokenClient.requestAccessToken({prompt: ''});
+        },
+      );
+    }
+
+    return await this.pendingToken;
+  }
+
+  private async maybeInitClient(): Promise<void> {
+    if (!this.clientInitPromise) {
+      this.clientInitPromise = new Promise<void>(async (resolve) => {
+        await this.gapiLoad('client');
+        await gapi.client.init({
+          apiKey: this.apiKey,
+          discoveryDocs: [
+            'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
+          ],
+        });
+        resolve();
+      });
+    }
+    await this.clientInitPromise;
+  }
+
+  private async gapiLoad(what: string): Promise<void> {
+    return await new Promise((resolve) => {
+      gapi.load(what, () => {
+        resolve();
+      });
+    });
+  }
+
+  private async isTokenValid(token: string) {
+    try {
+      const response = await fetch(
+        'https://www.googleapis.com/oauth2/v1/tokeninfo',
+        {
+          method: 'GET',
+          headers: {
+            Authorization: `Bearer ${token}`,
+          },
+        },
+      );
+
+      return response.ok;
+    } catch (error) {
+      console.error('Token validation error:', error);
+      return false;
+    }
+  }
+}
diff --git a/ui/src/core_plugins/dev.perfetto.GoogleDrive/index.ts b/ui/src/core_plugins/dev.perfetto.GoogleDrive/index.ts
new file mode 100644
index 0000000..aa838ca
--- /dev/null
+++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/index.ts
@@ -0,0 +1,321 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {AppImpl} from '../../core/app_impl';
+import {TraceImpl} from '../../core/trace_impl';
+import {PerfettoPlugin} from '../../public/plugin';
+import {Anchor} from '../../widgets/anchor';
+import {Button, ButtonVariant} from '../../widgets/button';
+import {closeModal, showModal} from '../../widgets/modal';
+import {Stack, StackAuto} from '../../widgets/stack';
+import {TextInput} from '../../widgets/text_input';
+import {GoogleDriveClient, GoogleDriveFile} from './gdrive_client';
+import {Result} from '../../base/result';
+import {Intent} from '../../widgets/common';
+import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button';
+
+// Note: keys purposely stripped for now...
+const API_KEY = '';
+const CLIENT_ID = '';
+const APP_ID = '';
+const gDriveClient = new GoogleDriveClient(API_KEY, CLIENT_ID, APP_ID);
+const uploadedTraces = new WeakMap<TraceImpl, string>();
+let pendingTraceFileId: string | undefined = undefined;
+
+// TODO(stevegolton): Add the ability for the app to be able to open a google
+// drive file by clicking through the open with menu of the google drive UI.
+// This will require the app to be registered with google and the correct
+// redirect URIs to be set up - i.e. https://perfetto.dev/?state={...} <-- JSON
+// blob represents a file
+
+export default class implements PerfettoPlugin {
+  static readonly id = 'dev.perfetto.GoogleDrive';
+  static readonly description = 'Open and save traces to Google Drive';
+
+  static onActivate(app: AppImpl) {
+    app.sidebar.addMenuItem({
+      section: 'navigation',
+      text: 'Open from Google Drive',
+      icon: 'add_to_drive',
+      action: async () => {
+        const auth = await gDriveClient.authenticate();
+        if (auth.response !== 'success') return;
+        const files = await gDriveClient.pickFile(auth.accessToken);
+        if (!files) return;
+        if (files.length === 0) return;
+
+        const firstFile = files[0];
+        const fileResult = await gDriveClient.openFile(
+          auth.accessToken,
+          firstFile.id,
+        );
+        if (fileResult.ok) {
+          const file = fileResult.value;
+          pendingTraceFileId = file.id;
+          app.openTraceFromBuffer({
+            buffer: await file.blob.arrayBuffer(),
+            title: file.name || 'Google Drive Trace',
+            fileName: file.name || 'gdrive-trace.pftrace',
+          });
+        }
+      },
+    });
+
+    const args = app.initialPluginRouteArgs;
+    const fileId = args['openFileId'];
+
+    if (fileId !== undefined && typeof fileId === 'string') {
+      handlePermalink(app, fileId);
+    }
+  }
+
+  async onTraceLoad(trace: TraceImpl): Promise<void> {
+    if (pendingTraceFileId) {
+      uploadedTraces.set(trace, pendingTraceFileId);
+      pendingTraceFileId = undefined;
+    }
+
+    trace.sidebar.addMenuItem({
+      section: 'current_trace',
+      text: 'Share to Google Drive',
+      icon: 'add_to_drive',
+      sortOrder: 10,
+      action: () => {
+        // This button opens a modal dialog which displays the state of the file
+        // in google drive allowing the user to upload it and optionally share
+        // it. The dialog gets the state of whether the file is already in
+        // google drive from the trace type + the fileId.
+        showModal({
+          key: 'GDriveUpload',
+          title: 'Google Drive',
+          content: () => m(UploadTraceModal, {trace, gDriveClient}),
+        });
+      },
+    });
+  }
+}
+
+async function openGoogleDriveTrace(
+  app: AppImpl,
+  token: string,
+  fileId: string,
+): Promise<Result<GoogleDriveFile>> {
+  const fileResult = await gDriveClient.openFile(token, fileId);
+  if (fileResult.ok) {
+    const file = fileResult.value;
+    app.openTraceFromBuffer({
+      buffer: await file.blob.arrayBuffer(),
+      title: file.name,
+      // fileName: file.name,
+    });
+    pendingTraceFileId = file.id;
+  }
+  return fileResult;
+}
+
+async function handlePermalink(app: AppImpl, fileId: string) {
+  // Here we should check to see if we actually need to authenticate or wether
+  // we can get away with using our cached token. If we do need to authenticate,
+  // use full screen authentication as the normal authentication would probably
+  // be blocked by the popup blocker in the browser. We might need to
+  // authenticate first, use fullscreen authentication.
+
+  const auth = await gDriveClient.authenticate();
+  if (auth.response === 'popup_blocked') {
+    console.log('Popup blocked, need to show a dialog to the user.');
+    showModal({
+      key: 'GoogleDrivePopupBlocked',
+      title: 'Popups blocked',
+      content: () =>
+        m(Stack, [
+          m(
+            'p',
+            'Google drive authentication requires a popup, please disable popups on this website and reload the page.',
+          ),
+          m(Stack, {orientation: 'horizontal'}, [
+            m(Button, {
+              label: 'Try again',
+              intent: Intent.Primary,
+              variant: ButtonVariant.Filled,
+              onclick: async () => {
+                window.location.reload();
+              },
+            }),
+            m(Button, {
+              label: 'Reload',
+              intent: Intent.Primary,
+              variant: ButtonVariant.Filled,
+              onclick: async () => {
+                window.location.reload();
+              },
+            }),
+          ]),
+        ]),
+    });
+  }
+
+  if (auth.response !== 'success') {
+    console.log('Failed to authenticate with Google Drive');
+    return;
+  }
+
+  const accessToken = auth.accessToken;
+  const fileResult = await openGoogleDriveTrace(app, accessToken, fileId);
+  if (!fileResult.ok) {
+    // Maybe we need to authorize access to the file from our app?
+    showModal({
+      key: 'GoogleDriveAuthNeeded',
+      title: 'Authorize access to file',
+      content: () =>
+        m(Stack, [
+          m(
+            'p',
+            "This is the first time you've accessed this app through the drive, please authorize access to the file.",
+          ),
+          m(Button, {
+            label: 'Authorize File Access',
+            intent: Intent.Primary,
+            variant: ButtonVariant.Filled,
+            onclick: async () => {
+              const files = await gDriveClient.requestFileAccess(
+                accessToken,
+                fileId,
+              );
+              if (!files) return;
+              await openGoogleDriveTrace(app, accessToken, fileId);
+              closeModal('GoogleDriveAuthNeeded');
+              // TODO(stevegolton): If this didn't work, I'm out of ideas!
+            },
+          }),
+        ]),
+    });
+    console.log(fileResult);
+  }
+}
+
+interface UploadTraceModalAttrs {
+  readonly trace: TraceImpl;
+  readonly gDriveClient: GoogleDriveClient;
+}
+
+function UploadTraceModal(): m.Component<UploadTraceModalAttrs> {
+  let fileName = '';
+  let location: 'root' | google.picker.DocumentObject = 'root';
+  let uploading = false;
+
+  return {
+    oninit({attrs}) {
+      fileName = attrs.trace.traceInfo.traceTitle ?? '';
+    },
+    view({attrs}) {
+      const {trace} = attrs;
+      const fileId = uploadedTraces.get(trace);
+
+      if (fileId) {
+        // The file is already uploaded.
+        return m(Stack, {spacing: 'large'}, [
+          m(
+            Anchor,
+            {
+              href: `https://docs.google.com/file/d/${fileId}/view`,
+              target: '_blank',
+            },
+            'View file in Google Drive',
+          ),
+          m(Stack, {orientation: 'horizontal'}, [
+            m(CopyToClipboardButton, {
+              textToCopy: `${window.location.origin}#!/?dev.perfetto.GoogleDrive:openFileId=${fileId}`,
+              title: 'Copy link',
+              label: 'Copy link',
+              intent: Intent.Primary,
+              variant: ButtonVariant.Filled,
+            }),
+            m(Button, {
+              label: 'Change ACLs',
+              onclick: async () => {
+                const auth = await attrs.gDriveClient.authenticate();
+                if (auth.response === 'success') {
+                  attrs.gDriveClient.openSharingDialog(
+                    auth.accessToken,
+                    fileId,
+                  );
+                }
+              },
+            }),
+          ]),
+        ]);
+      } else {
+        // The file is not yet uploaded.
+        return m(Stack, {spacing: 'large'}, [
+          m(Stack, {spacing: 'medium'}, [
+            m(Stack, {orientation: 'horizontal'}, [
+              'Filename: ',
+              m(TextInput, {
+                className: 'pf-gdrive-share__filename',
+                value: fileName,
+                oninput: (e: Event) => {
+                  fileName = (e.target as HTMLInputElement).value;
+                },
+                placeholder: 'Enter filename...',
+              }),
+            ]),
+            m(Stack, {orientation: 'horizontal'}, [
+              'Location: ',
+              location === 'root' ? 'My Drive' : location.name,
+              m(StackAuto),
+              m(Button, {
+                label: 'Change location...',
+                onclick: async () => {
+                  const auth = await attrs.gDriveClient.authenticate();
+                  if (auth.response !== 'success') return;
+                  const folder = await attrs.gDriveClient.pickFolder(
+                    auth.accessToken,
+                  );
+                  if (!folder) return;
+                  location = folder;
+                  m.redraw();
+                },
+              }),
+            ]),
+          ]),
+          m(Button, {
+            loading: uploading,
+            disabled: fileName.length === 0 || uploading,
+            onclick: async () => {
+              uploading = true;
+              const auth = await attrs.gDriveClient.authenticate();
+              if (auth.response !== 'success') return;
+              const traceBlob = await trace.getTraceFile();
+              const result = await attrs.gDriveClient.uploadFile(
+                auth.accessToken,
+                traceBlob,
+                location === 'root' ? 'root' : location.id,
+                fileName,
+              );
+              if (result.ok) {
+                uploadedTraces.set(trace, result.value);
+              }
+
+              m.redraw();
+            },
+            intent: Intent.Primary,
+            variant: ButtonVariant.Filled,
+            label: 'Upload',
+          }),
+        ]);
+      }
+    },
+  };
+}
diff --git a/ui/src/core_plugins/dev.perfetto.GoogleDrive/styles.scss b/ui/src/core_plugins/dev.perfetto.GoogleDrive/styles.scss
new file mode 100644
index 0000000..e879a09
--- /dev/null
+++ b/ui/src/core_plugins/dev.perfetto.GoogleDrive/styles.scss
@@ -0,0 +1,19 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+.pf-gdrive-share {
+  &__filename {
+    flex: 1;
+  }
+}
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 57f4da3..693be92 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -123,6 +123,7 @@
       'https://*.googleusercontent.com',
       'https://www.googletagmanager.com',
       'https://*.google-analytics.com',
+      'https://accounts.google.com/',
     ],
     'object-src': ['none'],
     'connect-src': [
@@ -132,6 +133,8 @@
       'https://*.googleapis.com', // For Google Cloud Storage fetches.
       'blob:',
       'data:',
+      'https://drive.google.com',
+      'https://play.google.com',
     ].concat(rpcPolicy),
     'img-src': [
       `'self'`,
@@ -140,8 +143,15 @@
       'https://*.google-analytics.com',
       'https://www.googletagmanager.com',
       'https://*.googleapis.com',
+      'https://ssl.gstatic.com',
     ],
     'style-src': [`'self'`, `'unsafe-inline'`],
+    'frame-src': [
+      'https://content.googleapis.com',
+      'https://accounts.google.com/',
+      'https://docs.google.com',
+      'https://drive.google.com',
+    ],
     'navigate-to': ['https://*.perfetto.dev', 'self'],
   };
   const meta = document.createElement('meta');
@@ -251,8 +261,12 @@
     defaultValue: true,
   });
 
+  const initialRouteArgs = Router.parseUrl(window.location.href).args;
+
+  console.log('Initial route args', initialRouteArgs);
+
   AppImpl.initialize({
-    initialRouteArgs: Router.parseUrl(window.location.href).args,
+    initialRouteArgs,
     settingsManager,
     timestampFormatSetting,
     durationPrecisionSetting,
diff --git a/ui/src/widgets/copy_to_clipboard_button.ts b/ui/src/widgets/copy_to_clipboard_button.ts
index f59159a..0451aed 100644
--- a/ui/src/widgets/copy_to_clipboard_button.ts
+++ b/ui/src/widgets/copy_to_clipboard_button.ts
@@ -47,6 +47,7 @@
   readonly textToCopy: string;
   readonly title?: string;
   readonly label?: string;
+  readonly intent?: Intent;
   readonly variant?: ButtonVariant;
 }
 
@@ -65,7 +66,7 @@
         variant: attrs.variant,
         title: attrs.title ?? 'Copy to clipboard',
         icon: helper.copied ? Icons.Check : Icons.Copy,
-        intent: helper.copied ? Intent.Success : Intent.None,
+        intent: attrs.intent,
         label,
         onclick: async () => {
           await helper.copy(attrs.textToCopy);
diff --git a/ui/src/widgets/tree.ts b/ui/src/widgets/tree.ts
index 5a822f8..670d0b0 100644
--- a/ui/src/widgets/tree.ts
+++ b/ui/src/widgets/tree.ts
@@ -15,6 +15,7 @@
 import m from 'mithril';
 import {classNames} from '../base/classnames';
 import {hasChildren} from '../base/mithril_utils';
+import {Icon} from './icon';
 
 // Heirachical tree layout with left and right values.
 // Right and left values of the same indentation level are horizontally aligned.
@@ -93,6 +94,7 @@
               onCollapseChanged(this.collapsed, attrs);
             },
           }),
+          attrs.icon && m(Icon, {icon: attrs.icon, className: 'pf-left-icon'}),
           left,
         ),
         this.renderRight(vnode),