• 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 {produce} from 'immer';
16import {assertExists} from '../base/logging';
17import {runValidator} from '../base/validators';
18import {Actions} from '../common/actions';
19import {ConversionJobStatus} from '../common/conversion_jobs';
20import {
21  createEmptyNonSerializableState,
22  createEmptyState,
23} from '../common/empty_state';
24import {EngineConfig, ObjectById, STATE_VERSION, State} from '../common/state';
25import {
26  BUCKET_NAME,
27  TraceGcsUploader,
28  buggyToSha256,
29  deserializeStateObject,
30  saveState,
31  toSha256,
32} from '../common/upload_utils';
33import {
34  RecordConfig,
35  recordConfigValidator,
36} from '../controller/record_config_types';
37import {globals} from './globals';
38import {
39  publishConversionJobStatusUpdate,
40  publishPermalinkHash,
41} from './publish';
42import {Router} from './router';
43import {showModal} from '../widgets/modal';
44
45export interface PermalinkOptions {
46  isRecordingConfig?: boolean;
47}
48
49export async function createPermalink(
50  options: PermalinkOptions = {},
51): Promise<void> {
52  const {isRecordingConfig = false} = options;
53  const jobName = 'create_permalink';
54  publishConversionJobStatusUpdate({
55    jobName,
56    jobStatus: ConversionJobStatus.InProgress,
57  });
58
59  try {
60    const hash = await createPermalinkInternal(isRecordingConfig);
61    publishPermalinkHash(hash);
62  } finally {
63    publishConversionJobStatusUpdate({
64      jobName,
65      jobStatus: ConversionJobStatus.NotRunning,
66    });
67  }
68}
69
70async function createPermalinkInternal(
71  isRecordingConfig: boolean,
72): Promise<string> {
73  let uploadState: State | RecordConfig = globals.state;
74
75  if (isRecordingConfig) {
76    uploadState = globals.state.recordConfig;
77  } else {
78    const engine = assertExists(globals.getCurrentEngine());
79    let dataToUpload: File | ArrayBuffer | undefined = undefined;
80    let traceName = `trace ${engine.id}`;
81    if (engine.source.type === 'FILE') {
82      dataToUpload = engine.source.file;
83      traceName = dataToUpload.name;
84    } else if (engine.source.type === 'ARRAY_BUFFER') {
85      dataToUpload = engine.source.buffer;
86    } else if (engine.source.type !== 'URL') {
87      throw new Error(`Cannot share trace ${JSON.stringify(engine.source)}`);
88    }
89
90    if (dataToUpload !== undefined) {
91      updateStatus(`Uploading ${traceName}`);
92      const uploader = new TraceGcsUploader(dataToUpload, () => {
93        switch (uploader.state) {
94          case 'UPLOADING':
95            const statusTxt = `Uploading ${uploader.getEtaString()}`;
96            updateStatus(statusTxt);
97            break;
98          case 'UPLOADED':
99            // Convert state to use URLs and remove permalink.
100            const url = uploader.uploadedUrl;
101            uploadState = produce(globals.state, (draft) => {
102              assertExists(draft.engine).source = {type: 'URL', url};
103            });
104            break;
105          case 'ERROR':
106            updateStatus(`Upload failed ${uploader.error}`);
107            break;
108        } // switch (state)
109      }); // onProgress
110      await uploader.waitForCompletion();
111    }
112  }
113
114  // Upload state.
115  updateStatus(`Creating permalink...`);
116  const hash = await saveState(uploadState);
117  updateStatus(`Permalink ready`);
118  return hash;
119}
120
121function updateStatus(msg: string): void {
122  // TODO(hjd): Unify loading updates.
123  globals.dispatch(
124    Actions.updateStatus({
125      msg,
126      timestamp: Date.now() / 1000,
127    }),
128  );
129}
130
131export async function loadPermalink(hash: string): Promise<void> {
132  // Otherwise, this is a request to load the permalink.
133  const stateOrConfig = await loadState(hash);
134
135  if (isRecordConfig(stateOrConfig)) {
136    // This permalink state only contains a RecordConfig. Show the
137    // recording page with the config, but keep other state as-is.
138    const validConfig = runValidator(
139      recordConfigValidator,
140      stateOrConfig as unknown,
141    ).result;
142    globals.dispatch(Actions.setRecordConfig({config: validConfig}));
143    Router.navigate('#!/record');
144    return;
145  }
146  globals.dispatch(Actions.setState({newState: stateOrConfig}));
147}
148
149async function loadState(id: string): Promise<State | RecordConfig> {
150  const url = `https://storage.googleapis.com/${BUCKET_NAME}/${id}`;
151  const response = await fetch(url);
152  if (!response.ok) {
153    throw new Error(
154      `Could not fetch permalink.\n` +
155        `Are you sure the id (${id}) is correct?\n` +
156        `URL: ${url}`,
157    );
158  }
159  const text = await response.text();
160  const stateHash = await toSha256(text);
161  const state = deserializeStateObject<State>(text);
162  if (stateHash !== id) {
163    // Old permalinks incorrectly dropped some digits from the
164    // hexdigest of the SHA256. We don't want to invalidate those
165    // links so we also compute the old string and try that here
166    // also.
167    const buggyStateHash = await buggyToSha256(text);
168    if (buggyStateHash !== id) {
169      throw new Error(`State hash does not match ${id} vs. ${stateHash}`);
170    }
171  }
172  if (!isRecordConfig(state)) {
173    return upgradeState(state);
174  }
175  return state;
176}
177
178function isRecordConfig(
179  stateOrConfig: State | RecordConfig,
180): stateOrConfig is RecordConfig {
181  const mode = (stateOrConfig as {mode?: string}).mode;
182  return (
183    mode !== undefined &&
184    ['STOP_WHEN_FULL', 'RING_BUFFER', 'LONG_TRACE'].includes(mode)
185  );
186}
187
188function upgradeState(state: State): State {
189  if (state.engine !== undefined && state.engine.source.type !== 'URL') {
190    // All permalink traces should be modified to have a source.type=URL
191    // pointing to the uploaded trace. Due to a bug in some older version
192    // of the UI (b/327049372), an upload failure can end up with a state that
193    // has type=FILE but a null file object. If this happens, invalidate the
194    // trace and show a message.
195    showModal({
196      title: 'Cannot load trace permalink',
197      content: m(
198        'div',
199        'The permalink stored on the server is corrupted ' +
200          'and cannot be loaded.',
201      ),
202    });
203    return createEmptyState();
204  }
205
206  if (state.version !== STATE_VERSION) {
207    const newState = createEmptyState();
208    // Old permalinks from state versions prior to version 24
209    // have multiple engines of which only one is identified as the
210    // current engine via currentEngineId. Handle this case:
211    if (isMultiEngineState(state)) {
212      const engineId = state.currentEngineId;
213      if (engineId !== undefined) {
214        newState.engine = state.engines[engineId];
215      }
216    } else {
217      newState.engine = state.engine;
218    }
219
220    if (newState.engine !== undefined) {
221      newState.engine.ready = false;
222    }
223    const message =
224      `Unable to parse old state version. Discarding state ` +
225      `and loading trace.`;
226    console.warn(message);
227    updateStatus(message);
228    return newState;
229  } else {
230    // Loaded state is presumed to be compatible with the State type
231    // definition in the app. However, a non-serializable part has to be
232    // recreated.
233    state.nonSerializableState = createEmptyNonSerializableState();
234  }
235  return state;
236}
237
238interface MultiEngineState {
239  currentEngineId?: string;
240  engines: ObjectById<EngineConfig>;
241}
242
243function isMultiEngineState(
244  state: State | MultiEngineState,
245): state is MultiEngineState {
246  if ((state as MultiEngineState).engines !== undefined) {
247    return true;
248  }
249  return false;
250}
251