1/*
2 * Copyright 2022, 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 {OnProgressUpdateType} from 'common/function_utils';
18import {PersistentStore} from 'common/persistent_store';
19import {OnRequestSuccessCallback} from './on_request_success_callback';
20import {ConfigMap} from './trace_collection_utils';
21
22export interface Device {
23  [key: string]: DeviceProperties;
24}
25
26export interface DeviceProperties {
27  authorised: boolean;
28  model: string;
29}
30
31export enum ProxyState {
32  ERROR,
33  CONNECTING,
34  NO_PROXY,
35  INVALID_VERSION,
36  UNAUTH,
37  DEVICES,
38  START_TRACE,
39  END_TRACE,
40  LOAD_DATA,
41  STARTING_TRACE,
42}
43
44export enum ProxyEndpoint {
45  DEVICES = '/devices/',
46  START_TRACE = '/start/',
47  END_TRACE = '/end/',
48  ENABLE_CONFIG_TRACE = '/configtrace/',
49  SELECTED_WM_CONFIG_TRACE = '/selectedwmconfigtrace/',
50  SELECTED_SF_CONFIG_TRACE = '/selectedsfconfigtrace/',
51  DUMP = '/dump/',
52  FETCH = '/fetch/',
53  STATUS = '/status/',
54  CHECK_WAYLAND = '/checkwayland/',
55}
56
57// from here, all requests to the proxy are made
58class ProxyRequest {
59  // List of trace we are actively tracing
60  private tracingTraces: string[] | undefined;
61
62  async call(
63    method: string,
64    path: string,
65    onSuccess: OnRequestSuccessCallback | undefined,
66    type?: XMLHttpRequest['responseType'],
67    jsonRequest?: object,
68  ): Promise<void> {
69    return new Promise((resolve) => {
70      const request = new XMLHttpRequest();
71      const client = proxyClient;
72      request.onreadystatechange = async function () {
73        if (this.readyState !== XMLHttpRequest.DONE) {
74          return;
75        }
76        if (this.status === XMLHttpRequest.UNSENT) {
77          client.setState(ProxyState.NO_PROXY);
78          resolve();
79        } else if (this.status === 200) {
80          if (
81            !client.areVersionsCompatible(
82              this.getResponseHeader('Winscope-Proxy-Version'),
83            )
84          ) {
85            client.setState(ProxyState.INVALID_VERSION);
86            resolve();
87          } else if (onSuccess) {
88            try {
89              await onSuccess(this);
90            } catch (err) {
91              console.error(err);
92              proxyClient.setState(
93                ProxyState.ERROR,
94                `Error handling request response:\n${err}\n\n` +
95                  `Request:\n ${request.responseText}`,
96              );
97              resolve();
98            }
99          }
100          resolve();
101        } else if (this.status === 403) {
102          client.setState(ProxyState.UNAUTH);
103          resolve();
104        } else {
105          if (this.responseType === 'text' || !this.responseType) {
106            client.errorText = this.responseText;
107          } else if (this.responseType === 'arraybuffer') {
108            client.errorText = String.fromCharCode.apply(
109              null,
110              new Array(this.response),
111            );
112          }
113          client.setState(ProxyState.ERROR, client.errorText);
114          resolve();
115        }
116      };
117      request.responseType = type || '';
118      request.open(method, client.WINSCOPE_PROXY_URL + path);
119      const lastKey = client.store.get('adb.proxyKey');
120      if (lastKey !== undefined) {
121        client.proxyKey = lastKey;
122      }
123      request.setRequestHeader('Winscope-Token', client.proxyKey);
124      if (jsonRequest) {
125        const json = JSON.stringify(jsonRequest);
126        request.setRequestHeader(
127          'Content-Type',
128          'application/json;charset=UTF-8',
129        );
130        request.send(json);
131      } else {
132        request.send();
133      }
134    });
135  }
136
137  async getDevices() {
138    await proxyRequest.call(
139      'GET',
140      ProxyEndpoint.DEVICES,
141      proxyRequest.onSuccessGetDevices,
142    );
143  }
144
145  async setEnabledConfig(view: ProxyClient, req: string[]) {
146    await proxyRequest.call(
147      'POST',
148      `${ProxyEndpoint.ENABLE_CONFIG_TRACE}${view.selectedDevice}/`,
149      undefined,
150      undefined,
151      req,
152    );
153  }
154
155  async setSelectedConfig(
156    endpoint: ProxyEndpoint,
157    view: ProxyClient,
158    req: ConfigMap,
159  ) {
160    await proxyRequest.call(
161      'POST',
162      `${endpoint}${view.selectedDevice}/`,
163      undefined,
164      undefined,
165      req,
166    );
167  }
168
169  async startTrace(
170    view: ProxyClient,
171    requestedTraces: string[],
172    onSuccessStartTrace: OnRequestSuccessCallback,
173  ) {
174    this.tracingTraces = requestedTraces;
175    await proxyRequest.call(
176      'POST',
177      `${ProxyEndpoint.START_TRACE}${view.selectedDevice}/`,
178      onSuccessStartTrace,
179      undefined,
180      requestedTraces,
181    );
182  }
183
184  async endTrace(
185    view: ProxyClient,
186    progressCallback: OnProgressUpdateType,
187  ): Promise<void> {
188    const requestedTraces = this.tracingTraces;
189    this.tracingTraces = undefined;
190    if (requestedTraces === undefined) {
191      throw Error('Trace not started before stopping');
192    }
193    await proxyRequest.call(
194      'POST',
195      `${ProxyEndpoint.END_TRACE}${view.selectedDevice}/`,
196      async (request: XMLHttpRequest) => {
197        await proxyClient.updateAdbData(
198          requestedTraces,
199          'trace',
200          progressCallback,
201        );
202      },
203    );
204  }
205
206  async keepTraceAlive(
207    view: ProxyClient,
208    onSuccessKeepTraceAlive: OnRequestSuccessCallback,
209  ) {
210    await proxyRequest.call(
211      'GET',
212      `${ProxyEndpoint.STATUS}${view.selectedDevice}/`,
213      onSuccessKeepTraceAlive,
214    );
215  }
216
217  async dumpState(
218    view: ProxyClient,
219    requestedDumps: string[],
220    progressCallback: OnProgressUpdateType,
221  ) {
222    await proxyRequest.call(
223      'POST',
224      `${ProxyEndpoint.DUMP}${view.selectedDevice}/`,
225      async (request: XMLHttpRequest) => {
226        await proxyClient.updateAdbData(
227          requestedDumps,
228          'dump',
229          progressCallback,
230        );
231      },
232      undefined,
233      requestedDumps,
234    );
235  }
236
237  async fetchFiles(dev: string, adbParams: AdbParams): Promise<void> {
238    const files = adbParams.files;
239    const idx = adbParams.idx;
240
241    await proxyRequest.call(
242      'GET',
243      `${ProxyEndpoint.FETCH}${dev}/${files[idx]}/`,
244      this.onSuccessFetchFiles,
245      'arraybuffer',
246    );
247  }
248
249  private onSuccessFetchFiles: OnRequestSuccessCallback = async (
250    request: XMLHttpRequest,
251  ) => {
252    try {
253      const enc = new TextDecoder('utf-8');
254      const resp = enc.decode(request.response);
255      const filesByType = JSON.parse(resp);
256
257      for (const filetype of Object.keys(filesByType)) {
258        const files = filesByType[filetype];
259        for (const encodedFileBuffer of files) {
260          const buffer = Uint8Array.from(atob(encodedFileBuffer), (c) =>
261            c.charCodeAt(0),
262          );
263          const blob = new Blob([buffer]);
264          const newFile = new File([blob], filetype);
265          proxyClient.adbData.push(newFile);
266        }
267      }
268    } catch (error) {
269      proxyClient.setState(ProxyState.ERROR, request.responseText);
270      throw error;
271    }
272  };
273
274  private onSuccessGetDevices: OnRequestSuccessCallback = async (
275    request: XMLHttpRequest,
276  ) => {
277    const client = proxyClient;
278    try {
279      client.devices = JSON.parse(request.responseText);
280      const last = client.store.get('adb.lastDevice');
281      if (last && client.devices[last] && client.devices[last].authorised) {
282        await client.selectDevice(last);
283      } else {
284        client.setState(ProxyState.DEVICES);
285      }
286      if (client.refresh_worker === undefined) {
287        client.refresh_worker = setInterval(client.getDevices, 1000);
288      }
289    } catch (err) {
290      console.error(err);
291      client.errorText = request.responseText;
292      client.setState(ProxyState.ERROR, client.errorText);
293    }
294  };
295}
296export const proxyRequest = new ProxyRequest();
297
298interface AdbParams {
299  files: string[];
300  idx: number;
301  traceType: string;
302}
303
304// stores all the changing variables from proxy and sets up calls from ProxyRequest
305export class ProxyClient {
306  readonly WINSCOPE_PROXY_URL = 'http://localhost:5544';
307  readonly VERSION = '2.1.1';
308  state: ProxyState = ProxyState.CONNECTING;
309  stateChangeListeners: Array<{
310    (param: ProxyState, errorText: string): Promise<void>;
311  }> = [];
312  refresh_worker: NodeJS.Timeout | undefined;
313  devices: Device = {};
314  selectedDevice = '';
315  errorText = '';
316  adbData: File[] = [];
317  proxyKey = '';
318  lastDevice = '';
319  store = new PersistentStore();
320
321  async setState(state: ProxyState, errorText = '') {
322    this.state = state;
323    this.errorText = errorText;
324    for (const listener of this.stateChangeListeners) {
325      await listener(state, errorText);
326    }
327  }
328
329  onProxyChange(fn: (state: ProxyState, errorText: string) => Promise<void>) {
330    this.removeOnProxyChange(fn);
331    this.stateChangeListeners.push(fn);
332  }
333
334  removeOnProxyChange(
335    removeFn: (state: ProxyState, errorText: string) => Promise<void>,
336  ) {
337    this.stateChangeListeners = this.stateChangeListeners.filter(
338      (fn) => fn !== removeFn,
339    );
340  }
341
342  async getDevices() {
343    if (
344      proxyClient.state !== ProxyState.DEVICES &&
345      proxyClient.state !== ProxyState.CONNECTING &&
346      proxyClient.state !== ProxyState.START_TRACE
347    ) {
348      if (proxyClient.refresh_worker !== undefined) {
349        clearInterval(proxyClient.refresh_worker);
350        proxyClient.refresh_worker = undefined;
351      }
352      return;
353    }
354    proxyRequest.getDevices();
355  }
356
357  async selectDevice(device_id: string) {
358    this.selectedDevice = device_id;
359    this.store.add('adb.lastDevice', device_id);
360    this.setState(ProxyState.START_TRACE);
361  }
362
363  async updateAdbData(
364    files: string[],
365    traceType: string,
366    progressCallback: OnProgressUpdateType,
367  ) {
368    for (let idx = 0; idx < files.length; idx++) {
369      const adbParams = {
370        files,
371        idx,
372        traceType,
373      };
374      await proxyRequest.fetchFiles(this.selectedDevice, adbParams);
375      progressCallback((100 * (idx + 1)) / files.length);
376    }
377  }
378
379  areVersionsCompatible(proxyVersion: string | null): boolean {
380    if (!proxyVersion) return false;
381    const [proxyMajor, proxyMinor, proxyPatch] = proxyVersion
382      .split('.')
383      .map((s) => Number(s));
384    const [clientMajor, clientMinor, clientPatch] = this.VERSION.split('.').map(
385      (s) => Number(s),
386    );
387
388    if (proxyMajor !== clientMajor) {
389      return false;
390    }
391
392    if (proxyMinor === clientMinor) {
393      // Check patch number to ensure user has deployed latest bug fixes
394      return proxyPatch >= clientPatch;
395    }
396
397    return proxyMinor > clientMinor;
398  }
399}
400
401export const proxyClient = new ProxyClient();
402