1// Copyright (C) 2022 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 {assertExists, assertTrue} from '../../base/logging'; 16import {globals} from '../../frontend/globals'; 17import {autosaveConfigStore} from '../../frontend/record_config'; 18import { 19 DEFAULT_ADB_WEBSOCKET_URL, 20 DEFAULT_TRACED_WEBSOCKET_URL, 21} from '../../frontend/recording/recording_ui_utils'; 22import { 23 couldNotClaimInterface, 24} from '../../frontend/recording/reset_interface_modal'; 25import {Actions} from '../actions'; 26import {TRACE_SUFFIX} from '../constants'; 27import {TraceConfig} from '../protos'; 28import {currentDateHourAndMinute} from '../time'; 29 30import {genTraceConfig} from './recording_config_utils'; 31import {RecordingError, showRecordingModal} from './recording_error_handling'; 32import { 33 RecordingTargetV2, 34 TargetInfo, 35 TracingSession, 36 TracingSessionListener, 37} from './recording_interfaces_v2'; 38import { 39 BUFFER_USAGE_NOT_ACCESSIBLE, 40 RECORDING_IN_PROGRESS, 41} from './recording_utils'; 42import { 43 ANDROID_WEBSOCKET_TARGET_FACTORY, 44 AndroidWebsocketTargetFactory, 45} from './target_factories/android_websocket_target_factory'; 46import { 47 ANDROID_WEBUSB_TARGET_FACTORY, 48} from './target_factories/android_webusb_target_factory'; 49import { 50 HOST_OS_TARGET_FACTORY, 51 HostOsTargetFactory, 52} from './target_factories/host_os_target_factory'; 53import {targetFactoryRegistry} from './target_factory_registry'; 54 55// The recording page can be in any of these states. It can transition between 56// states: 57// a) because of a user actions - pressing a UI button ('Start', 'Stop', 58// 'Cancel', 'Force reset' of the target), selecting a different target in 59// the UI, authorizing authentication on an Android device, 60// pulling the cable which connects an Android device. 61// b) automatically - if there is no need to reset the device or if the user 62// has previously authorised the device to be debugged via USB. 63// 64// Recording state machine: https://screenshot.googleplex.com/BaX5EGqQMajgV7G 65export enum RecordingState { 66 NO_TARGET = 0, 67 TARGET_SELECTED = 1, 68 // P1 stands for 'Part 1', where we first connect to the device in order to 69 // obtain target information. 70 ASK_TO_FORCE_P1 = 2, 71 AUTH_P1 = 3, 72 TARGET_INFO_DISPLAYED = 4, 73 // P2 stands for 'Part 2', where we connect to device for the 2nd+ times, to 74 // record a tracing session. 75 ASK_TO_FORCE_P2 = 5, 76 AUTH_P2 = 6, 77 RECORDING = 7, 78 WAITING_FOR_TRACE_DISPLAY = 8, 79} 80 81// Wraps a tracing session promise while the promise is being resolved (e.g. 82// while we are awaiting for ADB auth). 83class TracingSessionWrapper { 84 private tracingSession?: TracingSession = undefined; 85 private isCancelled = false; 86 // We only execute the logic in the callbacks if this TracingSessionWrapper 87 // is the one referenced by the controller. Otherwise this can hold a 88 // tracing session which the user has already cancelled, so it shouldn't 89 // influence the UI. 90 private tracingSessionListener: TracingSessionListener = { 91 onTraceData: (trace: Uint8Array) => 92 this.controller.maybeOnTraceData(this, trace), 93 onStatus: (message) => this.controller.maybeOnStatus(this, message), 94 onDisconnect: (errorMessage?: string) => 95 this.controller.maybeOnDisconnect(this, errorMessage), 96 onError: (errorMessage: string) => 97 this.controller.maybeOnError(this, errorMessage), 98 }; 99 100 private target: RecordingTargetV2; 101 private controller: RecordingPageController; 102 103 constructor(target: RecordingTargetV2, controller: RecordingPageController) { 104 this.target = target; 105 this.controller = controller; 106 } 107 108 async start(traceConfig: TraceConfig) { 109 let stateGeneratioNr = this.controller.getStateGeneration(); 110 const createSession = async () => { 111 try { 112 this.controller.maybeSetState( 113 this, RecordingState.AUTH_P2, stateGeneratioNr); 114 stateGeneratioNr += 1; 115 116 const session = 117 await this.target.createTracingSession(this.tracingSessionListener); 118 119 // We check the `isCancelled` to see if the user has cancelled the 120 // tracing session before it becomes available in TracingSessionWrapper. 121 if (this.isCancelled) { 122 session.cancel(); 123 return; 124 } 125 126 this.tracingSession = session; 127 this.controller.maybeSetState( 128 this, RecordingState.RECORDING, stateGeneratioNr); 129 // When the session is resolved, the traceConfig has been instantiated. 130 this.tracingSession.start(assertExists(traceConfig)); 131 } catch (e) { 132 this.tracingSessionListener.onError(e.message); 133 } 134 }; 135 136 if (await this.target.canConnectWithoutContention()) { 137 await createSession(); 138 } else { 139 // If we need to reset the connection to be able to connect, we ask 140 // the user if they want to reset the connection. 141 this.controller.maybeSetState( 142 this, RecordingState.ASK_TO_FORCE_P2, stateGeneratioNr); 143 stateGeneratioNr += 1; 144 couldNotClaimInterface( 145 createSession, () => this.controller.maybeClearRecordingState(this)); 146 } 147 } 148 149 async fetchTargetInfo() { 150 let stateGeneratioNr = this.controller.getStateGeneration(); 151 const createSession = async () => { 152 try { 153 this.controller.maybeSetState( 154 this, RecordingState.AUTH_P1, stateGeneratioNr); 155 stateGeneratioNr += 1; 156 await this.target.fetchTargetInfo(this.tracingSessionListener); 157 this.controller.maybeSetState( 158 this, RecordingState.TARGET_INFO_DISPLAYED, stateGeneratioNr); 159 } catch (e) { 160 this.tracingSessionListener.onError(e.message); 161 } 162 }; 163 164 if (await this.target.canConnectWithoutContention()) { 165 await createSession(); 166 } else { 167 // If we need to reset the connection to be able to connect, we ask 168 // the user if they want to reset the connection. 169 this.controller.maybeSetState( 170 this, RecordingState.ASK_TO_FORCE_P1, stateGeneratioNr); 171 stateGeneratioNr += 1; 172 couldNotClaimInterface( 173 createSession, 174 () => this.controller.maybeSetState( 175 this, RecordingState.TARGET_SELECTED, stateGeneratioNr)); 176 } 177 } 178 179 cancel() { 180 if (this.tracingSession) { 181 this.tracingSession.cancel(); 182 } else { 183 // In some cases, the tracingSession may not be available to the 184 // TracingSessionWrapper when the user cancels it. 185 // For instance: 186 // 1. The user clicked 'Start'. 187 // 2. They clicked 'Stop' without authorizing on the device. 188 // 3. They clicked 'Start'. 189 // 4. They authorized on the device. 190 // In these cases, we want to cancel the tracing session as soon as it 191 // becomes available. Therefore, we keep the `isCancelled` boolean and 192 // check it when we receive the tracing session. 193 this.isCancelled = true; 194 } 195 this.controller.maybeClearRecordingState(this); 196 } 197 198 stop() { 199 const stateGeneratioNr = this.controller.getStateGeneration(); 200 if (this.tracingSession) { 201 this.tracingSession.stop(); 202 this.controller.maybeSetState( 203 this, RecordingState.WAITING_FOR_TRACE_DISPLAY, stateGeneratioNr); 204 } else { 205 // In some cases, the tracingSession may not be available to the 206 // TracingSessionWrapper when the user stops it. 207 // For instance: 208 // 1. The user clicked 'Start'. 209 // 2. They clicked 'Stop' without authorizing on the device. 210 // 3. They clicked 'Start'. 211 // 4. They authorized on the device. 212 // In these cases, we want to cancel the tracing session as soon as it 213 // becomes available. Therefore, we keep the `isCancelled` boolean and 214 // check it when we receive the tracing session. 215 this.isCancelled = true; 216 this.controller.maybeClearRecordingState(this); 217 } 218 } 219 220 getTraceBufferUsage(): Promise<number> { 221 if (!this.tracingSession) { 222 throw new RecordingError(BUFFER_USAGE_NOT_ACCESSIBLE); 223 } 224 return this.tracingSession.getTraceBufferUsage(); 225 } 226} 227 228// Keeps track of the state the Ui is in. Has methods which are executed on 229// user actions such as starting/stopping/cancelling a tracing session. 230export class RecordingPageController { 231 // State of the recording page. This is set by user actions and/or automatic 232 // transitions. This is queried by the UI in order to 233 private state: RecordingState = RecordingState.NO_TARGET; 234 // Currently selected target. 235 private target?: RecordingTargetV2 = undefined; 236 // We wrap the tracing session in an object, because for some targets 237 // (Ex: Android) it is only created after we have succesfully authenticated 238 // with the target. 239 private tracingSessionWrapper?: TracingSessionWrapper = undefined; 240 // How much of the buffer is used for the current tracing session. 241 private bufferUsagePercentage: number = 0; 242 // A counter for state modifications. We use this to ensure that state 243 // transitions don't override one another in async functions. 244 private stateGeneration = 0; 245 246 getBufferUsagePercentage(): number { 247 return this.bufferUsagePercentage; 248 } 249 250 getState(): RecordingState { 251 return this.state; 252 } 253 254 getStateGeneration(): number { 255 return this.stateGeneration; 256 } 257 258 maybeSetState( 259 tracingSessionWrapper: TracingSessionWrapper, state: RecordingState, 260 stateGeneration: number): void { 261 if (this.tracingSessionWrapper !== tracingSessionWrapper) { 262 return; 263 } 264 if (stateGeneration !== this.stateGeneration) { 265 throw new RecordingError('Recording page state transition out of order.'); 266 } 267 this.setState(state); 268 globals.dispatch(Actions.setRecordingStatus({status: undefined})); 269 globals.rafScheduler.scheduleFullRedraw(); 270 } 271 272 maybeClearRecordingState(tracingSessionWrapper: TracingSessionWrapper): void { 273 if (this.tracingSessionWrapper === tracingSessionWrapper) { 274 this.clearRecordingState(); 275 } 276 } 277 278 maybeOnTraceData( 279 tracingSessionWrapper: TracingSessionWrapper, trace: Uint8Array) { 280 if (this.tracingSessionWrapper !== tracingSessionWrapper) { 281 return; 282 } 283 globals.dispatch(Actions.openTraceFromBuffer({ 284 title: 'Recorded trace', 285 buffer: trace.buffer, 286 fileName: `trace_${currentDateHourAndMinute()}${TRACE_SUFFIX}`, 287 })); 288 this.clearRecordingState(); 289 } 290 291 maybeOnStatus(tracingSessionWrapper: TracingSessionWrapper, message: string) { 292 if (this.tracingSessionWrapper !== tracingSessionWrapper) { 293 return; 294 } 295 // For the 'Recording in progress for 7000ms we don't show a 296 // modal.' 297 if (message.startsWith(RECORDING_IN_PROGRESS)) { 298 globals.dispatch(Actions.setRecordingStatus({status: message})); 299 } else { 300 // For messages such as 'Please allow USB debugging on your 301 // device, which require a user action, we show a modal. 302 showRecordingModal(message); 303 } 304 } 305 306 maybeOnDisconnect( 307 tracingSessionWrapper: TracingSessionWrapper, errorMessage?: string) { 308 if (this.tracingSessionWrapper !== tracingSessionWrapper) { 309 return; 310 } 311 if (errorMessage) { 312 showRecordingModal(errorMessage); 313 } 314 this.clearRecordingState(); 315 this.onTargetChange(); 316 } 317 318 maybeOnError( 319 tracingSessionWrapper: TracingSessionWrapper, errorMessage: string) { 320 if (this.tracingSessionWrapper !== tracingSessionWrapper) { 321 return; 322 } 323 showRecordingModal(errorMessage); 324 this.clearRecordingState(); 325 } 326 327 getTargetInfo(): TargetInfo|undefined { 328 if (!this.target) { 329 return undefined; 330 } 331 return this.target.getInfo(); 332 } 333 334 canCreateTracingSession() { 335 if (!this.target) { 336 return false; 337 } 338 return this.target.canCreateTracingSession(); 339 } 340 341 selectTarget(selectedTarget?: RecordingTargetV2) { 342 assertTrue( 343 RecordingState.NO_TARGET <= this.state && 344 this.state < RecordingState.RECORDING); 345 // If the selected target exists and is the same as the previous one, we 346 // don't need to do anything. 347 if (selectedTarget && selectedTarget === this.target) { 348 return; 349 } 350 351 // We assign the new target and redraw the page. 352 this.target = selectedTarget; 353 354 if (!this.target) { 355 this.setState(RecordingState.NO_TARGET); 356 globals.rafScheduler.scheduleFullRedraw(); 357 return; 358 } 359 this.setState(RecordingState.TARGET_SELECTED); 360 globals.rafScheduler.scheduleFullRedraw(); 361 362 this.tracingSessionWrapper = this.createTracingSessionWrapper(this.target); 363 this.tracingSessionWrapper.fetchTargetInfo(); 364 } 365 366 async addAndroidDevice(): Promise<void> { 367 try { 368 const target = 369 await targetFactoryRegistry.get(ANDROID_WEBUSB_TARGET_FACTORY) 370 .connectNewTarget(); 371 this.selectTarget(target); 372 } catch (e) { 373 if (e instanceof RecordingError) { 374 showRecordingModal(e.message); 375 } else { 376 throw e; 377 } 378 } 379 } 380 381 onTargetSelection(targetName: string): void { 382 assertTrue( 383 RecordingState.NO_TARGET <= this.state && 384 this.state < RecordingState.RECORDING); 385 const allTargets = targetFactoryRegistry.listTargets(); 386 this.selectTarget(allTargets.find((t) => t.getInfo().name === targetName)); 387 } 388 389 onStartRecordingPressed(): void { 390 assertTrue(RecordingState.TARGET_INFO_DISPLAYED === this.state); 391 location.href = '#!/record/instructions'; 392 autosaveConfigStore.save(globals.state.recordConfig); 393 394 const target = this.getTarget(); 395 const targetInfo = target.getInfo(); 396 globals.logging.logEvent( 397 'Record Trace', `Record trace (${targetInfo.targetType})`); 398 const traceConfig = genTraceConfig(globals.state.recordConfig, targetInfo); 399 400 this.tracingSessionWrapper = this.createTracingSessionWrapper(target); 401 this.tracingSessionWrapper.start(traceConfig); 402 } 403 404 onCancel() { 405 assertTrue( 406 RecordingState.AUTH_P2 <= this.state && 407 this.state <= RecordingState.RECORDING); 408 // The 'Cancel' button will only be shown after a `tracingSessionWrapper` 409 // is created. 410 this.getTracingSessionWrapper().cancel(); 411 } 412 413 onStop() { 414 assertTrue( 415 RecordingState.AUTH_P2 <= this.state && 416 this.state <= RecordingState.RECORDING); 417 // The 'Stop' button will only be shown after a `tracingSessionWrapper` 418 // is created. 419 this.getTracingSessionWrapper().stop(); 420 } 421 422 async fetchBufferUsage() { 423 assertTrue(this.state >= RecordingState.AUTH_P2); 424 if (!this.tracingSessionWrapper) return; 425 const session = this.tracingSessionWrapper; 426 427 try { 428 const usage = await session.getTraceBufferUsage(); 429 if (this.tracingSessionWrapper === session) { 430 this.bufferUsagePercentage = usage; 431 } 432 } catch (e) { 433 // We ignore RecordingErrors because they are not necessary for the trace 434 // to be successfully collected. 435 if (!(e instanceof RecordingError)) { 436 throw e; 437 } 438 } 439 // We redraw if: 440 // 1. We received a correct buffer usage value. 441 // 2. We receive a RecordingError. 442 globals.rafScheduler.scheduleFullRedraw(); 443 } 444 445 initFactories() { 446 assertTrue(this.state <= RecordingState.TARGET_INFO_DISPLAYED); 447 for (const targetFactory of targetFactoryRegistry.listTargetFactories()) { 448 if (targetFactory) { 449 targetFactory.setOnTargetChange(this.onTargetChange.bind(this)); 450 } 451 } 452 453 if (targetFactoryRegistry.has(ANDROID_WEBSOCKET_TARGET_FACTORY)) { 454 const websocketTargetFactory = 455 targetFactoryRegistry.get(ANDROID_WEBSOCKET_TARGET_FACTORY) as 456 AndroidWebsocketTargetFactory; 457 websocketTargetFactory.tryEstablishWebsocket(DEFAULT_ADB_WEBSOCKET_URL); 458 } 459 if (targetFactoryRegistry.has(HOST_OS_TARGET_FACTORY)) { 460 const websocketTargetFactory = 461 targetFactoryRegistry.get(HOST_OS_TARGET_FACTORY) as 462 HostOsTargetFactory; 463 websocketTargetFactory.tryEstablishWebsocket( 464 DEFAULT_TRACED_WEBSOCKET_URL); 465 } 466 } 467 468 shouldShowTargetSelection(): boolean { 469 return RecordingState.NO_TARGET <= this.state && 470 this.state < RecordingState.RECORDING; 471 } 472 473 shouldShowStopCancelButtons(): boolean { 474 return RecordingState.AUTH_P2 <= this.state && 475 this.state <= RecordingState.RECORDING; 476 } 477 478 private onTargetChange() { 479 const allTargets = targetFactoryRegistry.listTargets(); 480 // If the change happens for an existing target, the controller keeps the 481 // currently selected target in focus. 482 if (this.target && allTargets.includes(this.target)) { 483 globals.rafScheduler.scheduleFullRedraw(); 484 return; 485 } 486 // If the change happens to a new target or the controller does not have a 487 // defined target, the selection process again is run again. 488 this.selectTarget(); 489 } 490 491 private createTracingSessionWrapper(target: RecordingTargetV2): 492 TracingSessionWrapper { 493 return new TracingSessionWrapper(target, this); 494 } 495 496 private clearRecordingState(): void { 497 this.bufferUsagePercentage = 0; 498 this.tracingSessionWrapper = undefined; 499 this.setState(RecordingState.TARGET_INFO_DISPLAYED); 500 globals.dispatch(Actions.setRecordingStatus({status: undefined})); 501 // Redrawing because this method has changed the RecordingState, which will 502 // affect the display of the record_page. 503 globals.rafScheduler.scheduleFullRedraw(); 504 } 505 506 private setState(state: RecordingState) { 507 this.state = state; 508 this.stateGeneration += 1; 509 } 510 511 private getTarget(): RecordingTargetV2 { 512 assertTrue(RecordingState.TARGET_INFO_DISPLAYED === this.state); 513 return assertExists(this.target); 514 } 515 516 private getTracingSessionWrapper(): TracingSessionWrapper { 517 assertTrue( 518 RecordingState.ASK_TO_FORCE_P2 <= this.state && 519 this.state <= RecordingState.RECORDING); 520 return assertExists(this.tracingSessionWrapper); 521 } 522} 523