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