blob: 5ab705984d5971b6e7ee4dbe173c36e99eb33442 [file] [log] [blame]
// Copyright (C) 2024 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 {assertExists} from '../base/logging';
import {
JsonSerialize,
parseAppState,
serializeAppState,
} from '../core/state_serialization';
import {
BUCKET_NAME,
MIME_BINARY,
MIME_JSON,
GcsUploader,
} from '../common/gcs_uploader';
import {
SERIALIZED_STATE_VERSION,
SerializedAppState,
} from '../core/state_serialization_schema';
import {z} from 'zod';
import {showModal} from '../widgets/modal';
import {AppImpl} from '../core/app_impl';
import {Router} from '../core/router';
import {onClickCopy} from './clipboard';
import {RecordingManager} from '../controller/recording_manager';
// Permalink serialization has two layers:
// 1. Serialization of the app state (state_serialization.ts):
// This is a JSON object that represents the visual app state (pinned tracks,
// visible viewport bounds, etc) BUT not the trace source.
// 2. An outer layer that contains the app state AND a link to the trace file.
// (This file)
//
// In a nutshell:
// AppState: {viewport: {...}, pinnedTracks: {...}, notes: {...}}
// Permalink: {appState: {see above}, traceUrl: 'https://gcs/trace/file'}
//
// This file deals with the outer layer, state_serialization.ts with the inner.
const PERMALINK_SCHEMA = z.object({
traceUrl: z.string().optional(),
// We don't want to enforce validation at this level but want to delegate it
// to parseAppState(), for two reasons:
// 1. parseAppState() does further semantic checks (e.g. version checking).
// 2. We want to still load the traceUrl even if the app state is invalid.
appState: z.any().optional(),
// This is for the very unusual case of clicking on "Share settings" in the
// recording page. In this case there is no trace or app state. We just
// create a permalink with the recording state.
recordingOpts: z.any().optional(),
});
type PermalinkState = z.infer<typeof PERMALINK_SCHEMA>;
export interface PermalinkOptions {
mode: 'APP_STATE' | 'RECORDING_OPTS';
}
export async function createPermalink(opts: PermalinkOptions): Promise<void> {
const hash = await createPermalinkInternal(opts);
showPermalinkDialog(hash);
}
// Returns the file name, not the full url (i.e. the name of the GCS object).
async function createPermalinkInternal(
opts: PermalinkOptions,
): Promise<string> {
const permalinkData: PermalinkState = {};
if (opts.mode === 'RECORDING_OPTS') {
permalinkData.recordingOpts = RecordingManager.instance.state.recordConfig;
} else if (opts.mode === 'APP_STATE') {
// Check if we need to upload the trace file, before serializing the app
// state.
let alreadyUploadedUrl = '';
const trace = assertExists(AppImpl.instance.trace);
const traceSource = trace.traceInfo.source;
let dataToUpload: File | ArrayBuffer | undefined = undefined;
let traceName = trace.traceInfo.traceTitle || 'trace';
if (traceSource.type === 'FILE') {
dataToUpload = traceSource.file;
traceName = dataToUpload.name;
} else if (traceSource.type === 'ARRAY_BUFFER') {
dataToUpload = traceSource.buffer;
} else if (traceSource.type === 'URL') {
alreadyUploadedUrl = traceSource.url;
} else {
throw new Error(`Cannot share trace ${JSON.stringify(traceSource)}`);
}
// Upload the trace file, unless it's already uploaded (type == 'URL').
// Internally TraceGcsUploader will skip the upload if an object with the
// same hash exists already.
if (alreadyUploadedUrl) {
permalinkData.traceUrl = alreadyUploadedUrl;
} else if (dataToUpload !== undefined) {
updateStatus(`Uploading ${traceName}`);
const uploader: GcsUploader = new GcsUploader(dataToUpload, {
mimeType: MIME_BINARY,
onProgress: () => reportUpdateProgress(uploader),
});
await uploader.waitForCompletion();
permalinkData.traceUrl = uploader.uploadedUrl;
}
permalinkData.appState = serializeAppState(trace);
}
// Serialize the permalink with the app state (or recording state) and upload.
updateStatus(`Creating permalink...`);
const permalinkJson = JsonSerialize(permalinkData);
const uploader: GcsUploader = new GcsUploader(permalinkJson, {
mimeType: MIME_JSON,
onProgress: () => reportUpdateProgress(uploader),
});
await uploader.waitForCompletion();
return uploader.uploadedFileName;
}
/**
* Loads a permalink from Google Cloud Storage.
* This is invoked when passing !#?s=fileName to URL.
* @param gcsFileName the file name of the cloud storage object. This is
* expected to be a JSON file that respects the schema defined by
* PERMALINK_SCHEMA.
*/
export async function loadPermalink(gcsFileName: string): Promise<void> {
// Otherwise, this is a request to load the permalink.
const url = `https://storage.googleapis.com/${BUCKET_NAME}/${gcsFileName}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Could not fetch permalink.\n URL: ${url}`);
}
const text = await response.text();
const permalinkJson = JSON.parse(text);
let permalink: PermalinkState;
let error = '';
// Try to recover permalinks generated by older versions of the UI before
// r.android.com/3119920 .
const convertedLegacyPermalink = tryLoadLegacyPermalink(permalinkJson);
if (convertedLegacyPermalink !== undefined) {
permalink = convertedLegacyPermalink;
} else {
const res = PERMALINK_SCHEMA.safeParse(permalinkJson);
if (res.success) {
permalink = res.data;
} else {
error = res.error.toString();
permalink = {};
}
}
if (permalink.recordingOpts !== undefined) {
// This permalink state only contains a RecordConfig. Show the
// recording page with the config, but keep other state as-is.
RecordingManager.instance.setRecordConfig(permalink.recordingOpts);
Router.navigate('#!/record');
return;
}
let serializedAppState: SerializedAppState | undefined = undefined;
if (permalink.appState !== undefined) {
// This is the most common case where the permalink contains the app state
// (and optionally a traceUrl, below).
const parseRes = parseAppState(permalink.appState);
if (parseRes.success) {
serializedAppState = parseRes.data;
} else {
error = parseRes.error;
}
}
if (permalink.traceUrl) {
AppImpl.instance.openTraceFromUrl(permalink.traceUrl, serializedAppState);
}
if (error) {
showModal({
title: 'Failed to restore the serialized app state',
content: m(
'div',
m(
'p',
'Something went wrong when restoring the app state.' +
'This is due to some backwards-incompatible change ' +
'when the permalink is generated and then opened using ' +
'two different UI versions.',
),
m(
'p',
"I'm going to try to open the trace file anyways, but " +
'the zoom level, pinned tracks and other UI ' +
"state wont't be recovered",
),
m('p', 'Error details:'),
m('.modal-logs', error),
),
buttons: [
{
text: 'Open only the trace file',
primary: true,
},
],
});
}
}
// Tries to recover a previous permalink, before the split in two layers,
// where the permalink JSON contains the app state, which contains inside it
// the trace URL.
// If we suceed, convert it to a new-style JSON object preserving some minimal
// information (really just vieport and pinned tracks).
function tryLoadLegacyPermalink(data: unknown): PermalinkState | undefined {
const legacyData = data as {
version?: number;
engine?: {source?: {url?: string}};
pinnedTracks?: string[];
frontendLocalState?: {
visibleState?: {start?: {value?: string}; end?: {value?: string}};
};
};
if (legacyData.version === undefined) return undefined;
const vizState = legacyData.frontendLocalState?.visibleState;
return {
traceUrl: legacyData.engine?.source?.url,
appState: {
version: SERIALIZED_STATE_VERSION,
pinnedTracks: legacyData.pinnedTracks ?? [],
viewport: vizState
? {start: vizState.start?.value, end: vizState.end?.value}
: undefined,
} as SerializedAppState,
} as PermalinkState;
}
function reportUpdateProgress(uploader: GcsUploader) {
switch (uploader.state) {
case 'UPLOADING':
const statusTxt = `Uploading ${uploader.getEtaString()}`;
updateStatus(statusTxt);
break;
case 'ERROR':
updateStatus(`Upload failed ${uploader.error}`);
break;
default:
break;
} // switch (state)
}
function updateStatus(msg: string): void {
AppImpl.instance.omnibox.showStatusMessage(msg);
}
function showPermalinkDialog(hash: string) {
const url = `${self.location.origin}/#!/?s=${hash}`;
const linkProps = {title: 'Click to copy the URL', onclick: onClickCopy(url)};
showModal({
title: 'Permalink',
content: m(
'div',
m(`a[href=${url}]`, linkProps, url),
m('br'),
m('i', 'Click on the URL to copy it into the clipboard'),
),
});
}