1// Copyright (C) 2018 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'; 16 17import {assertExists} from '../base/logging'; 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} from '../common/state'; 25import {STATE_VERSION} from '../common/state'; 26import { 27 BUCKET_NAME, 28 buggyToSha256, 29 deserializeStateObject, 30 saveState, 31 saveTrace, 32 toSha256, 33} from '../common/upload_utils'; 34import {globals} from '../frontend/globals'; 35import {publishConversionJobStatusUpdate} from '../frontend/publish'; 36import {Router} from '../frontend/router'; 37 38import {Controller} from './controller'; 39import {RecordConfig, recordConfigValidator} from './record_config_types'; 40import {runValidator} from './validators'; 41 42interface MultiEngineState { 43 currentEngineId?: string; 44 engines: ObjectById<EngineConfig> 45} 46 47function isMultiEngineState(state: State| 48 MultiEngineState): state is MultiEngineState { 49 if ((state as MultiEngineState).engines !== undefined) { 50 return true; 51 } 52 return false; 53} 54 55export class PermalinkController extends Controller<'main'> { 56 private lastRequestId?: string; 57 constructor() { 58 super('main'); 59 } 60 61 run() { 62 if (globals.state.permalink.requestId === undefined || 63 globals.state.permalink.requestId === this.lastRequestId) { 64 return; 65 } 66 const requestId = assertExists(globals.state.permalink.requestId); 67 this.lastRequestId = requestId; 68 69 // if the |hash| is not set, this is a request to create a permalink. 70 if (globals.state.permalink.hash === undefined) { 71 const isRecordingConfig = 72 assertExists(globals.state.permalink.isRecordingConfig); 73 74 const jobName = 'create_permalink'; 75 publishConversionJobStatusUpdate({ 76 jobName, 77 jobStatus: ConversionJobStatus.InProgress, 78 }); 79 80 PermalinkController.createPermalink(isRecordingConfig) 81 .then((hash) => { 82 globals.dispatch(Actions.setPermalink({requestId, hash})); 83 }) 84 .finally(() => { 85 publishConversionJobStatusUpdate({ 86 jobName, 87 jobStatus: ConversionJobStatus.NotRunning, 88 }); 89 }); 90 return; 91 } 92 93 // Otherwise, this is a request to load the permalink. 94 PermalinkController.loadState(globals.state.permalink.hash) 95 .then((stateOrConfig) => { 96 if (PermalinkController.isRecordConfig(stateOrConfig)) { 97 // This permalink state only contains a RecordConfig. Show the 98 // recording page with the config, but keep other state as-is. 99 const validConfig = 100 runValidator(recordConfigValidator, stateOrConfig as unknown) 101 .result; 102 globals.dispatch(Actions.setRecordConfig({config: validConfig})); 103 Router.navigate('#!/record'); 104 return; 105 } 106 globals.dispatch(Actions.setState({newState: stateOrConfig})); 107 this.lastRequestId = stateOrConfig.permalink.requestId; 108 }); 109 } 110 111 private static upgradeState(state: State): State { 112 if (state.version !== STATE_VERSION) { 113 const newState = createEmptyState(); 114 // Old permalinks from state versions prior to version 24 115 // have multiple engines of which only one is identified as the 116 // current engine via currentEngineId. Handle this case: 117 if (isMultiEngineState(state)) { 118 const engineId = state.currentEngineId; 119 if (engineId !== undefined) { 120 newState.engine = state.engines[engineId]; 121 } 122 } else { 123 newState.engine = state.engine; 124 } 125 126 if (newState.engine !== undefined) { 127 newState.engine.ready = false; 128 } 129 130 const message = `Unable to parse old state version. Discarding state ` + 131 `and loading trace.`; 132 console.warn(message); 133 PermalinkController.updateStatus(message); 134 return newState; 135 } else { 136 // Loaded state is presumed to be compatible with the State type 137 // definition in the app. However, a non-serializable part has to be 138 // recreated. 139 state.nonSerializableState = createEmptyNonSerializableState(); 140 } 141 return state; 142 } 143 144 private static isRecordConfig(stateOrConfig: State| 145 RecordConfig): stateOrConfig is RecordConfig { 146 const mode = (stateOrConfig as {mode?: string}).mode; 147 return mode !== undefined && 148 ['STOP_WHEN_FULL', 'RING_BUFFER', 'LONG_TRACE'].includes(mode); 149 } 150 151 private static async createPermalink(isRecordingConfig: boolean): 152 Promise<string> { 153 let uploadState: State|RecordConfig = globals.state; 154 155 if (isRecordingConfig) { 156 uploadState = globals.state.recordConfig; 157 } else { 158 const engine = assertExists(globals.getCurrentEngine()); 159 let dataToUpload: File|ArrayBuffer|undefined = undefined; 160 let traceName = `trace ${engine.id}`; 161 if (engine.source.type === 'FILE') { 162 dataToUpload = engine.source.file; 163 traceName = dataToUpload.name; 164 } else if (engine.source.type === 'ARRAY_BUFFER') { 165 dataToUpload = engine.source.buffer; 166 } else if (engine.source.type !== 'URL') { 167 throw new Error(`Cannot share trace ${JSON.stringify(engine.source)}`); 168 } 169 170 if (dataToUpload !== undefined) { 171 PermalinkController.updateStatus(`Uploading ${traceName}`); 172 const url = await saveTrace(dataToUpload); 173 // Convert state to use URLs and remove permalink. 174 uploadState = produce(globals.state, (draft) => { 175 assertExists(draft.engine).source = {type: 'URL', url}; 176 draft.permalink = {}; 177 }); 178 } 179 } 180 181 // Upload state. 182 PermalinkController.updateStatus(`Creating permalink...`); 183 const hash = await saveState(uploadState); 184 PermalinkController.updateStatus(`Permalink ready`); 185 return hash; 186 } 187 188 private static async loadState(id: string): Promise<State|RecordConfig> { 189 const url = `https://storage.googleapis.com/${BUCKET_NAME}/${id}`; 190 const response = await fetch(url); 191 if (!response.ok) { 192 throw new Error( 193 `Could not fetch permalink.\n` + 194 `Are you sure the id (${id}) is correct?\n` + 195 `URL: ${url}`); 196 } 197 const text = await response.text(); 198 const stateHash = await toSha256(text); 199 const state = deserializeStateObject(text); 200 if (stateHash !== id) { 201 // Old permalinks incorrectly dropped some digits from the 202 // hexdigest of the SHA256. We don't want to invalidate those 203 // links so we also compute the old string and try that here 204 // also. 205 const buggyStateHash = await buggyToSha256(text); 206 if (buggyStateHash !== id) { 207 throw new Error(`State hash does not match ${id} vs. ${stateHash}`); 208 } 209 } 210 if (!this.isRecordConfig(state)) { 211 return this.upgradeState(state); 212 } 213 return state; 214 } 215 216 private static updateStatus(msg: string): void { 217 // TODO(hjd): Unify loading updates. 218 globals.dispatch(Actions.updateStatus({ 219 msg, 220 timestamp: Date.now() / 1000, 221 })); 222 } 223} 224