• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {
17  JsonSerialize,
18  parseAppState,
19  serializeAppState,
20} from '../core/state_serialization';
21import {
22  BUCKET_NAME,
23  MIME_BINARY,
24  MIME_JSON,
25  GcsUploader,
26} from '../base/gcs_uploader';
27import {
28  SERIALIZED_STATE_VERSION,
29  SerializedAppState,
30} from '../core/state_serialization_schema';
31import {z} from 'zod';
32import {showModal} from '../widgets/modal';
33import {AppImpl} from '../core/app_impl';
34import {CopyableLink} from '../widgets/copyable_link';
35import {TraceImpl} from '../core/trace_impl';
36
37// Permalink serialization has two layers:
38// 1. Serialization of the app state (state_serialization.ts):
39//    This is a JSON object that represents the visual app state (pinned tracks,
40//    visible viewport bounds, etc) BUT not the trace source.
41// 2. An outer layer that contains the app state AND a link to the trace file.
42//    (This file)
43//
44// In a nutshell:
45//   AppState:  {viewport: {...}, pinnedTracks: {...}, notes: {...}}
46//   Permalink: {appState: {see above}, traceUrl: 'https://gcs/trace/file'}
47//
48// This file deals with the outer layer, state_serialization.ts with the inner.
49
50const PERMALINK_SCHEMA = z.object({
51  traceUrl: z.string().optional(),
52
53  // We don't want to enforce validation at this level but want to delegate it
54  // to parseAppState(), for two reasons:
55  // 1. parseAppState() does further semantic checks (e.g. version checking).
56  // 2. We want to still load the traceUrl even if the app state is invalid.
57  appState: z.any().optional(),
58});
59
60type PermalinkState = z.infer<typeof PERMALINK_SCHEMA>;
61
62export async function createPermalink(trace: TraceImpl): Promise<void> {
63  const hash = await createPermalinkInternal(trace);
64  showPermalinkDialog(hash);
65}
66
67// Returns the file name, not the full url (i.e. the name of the GCS object).
68async function createPermalinkInternal(trace: TraceImpl): Promise<string> {
69  const permalinkData: PermalinkState = {};
70
71  // Check if we need to upload the trace file, before serializing the app
72  // state.
73  let alreadyUploadedUrl = '';
74  const traceSource = trace.traceInfo.source;
75  let dataToUpload: File | ArrayBuffer | undefined = undefined;
76  let traceName = trace.traceInfo.traceTitle || 'trace';
77  if (traceSource.type === 'FILE') {
78    dataToUpload = traceSource.file;
79    traceName = dataToUpload.name;
80  } else if (traceSource.type === 'ARRAY_BUFFER') {
81    dataToUpload = traceSource.buffer;
82  } else if (traceSource.type === 'URL') {
83    alreadyUploadedUrl = traceSource.url;
84  } else {
85    throw new Error(`Cannot share trace ${JSON.stringify(traceSource)}`);
86  }
87
88  // Upload the trace file, unless it's already uploaded (type == 'URL').
89  // Internally TraceGcsUploader will skip the upload if an object with the
90  // same hash exists already.
91  if (alreadyUploadedUrl) {
92    permalinkData.traceUrl = alreadyUploadedUrl;
93  } else if (dataToUpload !== undefined) {
94    updateStatus(`Uploading ${traceName}`);
95    const uploader: GcsUploader = new GcsUploader(dataToUpload, {
96      mimeType: MIME_BINARY,
97      onProgress: () => reportUpdateProgress(uploader),
98    });
99    await uploader.waitForCompletion();
100    permalinkData.traceUrl = uploader.uploadedUrl;
101  }
102
103  permalinkData.appState = serializeAppState(trace);
104
105  // Serialize the permalink with the app state (or recording state) and upload.
106  updateStatus(`Creating permalink...`);
107  const permalinkJson = JsonSerialize(permalinkData);
108  const uploader: GcsUploader = new GcsUploader(permalinkJson, {
109    mimeType: MIME_JSON,
110    onProgress: () => reportUpdateProgress(uploader),
111  });
112  await uploader.waitForCompletion();
113
114  return uploader.uploadedFileName;
115}
116
117/**
118 * Loads a permalink from Google Cloud Storage.
119 * This is invoked when passing !#?s=fileName to URL.
120 * @param gcsFileName the file name of the cloud storage object. This is
121 * expected to be a JSON file that respects the schema defined by
122 * PERMALINK_SCHEMA.
123 */
124export async function loadPermalink(gcsFileName: string): Promise<void> {
125  // Otherwise, this is a request to load the permalink.
126  const url = `https://storage.googleapis.com/${BUCKET_NAME}/${gcsFileName}`;
127  const response = await fetch(url);
128  if (!response.ok) {
129    throw new Error(`Could not fetch permalink.\n URL: ${url}`);
130  }
131  const text = await response.text();
132  const permalinkJson = JSON.parse(text);
133  let permalink: PermalinkState;
134  let error = '';
135
136  // Try to recover permalinks generated by older versions of the UI before
137  // r.android.com/3119920 .
138  const convertedLegacyPermalink = tryLoadLegacyPermalink(permalinkJson);
139  if (convertedLegacyPermalink !== undefined) {
140    permalink = convertedLegacyPermalink;
141  } else {
142    const res = PERMALINK_SCHEMA.safeParse(permalinkJson);
143    if (res.success) {
144      permalink = res.data;
145    } else {
146      error = res.error.toString();
147      permalink = {};
148    }
149  }
150
151  let serializedAppState: SerializedAppState | undefined = undefined;
152  if (permalink.appState !== undefined) {
153    // This is the most common case where the permalink contains the app state
154    // (and optionally a traceUrl, below).
155    const parseRes = parseAppState(permalink.appState);
156    if (parseRes.success) {
157      serializedAppState = parseRes.data;
158    } else {
159      error = parseRes.error;
160    }
161  }
162  if (permalink.traceUrl) {
163    AppImpl.instance.openTraceFromUrl(permalink.traceUrl, serializedAppState);
164  }
165
166  if (error) {
167    showModal({
168      title: 'Failed to restore the serialized app state',
169      content: m(
170        'div',
171        m(
172          'p',
173          'Something went wrong when restoring the app state.' +
174            'This is due to some backwards-incompatible change ' +
175            'when the permalink is generated and then opened using ' +
176            'two different UI versions.',
177        ),
178        m(
179          'p',
180          "I'm going to try to open the trace file anyways, but " +
181            'the zoom level, pinned tracks and other UI ' +
182            "state wont't be recovered",
183        ),
184        m('p', 'Error details:'),
185        m('.modal-logs', error),
186      ),
187      buttons: [
188        {
189          text: 'Open only the trace file',
190          primary: true,
191        },
192      ],
193    });
194  }
195}
196
197// Tries to recover a previous permalink, before the split in two layers,
198// where the permalink JSON contains the app state, which contains inside it
199// the trace URL.
200// If we suceed, convert it to a new-style JSON object preserving some minimal
201// information (really just vieport and pinned tracks).
202function tryLoadLegacyPermalink(data: unknown): PermalinkState | undefined {
203  const legacyData = data as {
204    version?: number;
205    engine?: {source?: {url?: string}};
206    pinnedTracks?: string[];
207    frontendLocalState?: {
208      visibleState?: {start?: {value?: string}; end?: {value?: string}};
209    };
210  };
211  if (legacyData.version === undefined) return undefined;
212  const vizState = legacyData.frontendLocalState?.visibleState;
213  return {
214    traceUrl: legacyData.engine?.source?.url,
215    appState: {
216      version: SERIALIZED_STATE_VERSION,
217      pinnedTracks: legacyData.pinnedTracks ?? [],
218      viewport: vizState
219        ? {start: vizState.start?.value, end: vizState.end?.value}
220        : undefined,
221    } as SerializedAppState,
222  } as PermalinkState;
223}
224
225function reportUpdateProgress(uploader: GcsUploader) {
226  switch (uploader.state) {
227    case 'UPLOADING':
228      const statusTxt = `Uploading ${uploader.getEtaString()}`;
229      updateStatus(statusTxt);
230      break;
231    case 'ERROR':
232      updateStatus(`Upload failed ${uploader.error}`);
233      break;
234    default:
235      break;
236  } // switch (state)
237}
238
239function updateStatus(msg: string): void {
240  AppImpl.instance.omnibox.showStatusMessage(msg);
241}
242
243function showPermalinkDialog(hash: string) {
244  showModal({
245    title: 'Permalink',
246    content: m(CopyableLink, {url: `${self.location.origin}/#!/?s=${hash}`}),
247  });
248}
249