• 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 {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