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