• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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