xref: /aosp_15_r20/development/tools/winscope/src/trace_collection/proxy_connection.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
1/*
2 * Copyright 2024, The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {assertDefined} from 'common/assert_utils';
18import {FunctionUtils} from 'common/function_utils';
19import {
20  HttpRequest,
21  HttpRequestHeaderType,
22  HttpRequestStatus,
23  HttpResponse,
24} from 'common/http_request';
25import {PersistentStore} from 'common/persistent_store';
26import {TimeUtils} from 'common/time_utils';
27import {UserNotifier} from 'common/user_notifier';
28import {Analytics} from 'logging/analytics';
29import {
30  ProxyTracingErrors,
31  ProxyTracingWarnings,
32} from 'messaging/user_warnings';
33import {AdbConnection, OnRequestSuccessCallback} from './adb_connection';
34import {AdbDevice} from './adb_device';
35import {ConnectionState} from './connection_state';
36import {ProxyEndpoint} from './proxy_endpoint';
37import {TraceRequest} from './trace_request';
38
39export class ProxyConnection extends AdbConnection {
40  static readonly VERSION = '4.0.8';
41  static readonly WINSCOPE_PROXY_URL = 'http://localhost:5544';
42
43  private static readonly MULTI_DISPLAY_SCREENRECORD_VERSION = '1.4';
44
45  private readonly store = new PersistentStore();
46  private readonly storeKeySecurityToken = 'adb.proxyKey';
47
48  private state: ConnectionState = ConnectionState.CONNECTING;
49  private errorText = '';
50  private securityToken = '';
51  private devices: AdbDevice[] = [];
52  selectedDevice: AdbDevice | undefined;
53  private requestedTraces: TraceRequest[] = [];
54  private adbData: File[] = [];
55  private keepTraceAliveWorker: number | undefined;
56  private refreshDevicesWorker: number | undefined;
57  private detectStateChangeInUi: () => Promise<void> =
58    FunctionUtils.DO_NOTHING_ASYNC;
59  private availableTracesChangeCallback: (traces: string[]) => void =
60    FunctionUtils.DO_NOTHING;
61  private devicesChangeCallback: (devices: AdbDevice[]) => void =
62    FunctionUtils.DO_NOTHING;
63
64  async initialize(
65    detectStateChangeInUi: () => Promise<void>,
66    availableTracesChangeCallback: (traces: string[]) => void,
67    devicesChangeCallback: (devices: AdbDevice[]) => void,
68  ): Promise<void> {
69    this.detectStateChangeInUi = detectStateChangeInUi;
70    this.availableTracesChangeCallback = availableTracesChangeCallback;
71    this.devicesChangeCallback = devicesChangeCallback;
72
73    const urlParams = new URLSearchParams(window.location.search);
74    if (urlParams.has('token')) {
75      this.securityToken = assertDefined(urlParams.get('token'));
76    } else {
77      this.securityToken = this.store.get(this.storeKeySecurityToken) ?? '';
78    }
79    await this.setState(ConnectionState.CONNECTING);
80  }
81
82  async restartConnection(): Promise<void> {
83    await this.setState(ConnectionState.CONNECTING);
84  }
85
86  setSecurityToken(token: string) {
87    if (token.length > 0) {
88      this.securityToken = token;
89      this.store.add(this.storeKeySecurityToken, token);
90    }
91  }
92
93  getDevices(): AdbDevice[] {
94    return this.devices;
95  }
96
97  getState(): ConnectionState {
98    return this.state;
99  }
100
101  getErrorText(): string {
102    return this.errorText;
103  }
104
105  onDestroy() {
106    window.clearInterval(this.refreshDevicesWorker);
107    this.refreshDevicesWorker = undefined;
108    window.clearInterval(this.keepTraceAliveWorker);
109    this.keepTraceAliveWorker = undefined;
110  }
111
112  async startTrace(
113    device: AdbDevice,
114    requestedTraces: TraceRequest[],
115  ): Promise<void> {
116    if (requestedTraces.length === 0) {
117      throw new Error('No traces requested');
118    }
119    this.updateMediaBasedConfig(requestedTraces);
120    this.selectedDevice = device;
121    this.requestedTraces = requestedTraces;
122    await this.setState(ConnectionState.STARTING_TRACE);
123  }
124
125  async endTrace() {
126    if (this.requestedTraces.length === 0) {
127      throw new Error('Trace not started before stopping');
128    }
129    await this.setState(ConnectionState.ENDING_TRACE);
130    this.requestedTraces = [];
131  }
132
133  async dumpState(
134    device: AdbDevice,
135    requestedDumps: TraceRequest[],
136  ): Promise<void> {
137    if (requestedDumps.length === 0) {
138      throw new Error('No dumps requested');
139    }
140    this.selectedDevice = device;
141    this.updateMediaBasedConfig(requestedDumps);
142    this.requestedTraces = requestedDumps;
143    await this.setState(ConnectionState.DUMPING_STATE);
144  }
145
146  private updateMediaBasedConfig(requestedConfig: TraceRequest[]) {
147    requestedConfig.forEach((req) => {
148      const displayConfig = req.config.find((c) => c.key === 'displays');
149      if (displayConfig?.value) {
150        if (Array.isArray(displayConfig.value)) {
151          displayConfig.value = displayConfig.value.map((display) => {
152            if (display[0] === '"') {
153              return display.split('"')[2].trim();
154            }
155            return display;
156          });
157        } else {
158          if (displayConfig.value[0] === '"') {
159            displayConfig.value = displayConfig.value.split('"')[2].trim();
160          }
161        }
162      }
163    });
164  }
165
166  async fetchLastTracingSessionData(device: AdbDevice): Promise<File[]> {
167    this.adbData = [];
168    this.selectedDevice = device;
169    await this.setState(ConnectionState.LOADING_DATA);
170    this.selectedDevice = undefined;
171    return this.adbData;
172  }
173
174  private async updateAdbData(device: AdbDevice) {
175    await this.getFromProxy(
176      `${ProxyEndpoint.FETCH}${device.id}/`,
177      this.onSuccessFetchFiles,
178      'arraybuffer',
179    );
180    if (this.adbData.length === 0) {
181      Analytics.Proxy.logNoFilesFound();
182    }
183  }
184
185  private async onConnectionStateChange(newState: ConnectionState) {
186    await this.detectStateChangeInUi();
187
188    switch (newState) {
189      case ConnectionState.ERROR:
190        Analytics.Error.logProxyError(this.errorText);
191        return;
192
193      case ConnectionState.CONNECTING:
194        await this.requestDevices();
195        return;
196
197      case ConnectionState.IDLE:
198        {
199          const isWaylandAvailable = await this.isWaylandAvailable();
200          if (isWaylandAvailable) {
201            this.availableTracesChangeCallback(['wayland_trace']);
202          }
203        }
204        return;
205
206      case ConnectionState.STARTING_TRACE:
207        await this.postToProxy(
208          `${ProxyEndpoint.START_TRACE}${
209            assertDefined(this.selectedDevice).id
210          }/`,
211          (response: HttpResponse) => {
212            this.tryProcessWarnings(response);
213            this.keepTraceAlive();
214          },
215          this.requestedTraces,
216        );
217        // TODO(b/330118129): identify source of additional start latency that affects some traces
218        await TimeUtils.sleepMs(1000); // 1s timeout ensures SR fully started
219        if (this.getState() === ConnectionState.STARTING_TRACE) {
220          this.setState(ConnectionState.TRACING);
221        }
222        return;
223
224      case ConnectionState.ENDING_TRACE:
225        await this.postToProxy(
226          `${ProxyEndpoint.END_TRACE}${assertDefined(this.selectedDevice).id}/`,
227          (response: HttpResponse) => {
228            const errors = JSON.parse(response.body);
229            if (Array.isArray(errors) && errors.length > 0) {
230              const processedErrors: string[] = errors.map((error: string) => {
231                const processed = error
232                  .replace("b'", "'")
233                  .replace('\\n', '')
234                  .replace(
235                    'please check your display state',
236                    'please check your display state (must be on at start of trace)',
237                  );
238                return processed;
239              });
240              UserNotifier.add(new ProxyTracingErrors(processedErrors));
241            }
242          },
243        );
244        return;
245
246      case ConnectionState.DUMPING_STATE:
247        await this.postToProxy(
248          `${ProxyEndpoint.DUMP}${assertDefined(this.selectedDevice).id}/`,
249          (response: HttpResponse) => this.tryProcessWarnings(response),
250          this.requestedTraces,
251        );
252        return;
253
254      case ConnectionState.LOADING_DATA:
255        if (this.selectedDevice === undefined) {
256          throw new Error('No device selected');
257        }
258        await this.updateAdbData(assertDefined(this.selectedDevice));
259        return;
260
261      default:
262      // do nothing
263    }
264  }
265
266  private tryProcessWarnings(response: HttpResponse) {
267    try {
268      const warnings = JSON.parse(response.body);
269      if (Array.isArray(warnings) && warnings.length > 0) {
270        UserNotifier.add(new ProxyTracingWarnings(warnings)).notify();
271      }
272    } catch {
273      // do nothing - warnings unavailable
274    }
275  }
276
277  private async keepTraceAlive() {
278    const state = this.getState();
279    if (
280      state !== ConnectionState.STARTING_TRACE &&
281      state !== ConnectionState.TRACING
282    ) {
283      window.clearInterval(this.keepTraceAliveWorker);
284      this.keepTraceAliveWorker = undefined;
285      return;
286    }
287
288    await this.getFromProxy(
289      `${ProxyEndpoint.STATUS}${assertDefined(this.selectedDevice).id}/`,
290      async (request: HttpResponse) => {
291        if (request.text !== 'True') {
292          window.clearInterval(this.keepTraceAliveWorker);
293          this.keepTraceAliveWorker = undefined;
294          await this.endTrace();
295          if (this.state === ConnectionState.ENDING_TRACE) {
296            await this.setState(ConnectionState.TRACE_TIMEOUT);
297          }
298        } else if (this.keepTraceAliveWorker === undefined) {
299          this.keepTraceAliveWorker = window.setInterval(
300            () => this.keepTraceAlive(),
301            1000,
302          );
303        }
304      },
305    );
306  }
307
308  private async setState(state: ConnectionState, errorText = '') {
309    const connectedStates = [
310      ConnectionState.IDLE,
311      ConnectionState.STARTING_TRACE,
312      ConnectionState.TRACING,
313      ConnectionState.ENDING_TRACE,
314      ConnectionState.DUMPING_STATE,
315      ConnectionState.LOADING_DATA,
316    ];
317    if (
318      state === ConnectionState.NOT_FOUND &&
319      connectedStates.includes(this.state)
320    ) {
321      Analytics.Proxy.logServerNotFound();
322    }
323    this.state = state;
324    this.errorText = errorText;
325    await this.onConnectionStateChange(state);
326  }
327
328  private async requestDevices() {
329    if (
330      this.state !== ConnectionState.IDLE &&
331      this.state !== ConnectionState.CONNECTING
332    ) {
333      if (this.refreshDevicesWorker !== undefined) {
334        window.clearInterval(this.refreshDevicesWorker);
335        this.refreshDevicesWorker = undefined;
336      }
337      return;
338    }
339
340    await this.getFromProxy(ProxyEndpoint.DEVICES, this.onSuccessGetDevices);
341  }
342
343  private onSuccessGetDevices: OnRequestSuccessCallback = async (
344    resp: HttpResponse,
345  ) => {
346    try {
347      const devices = JSON.parse(resp.text);
348      this.devices = Object.keys(devices).map((deviceId) => {
349        return {
350          id: deviceId,
351          authorized: devices[deviceId].authorized,
352          model: devices[deviceId].model,
353          displays: devices[deviceId].displays.map((display: string) => {
354            const parts = display.split(' ').slice(1);
355            const displayNameStartIndex = parts.findIndex((part) =>
356              part.includes('displayName'),
357            );
358            if (displayNameStartIndex !== -1) {
359              const displayName = parts
360                .slice(displayNameStartIndex)
361                .join(' ')
362                .slice(12);
363              if (displayName.length > 2) {
364                return [displayName]
365                  .concat(parts.slice(0, displayNameStartIndex))
366                  .join(' ');
367              }
368            }
369            return parts.join(' ');
370          }),
371          multiDisplayScreenRecordingAvailable:
372            devices[deviceId].screenrecord_version >=
373            ProxyConnection.MULTI_DISPLAY_SCREENRECORD_VERSION,
374        };
375      });
376      this.devicesChangeCallback(this.devices);
377      if (this.refreshDevicesWorker === undefined) {
378        this.refreshDevicesWorker = window.setInterval(
379          () => this.requestDevices(),
380          1000,
381        );
382      }
383      if (this.state === ConnectionState.CONNECTING) {
384        this.setState(ConnectionState.IDLE);
385      } else if (this.state === ConnectionState.IDLE) {
386        this.detectStateChangeInUi();
387      }
388    } catch (err) {
389      this.setState(
390        ConnectionState.ERROR,
391        `Could not find devices. Received:\n${resp.text}`,
392      );
393    }
394  };
395
396  private onSuccessFetchFiles: OnRequestSuccessCallback = async (
397    httpResponse: HttpResponse,
398  ) => {
399    try {
400      const enc = new TextDecoder('utf-8');
401      const resp = enc.decode(httpResponse.body);
402      const filesByType = JSON.parse(resp);
403
404      for (const filetype of Object.keys(filesByType)) {
405        const files = filesByType[filetype];
406        for (const encodedFileBuffer of files) {
407          const buffer = Uint8Array.from(window.atob(encodedFileBuffer), (c) =>
408            c.charCodeAt(0),
409          );
410          const blob = new Blob([buffer]);
411          const newFile = new File([blob], filetype);
412          this.adbData.push(newFile);
413        }
414      }
415    } catch (error) {
416      this.setState(
417        ConnectionState.ERROR,
418        `Could not fetch files. Received:\n${httpResponse.text}`,
419      );
420    }
421  };
422
423  private isWaylandAvailable(): Promise<boolean> {
424    return new Promise((resolve) => {
425      this.getFromProxy(
426        ProxyEndpoint.CHECK_WAYLAND,
427        (request: HttpResponse) => {
428          resolve(request.text === 'true');
429        },
430      );
431    });
432  }
433
434  private async getFromProxy(
435    path: string,
436    onSuccess: OnRequestSuccessCallback,
437    type?: XMLHttpRequest['responseType'],
438  ) {
439    const response = await HttpRequest.get(
440      this.makeRequestPath(path),
441      this.getSecurityTokenHeader(),
442      type,
443    );
444    await this.processProxyResponse(response, onSuccess);
445  }
446
447  private async postToProxy(
448    path: string,
449    onSuccess: OnRequestSuccessCallback,
450    jsonRequest?: object,
451  ) {
452    const response = await HttpRequest.post(
453      this.makeRequestPath(path),
454      this.getSecurityTokenHeader(),
455      jsonRequest,
456    );
457    await this.processProxyResponse(response, onSuccess);
458  }
459
460  private async processProxyResponse(
461    response: HttpResponse,
462    onSuccess: OnRequestSuccessCallback,
463  ) {
464    if (
465      response.status === HttpRequestStatus.SUCCESS &&
466      !this.isVersionCompatible(response)
467    ) {
468      await this.setState(ConnectionState.INVALID_VERSION);
469      return;
470    }
471    const adbResponse = await this.processHttpResponse(response, onSuccess);
472    if (adbResponse !== undefined) {
473      await this.setState(adbResponse.newState, adbResponse.errorMsg);
474    }
475  }
476
477  private isVersionCompatible(req: HttpResponse): boolean {
478    const proxyVersion = req.getHeader('Winscope-Proxy-Version');
479    if (!proxyVersion) return false;
480    const [proxyMajor, proxyMinor, proxyPatch] = proxyVersion
481      .split('.')
482      .map((s) => Number(s));
483    const [clientMajor, clientMinor, clientPatch] =
484      ProxyConnection.VERSION.split('.').map((s) => Number(s));
485
486    if (proxyMajor !== clientMajor) {
487      return false;
488    }
489
490    if (proxyMinor === clientMinor) {
491      // Check patch number to ensure user has deployed latest bug fixes
492      return proxyPatch >= clientPatch;
493    }
494
495    return proxyMinor > clientMinor;
496  }
497
498  private getSecurityTokenHeader(): HttpRequestHeaderType {
499    const lastKey = this.store.get(this.storeKeySecurityToken);
500    if (lastKey !== undefined) {
501      this.securityToken = lastKey;
502    }
503    return [['Winscope-Token', this.securityToken]];
504  }
505
506  private makeRequestPath(path: string): string {
507    return ProxyConnection.WINSCOPE_PROXY_URL + path;
508  }
509}
510