| // Copyright (C) 2022 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {assertExists, assertTrue} from '../../base/logging'; |
| import {globals} from '../../frontend/globals'; |
| import {autosaveConfigStore} from '../../frontend/record_config'; |
| import { |
| DEFAULT_ADB_WEBSOCKET_URL, |
| DEFAULT_TRACED_WEBSOCKET_URL, |
| } from '../../frontend/recording/recording_ui_utils'; |
| import { |
| couldNotClaimInterface, |
| } from '../../frontend/recording/reset_interface_modal'; |
| import {Actions} from '../actions'; |
| import {TRACE_SUFFIX} from '../constants'; |
| import {TraceConfig} from '../protos'; |
| import {currentDateHourAndMinute} from '../time'; |
| |
| import {genTraceConfig} from './recording_config_utils'; |
| import {RecordingError, showRecordingModal} from './recording_error_handling'; |
| import { |
| RecordingTargetV2, |
| TargetInfo, |
| TracingSession, |
| TracingSessionListener, |
| } from './recording_interfaces_v2'; |
| import { |
| BUFFER_USAGE_NOT_ACCESSIBLE, |
| RECORDING_IN_PROGRESS, |
| } from './recording_utils'; |
| import { |
| ANDROID_WEBSOCKET_TARGET_FACTORY, |
| AndroidWebsocketTargetFactory, |
| } from './target_factories/android_websocket_target_factory'; |
| import { |
| ANDROID_WEBUSB_TARGET_FACTORY, |
| } from './target_factories/android_webusb_target_factory'; |
| import { |
| HOST_OS_TARGET_FACTORY, |
| HostOsTargetFactory, |
| } from './target_factories/host_os_target_factory'; |
| import {targetFactoryRegistry} from './target_factory_registry'; |
| |
| // The recording page can be in any of these states. It can transition between |
| // states: |
| // a) because of a user actions - pressing a UI button ('Start', 'Stop', |
| // 'Cancel', 'Force reset' of the target), selecting a different target in |
| // the UI, authorizing authentication on an Android device, |
| // pulling the cable which connects an Android device. |
| // b) automatically - if there is no need to reset the device or if the user |
| // has previously authorised the device to be debugged via USB. |
| // |
| // Recording state machine: https://screenshot.googleplex.com/BaX5EGqQMajgV7G |
| export enum RecordingState { |
| NO_TARGET = 0, |
| TARGET_SELECTED = 1, |
| // P1 stands for 'Part 1', where we first connect to the device in order to |
| // obtain target information. |
| ASK_TO_FORCE_P1 = 2, |
| AUTH_P1 = 3, |
| TARGET_INFO_DISPLAYED = 4, |
| // P2 stands for 'Part 2', where we connect to device for the 2nd+ times, to |
| // record a tracing session. |
| ASK_TO_FORCE_P2 = 5, |
| AUTH_P2 = 6, |
| RECORDING = 7, |
| WAITING_FOR_TRACE_DISPLAY = 8, |
| } |
| |
| // Wraps a tracing session promise while the promise is being resolved (e.g. |
| // while we are awaiting for ADB auth). |
| class TracingSessionWrapper { |
| private tracingSession?: TracingSession = undefined; |
| private isCancelled = false; |
| // We only execute the logic in the callbacks if this TracingSessionWrapper |
| // is the one referenced by the controller. Otherwise this can hold a |
| // tracing session which the user has already cancelled, so it shouldn't |
| // influence the UI. |
| private tracingSessionListener: TracingSessionListener = { |
| onTraceData: (trace: Uint8Array) => |
| this.controller.maybeOnTraceData(this, trace), |
| onStatus: (message) => this.controller.maybeOnStatus(this, message), |
| onDisconnect: (errorMessage?: string) => |
| this.controller.maybeOnDisconnect(this, errorMessage), |
| onError: (errorMessage: string) => |
| this.controller.maybeOnError(this, errorMessage), |
| }; |
| |
| private target: RecordingTargetV2; |
| private controller: RecordingPageController; |
| |
| constructor(target: RecordingTargetV2, controller: RecordingPageController) { |
| this.target = target; |
| this.controller = controller; |
| } |
| |
| async start(traceConfig: TraceConfig) { |
| let stateGeneratioNr = this.controller.getStateGeneration(); |
| const createSession = async () => { |
| try { |
| this.controller.maybeSetState( |
| this, RecordingState.AUTH_P2, stateGeneratioNr); |
| stateGeneratioNr += 1; |
| |
| const session = |
| await this.target.createTracingSession(this.tracingSessionListener); |
| |
| // We check the `isCancelled` to see if the user has cancelled the |
| // tracing session before it becomes available in TracingSessionWrapper. |
| if (this.isCancelled) { |
| session.cancel(); |
| return; |
| } |
| |
| this.tracingSession = session; |
| this.controller.maybeSetState( |
| this, RecordingState.RECORDING, stateGeneratioNr); |
| // When the session is resolved, the traceConfig has been instantiated. |
| this.tracingSession.start(assertExists(traceConfig)); |
| } catch (e) { |
| this.tracingSessionListener.onError(e.message); |
| } |
| }; |
| |
| if (await this.target.canConnectWithoutContention()) { |
| await createSession(); |
| } else { |
| // If we need to reset the connection to be able to connect, we ask |
| // the user if they want to reset the connection. |
| this.controller.maybeSetState( |
| this, RecordingState.ASK_TO_FORCE_P2, stateGeneratioNr); |
| stateGeneratioNr += 1; |
| couldNotClaimInterface( |
| createSession, () => this.controller.maybeClearRecordingState(this)); |
| } |
| } |
| |
| async fetchTargetInfo() { |
| let stateGeneratioNr = this.controller.getStateGeneration(); |
| const createSession = async () => { |
| try { |
| this.controller.maybeSetState( |
| this, RecordingState.AUTH_P1, stateGeneratioNr); |
| stateGeneratioNr += 1; |
| await this.target.fetchTargetInfo(this.tracingSessionListener); |
| this.controller.maybeSetState( |
| this, RecordingState.TARGET_INFO_DISPLAYED, stateGeneratioNr); |
| } catch (e) { |
| this.tracingSessionListener.onError(e.message); |
| } |
| }; |
| |
| if (await this.target.canConnectWithoutContention()) { |
| await createSession(); |
| } else { |
| // If we need to reset the connection to be able to connect, we ask |
| // the user if they want to reset the connection. |
| this.controller.maybeSetState( |
| this, RecordingState.ASK_TO_FORCE_P1, stateGeneratioNr); |
| stateGeneratioNr += 1; |
| couldNotClaimInterface( |
| createSession, |
| () => this.controller.maybeSetState( |
| this, RecordingState.TARGET_SELECTED, stateGeneratioNr)); |
| } |
| } |
| |
| cancel() { |
| if (this.tracingSession) { |
| this.tracingSession.cancel(); |
| } else { |
| // In some cases, the tracingSession may not be available to the |
| // TracingSessionWrapper when the user cancels it. |
| // For instance: |
| // 1. The user clicked 'Start'. |
| // 2. They clicked 'Stop' without authorizing on the device. |
| // 3. They clicked 'Start'. |
| // 4. They authorized on the device. |
| // In these cases, we want to cancel the tracing session as soon as it |
| // becomes available. Therefore, we keep the `isCancelled` boolean and |
| // check it when we receive the tracing session. |
| this.isCancelled = true; |
| } |
| this.controller.maybeClearRecordingState(this); |
| } |
| |
| stop() { |
| const stateGeneratioNr = this.controller.getStateGeneration(); |
| if (this.tracingSession) { |
| this.tracingSession.stop(); |
| this.controller.maybeSetState( |
| this, RecordingState.WAITING_FOR_TRACE_DISPLAY, stateGeneratioNr); |
| } else { |
| // In some cases, the tracingSession may not be available to the |
| // TracingSessionWrapper when the user stops it. |
| // For instance: |
| // 1. The user clicked 'Start'. |
| // 2. They clicked 'Stop' without authorizing on the device. |
| // 3. They clicked 'Start'. |
| // 4. They authorized on the device. |
| // In these cases, we want to cancel the tracing session as soon as it |
| // becomes available. Therefore, we keep the `isCancelled` boolean and |
| // check it when we receive the tracing session. |
| this.isCancelled = true; |
| this.controller.maybeClearRecordingState(this); |
| } |
| } |
| |
| getTraceBufferUsage(): Promise<number> { |
| if (!this.tracingSession) { |
| throw new RecordingError(BUFFER_USAGE_NOT_ACCESSIBLE); |
| } |
| return this.tracingSession.getTraceBufferUsage(); |
| } |
| } |
| |
| // Keeps track of the state the Ui is in. Has methods which are executed on |
| // user actions such as starting/stopping/cancelling a tracing session. |
| export class RecordingPageController { |
| // State of the recording page. This is set by user actions and/or automatic |
| // transitions. This is queried by the UI in order to |
| private state: RecordingState = RecordingState.NO_TARGET; |
| // Currently selected target. |
| private target?: RecordingTargetV2 = undefined; |
| // We wrap the tracing session in an object, because for some targets |
| // (Ex: Android) it is only created after we have succesfully authenticated |
| // with the target. |
| private tracingSessionWrapper?: TracingSessionWrapper = undefined; |
| // How much of the buffer is used for the current tracing session. |
| private bufferUsagePercentage: number = 0; |
| // A counter for state modifications. We use this to ensure that state |
| // transitions don't override one another in async functions. |
| private stateGeneration = 0; |
| |
| getBufferUsagePercentage(): number { |
| return this.bufferUsagePercentage; |
| } |
| |
| getState(): RecordingState { |
| return this.state; |
| } |
| |
| getStateGeneration(): number { |
| return this.stateGeneration; |
| } |
| |
| maybeSetState( |
| tracingSessionWrapper: TracingSessionWrapper, state: RecordingState, |
| stateGeneration: number): void { |
| if (this.tracingSessionWrapper !== tracingSessionWrapper) { |
| return; |
| } |
| if (stateGeneration !== this.stateGeneration) { |
| throw new RecordingError('Recording page state transition out of order.'); |
| } |
| this.setState(state); |
| globals.dispatch(Actions.setRecordingStatus({status: undefined})); |
| globals.rafScheduler.scheduleFullRedraw(); |
| } |
| |
| maybeClearRecordingState(tracingSessionWrapper: TracingSessionWrapper): void { |
| if (this.tracingSessionWrapper === tracingSessionWrapper) { |
| this.clearRecordingState(); |
| } |
| } |
| |
| maybeOnTraceData( |
| tracingSessionWrapper: TracingSessionWrapper, trace: Uint8Array) { |
| if (this.tracingSessionWrapper !== tracingSessionWrapper) { |
| return; |
| } |
| globals.dispatch(Actions.openTraceFromBuffer({ |
| title: 'Recorded trace', |
| buffer: trace.buffer, |
| fileName: `trace_${currentDateHourAndMinute()}${TRACE_SUFFIX}`, |
| })); |
| this.clearRecordingState(); |
| } |
| |
| maybeOnStatus(tracingSessionWrapper: TracingSessionWrapper, message: string) { |
| if (this.tracingSessionWrapper !== tracingSessionWrapper) { |
| return; |
| } |
| // For the 'Recording in progress for 7000ms we don't show a |
| // modal.' |
| if (message.startsWith(RECORDING_IN_PROGRESS)) { |
| globals.dispatch(Actions.setRecordingStatus({status: message})); |
| } else { |
| // For messages such as 'Please allow USB debugging on your |
| // device, which require a user action, we show a modal. |
| showRecordingModal(message); |
| } |
| } |
| |
| maybeOnDisconnect( |
| tracingSessionWrapper: TracingSessionWrapper, errorMessage?: string) { |
| if (this.tracingSessionWrapper !== tracingSessionWrapper) { |
| return; |
| } |
| if (errorMessage) { |
| showRecordingModal(errorMessage); |
| } |
| this.clearRecordingState(); |
| this.onTargetChange(); |
| } |
| |
| maybeOnError( |
| tracingSessionWrapper: TracingSessionWrapper, errorMessage: string) { |
| if (this.tracingSessionWrapper !== tracingSessionWrapper) { |
| return; |
| } |
| showRecordingModal(errorMessage); |
| this.clearRecordingState(); |
| } |
| |
| getTargetInfo(): TargetInfo|undefined { |
| if (!this.target) { |
| return undefined; |
| } |
| return this.target.getInfo(); |
| } |
| |
| canCreateTracingSession() { |
| if (!this.target) { |
| return false; |
| } |
| return this.target.canCreateTracingSession(); |
| } |
| |
| selectTarget(selectedTarget?: RecordingTargetV2) { |
| assertTrue( |
| RecordingState.NO_TARGET <= this.state && |
| this.state < RecordingState.RECORDING); |
| // If the selected target exists and is the same as the previous one, we |
| // don't need to do anything. |
| if (selectedTarget && selectedTarget === this.target) { |
| return; |
| } |
| |
| // We assign the new target and redraw the page. |
| this.target = selectedTarget; |
| |
| if (!this.target) { |
| this.setState(RecordingState.NO_TARGET); |
| globals.rafScheduler.scheduleFullRedraw(); |
| return; |
| } |
| this.setState(RecordingState.TARGET_SELECTED); |
| globals.rafScheduler.scheduleFullRedraw(); |
| |
| this.tracingSessionWrapper = this.createTracingSessionWrapper(this.target); |
| this.tracingSessionWrapper.fetchTargetInfo(); |
| } |
| |
| async addAndroidDevice(): Promise<void> { |
| try { |
| const target = |
| await targetFactoryRegistry.get(ANDROID_WEBUSB_TARGET_FACTORY) |
| .connectNewTarget(); |
| this.selectTarget(target); |
| } catch (e) { |
| if (e instanceof RecordingError) { |
| showRecordingModal(e.message); |
| } else { |
| throw e; |
| } |
| } |
| } |
| |
| onTargetSelection(targetName: string): void { |
| assertTrue( |
| RecordingState.NO_TARGET <= this.state && |
| this.state < RecordingState.RECORDING); |
| const allTargets = targetFactoryRegistry.listTargets(); |
| this.selectTarget(allTargets.find((t) => t.getInfo().name === targetName)); |
| } |
| |
| onStartRecordingPressed(): void { |
| assertTrue(RecordingState.TARGET_INFO_DISPLAYED === this.state); |
| location.href = '#!/record/instructions'; |
| autosaveConfigStore.save(globals.state.recordConfig); |
| |
| const target = this.getTarget(); |
| const targetInfo = target.getInfo(); |
| globals.logging.logEvent( |
| 'Record Trace', `Record trace (${targetInfo.targetType})`); |
| const traceConfig = genTraceConfig(globals.state.recordConfig, targetInfo); |
| |
| this.tracingSessionWrapper = this.createTracingSessionWrapper(target); |
| this.tracingSessionWrapper.start(traceConfig); |
| } |
| |
| onCancel() { |
| assertTrue( |
| RecordingState.AUTH_P2 <= this.state && |
| this.state <= RecordingState.RECORDING); |
| // The 'Cancel' button will only be shown after a `tracingSessionWrapper` |
| // is created. |
| this.getTracingSessionWrapper().cancel(); |
| } |
| |
| onStop() { |
| assertTrue( |
| RecordingState.AUTH_P2 <= this.state && |
| this.state <= RecordingState.RECORDING); |
| // The 'Stop' button will only be shown after a `tracingSessionWrapper` |
| // is created. |
| this.getTracingSessionWrapper().stop(); |
| } |
| |
| async fetchBufferUsage() { |
| assertTrue(this.state >= RecordingState.AUTH_P2); |
| if (!this.tracingSessionWrapper) return; |
| const session = this.tracingSessionWrapper; |
| |
| try { |
| const usage = await session.getTraceBufferUsage(); |
| if (this.tracingSessionWrapper === session) { |
| this.bufferUsagePercentage = usage; |
| } |
| } catch (e) { |
| // We ignore RecordingErrors because they are not necessary for the trace |
| // to be successfully collected. |
| if (!(e instanceof RecordingError)) { |
| throw e; |
| } |
| } |
| // We redraw if: |
| // 1. We received a correct buffer usage value. |
| // 2. We receive a RecordingError. |
| globals.rafScheduler.scheduleFullRedraw(); |
| } |
| |
| initFactories() { |
| assertTrue(this.state <= RecordingState.TARGET_INFO_DISPLAYED); |
| for (const targetFactory of targetFactoryRegistry.listTargetFactories()) { |
| if (targetFactory) { |
| targetFactory.setOnTargetChange(this.onTargetChange.bind(this)); |
| } |
| } |
| |
| if (targetFactoryRegistry.has(ANDROID_WEBSOCKET_TARGET_FACTORY)) { |
| const websocketTargetFactory = |
| targetFactoryRegistry.get(ANDROID_WEBSOCKET_TARGET_FACTORY) as |
| AndroidWebsocketTargetFactory; |
| websocketTargetFactory.tryEstablishWebsocket(DEFAULT_ADB_WEBSOCKET_URL); |
| } |
| if (targetFactoryRegistry.has(HOST_OS_TARGET_FACTORY)) { |
| const websocketTargetFactory = |
| targetFactoryRegistry.get(HOST_OS_TARGET_FACTORY) as |
| HostOsTargetFactory; |
| websocketTargetFactory.tryEstablishWebsocket( |
| DEFAULT_TRACED_WEBSOCKET_URL); |
| } |
| } |
| |
| shouldShowTargetSelection(): boolean { |
| return RecordingState.NO_TARGET <= this.state && |
| this.state < RecordingState.RECORDING; |
| } |
| |
| shouldShowStopCancelButtons(): boolean { |
| return RecordingState.AUTH_P2 <= this.state && |
| this.state <= RecordingState.RECORDING; |
| } |
| |
| private onTargetChange() { |
| const allTargets = targetFactoryRegistry.listTargets(); |
| // If the change happens for an existing target, the controller keeps the |
| // currently selected target in focus. |
| if (this.target && allTargets.includes(this.target)) { |
| globals.rafScheduler.scheduleFullRedraw(); |
| return; |
| } |
| // If the change happens to a new target or the controller does not have a |
| // defined target, the selection process again is run again. |
| this.selectTarget(); |
| } |
| |
| private createTracingSessionWrapper(target: RecordingTargetV2): |
| TracingSessionWrapper { |
| return new TracingSessionWrapper(target, this); |
| } |
| |
| private clearRecordingState(): void { |
| this.bufferUsagePercentage = 0; |
| this.tracingSessionWrapper = undefined; |
| this.setState(RecordingState.TARGET_INFO_DISPLAYED); |
| globals.dispatch(Actions.setRecordingStatus({status: undefined})); |
| // Redrawing because this method has changed the RecordingState, which will |
| // affect the display of the record_page. |
| globals.rafScheduler.scheduleFullRedraw(); |
| } |
| |
| private setState(state: RecordingState) { |
| this.state = state; |
| this.stateGeneration += 1; |
| } |
| |
| private getTarget(): RecordingTargetV2 { |
| assertTrue(RecordingState.TARGET_INFO_DISPLAYED === this.state); |
| return assertExists(this.target); |
| } |
| |
| private getTracingSessionWrapper(): TracingSessionWrapper { |
| assertTrue( |
| RecordingState.ASK_TO_FORCE_P2 <= this.state && |
| this.state <= RecordingState.RECORDING); |
| return assertExists(this.tracingSessionWrapper); |
| } |
| } |