blob: a77edf1944734a717e809cd94cda2eb2405b1a88 [file] [log] [blame]
// 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);
}
}