• 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 {RECORDING_V2_FLAG} from '../../../core/feature_flags';
16import {
17  OnTargetChangeCallback,
18  RecordingTargetV2,
19  TargetFactory,
20} from '../recording_interfaces_v2';
21import {
22  buildAbdWebsocketCommand,
23  WEBSOCKET_CLOSED_ABNORMALLY_CODE,
24} from '../recording_utils';
25import {targetFactoryRegistry} from '../target_factory_registry';
26import {AndroidWebsocketTarget} from '../targets/android_websocket_target';
27
28export const ANDROID_WEBSOCKET_TARGET_FACTORY = 'AndroidWebsocketTargetFactory';
29
30// https://cs.android.com/android/platform/superproject/+/main:packages/
31// modules/adb/SERVICES.TXT;l=135
32const PREFIX_LENGTH = 4;
33
34// information received over the websocket regarding a device
35// Ex: "${serialNumber} authorized"
36interface ListedDevice {
37  serialNumber: string;
38  // Full list of connection states can be seen at:
39  // go/codesearch/android/packages/modules/adb/adb.cpp;l=115-139
40  connectionState: string;
41}
42
43// Contains the result of parsing a message received over websocket.
44interface ParsingResult {
45  listedDevices: ListedDevice[];
46  messageRemainder: string;
47}
48
49// We issue the command 'track-devices' which will encode the short form
50// of the device:
51// see go/codesearch/android/packages/modules/adb/services.cpp;l=244-245
52// and go/codesearch/android/packages/modules/adb/transport.cpp;l=1417-1420
53// Therefore a line will contain solely the device serial number and the
54// connectionState (and no other properties).
55function parseListedDevice(line: string): ListedDevice | undefined {
56  const parts = line.split('\t');
57  if (parts.length === 2) {
58    return {
59      serialNumber: parts[0],
60      connectionState: parts[1],
61    };
62  }
63  return undefined;
64}
65
66export function parseWebsocketResponse(message: string): ParsingResult {
67  // A response we receive on the websocket contains multiple messages:
68  // "{m1.length}{m1.payload}{m2.length}{m2.payload}..."
69  // where m1, m2 are messages
70  // Each message has the form:
71  // "{message.length}SN1\t${connectionState1}\nSN2\t${connectionState2}\n..."
72  // where SN1, SN2 are device serial numbers
73  // and connectionState1, connectionState2 are adb connection states, created
74  // here: go/codesearch/android/packages/modules/adb/adb.cpp;l=115-139
75  const latestStatusByDevice: Map<string, string> = new Map();
76  while (message.length >= PREFIX_LENGTH) {
77    const payloadLength = parseInt(message.substring(0, PREFIX_LENGTH), 16);
78    const prefixAndPayloadLength = PREFIX_LENGTH + payloadLength;
79    if (message.length < prefixAndPayloadLength) {
80      break;
81    }
82
83    const payload = message.substring(PREFIX_LENGTH, prefixAndPayloadLength);
84    for (const line of payload.split('\n')) {
85      const listedDevice = parseListedDevice(line);
86      if (listedDevice) {
87        // We overwrite previous states for the same serial number.
88        latestStatusByDevice.set(
89          listedDevice.serialNumber,
90          listedDevice.connectionState,
91        );
92      }
93    }
94    message = message.substring(prefixAndPayloadLength);
95  }
96  const listedDevices: ListedDevice[] = [];
97  for (const [
98    serialNumber,
99    connectionState,
100  ] of latestStatusByDevice.entries()) {
101    listedDevices.push({serialNumber, connectionState});
102  }
103  return {listedDevices, messageRemainder: message};
104}
105
106export class WebsocketConnection {
107  private targets: Map<string, AndroidWebsocketTarget> = new Map<
108    string,
109    AndroidWebsocketTarget
110  >();
111  private pendingData: string = '';
112
113  constructor(
114    private websocket: WebSocket,
115    private maybeClearConnection: (connection: WebsocketConnection) => void,
116    private onTargetChange: OnTargetChangeCallback,
117  ) {
118    this.initWebsocket();
119  }
120
121  listTargets(): RecordingTargetV2[] {
122    return Array.from(this.targets.values());
123  }
124
125  // Setup websocket callbacks.
126  initWebsocket(): void {
127    this.websocket.onclose = (ev: CloseEvent) => {
128      if (ev.code === WEBSOCKET_CLOSED_ABNORMALLY_CODE) {
129        console.info(
130          `It's safe to ignore the 'WebSocket connection to ${this.websocket.url} error above, if present. It occurs when ` +
131            'checking the connection to the local Websocket server.',
132        );
133      }
134      this.maybeClearConnection(this);
135      this.close();
136    };
137
138    // once the websocket is open, we start tracking the devices
139    this.websocket.onopen = () => {
140      this.websocket.send(buildAbdWebsocketCommand('host:track-devices'));
141    };
142
143    this.websocket.onmessage = async (evt: MessageEvent) => {
144      let resp = await evt.data.text();
145      if (resp.substr(0, 4) === 'OKAY') {
146        resp = resp.substr(4);
147      }
148      const parsingResult = parseWebsocketResponse(this.pendingData + resp);
149      this.pendingData = parsingResult.messageRemainder;
150      this.trackDevices(parsingResult.listedDevices);
151    };
152  }
153
154  close() {
155    // The websocket connection may have already been closed by the websocket
156    // server.
157    if (this.websocket.readyState === this.websocket.OPEN) {
158      this.websocket.close();
159    }
160    // Disconnect all the targets, to release all the websocket connections that
161    // they hold and end their tracing sessions.
162    for (const target of this.targets.values()) {
163      target.disconnect();
164    }
165    this.targets.clear();
166
167    // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
168    if (this.onTargetChange) {
169      this.onTargetChange();
170    }
171  }
172
173  getUrl() {
174    return this.websocket.url;
175  }
176
177  // Handle messages received over the websocket regarding devices connecting
178  // or disconnecting.
179  private trackDevices(listedDevices: ListedDevice[]) {
180    // When a SN becomes offline, we should remove it from the list
181    // of targets. Otherwise, we should check if it maps to a target. If the
182    // SN does not map to a target, we should create one for it.
183    let targetsUpdated = false;
184    for (const listedDevice of listedDevices) {
185      if (['offline', 'unknown'].includes(listedDevice.connectionState)) {
186        const target = this.targets.get(listedDevice.serialNumber);
187        if (target === undefined) {
188          continue;
189        }
190        target.disconnect();
191        this.targets.delete(listedDevice.serialNumber);
192        targetsUpdated = true;
193      } else if (!this.targets.has(listedDevice.serialNumber)) {
194        this.targets.set(
195          listedDevice.serialNumber,
196          new AndroidWebsocketTarget(
197            listedDevice.serialNumber,
198            this.websocket.url,
199            this.onTargetChange,
200          ),
201        );
202        targetsUpdated = true;
203      }
204    }
205
206    // Notify the calling code that the list of targets has been updated.
207    if (targetsUpdated) {
208      this.onTargetChange();
209    }
210  }
211}
212
213export class AndroidWebsocketTargetFactory implements TargetFactory {
214  readonly kind = ANDROID_WEBSOCKET_TARGET_FACTORY;
215  private onTargetChange: OnTargetChangeCallback = () => {};
216  private websocketConnection?: WebsocketConnection;
217
218  getName() {
219    return 'Android Websocket';
220  }
221
222  listTargets(): RecordingTargetV2[] {
223    return this.websocketConnection
224      ? this.websocketConnection.listTargets()
225      : [];
226  }
227
228  listRecordingProblems(): string[] {
229    return [];
230  }
231
232  // This interface method can not return anything because a websocket target
233  // can not be created on user input. It can only be created when the websocket
234  // server detects a new target.
235  connectNewTarget(): Promise<RecordingTargetV2> {
236    return Promise.reject(
237      new Error(
238        'The websocket can only automatically connect targets ' +
239          'when they become available.',
240      ),
241    );
242  }
243
244  tryEstablishWebsocket(websocketUrl: string) {
245    if (this.websocketConnection) {
246      if (this.websocketConnection.getUrl() === websocketUrl) {
247        return;
248      } else {
249        this.websocketConnection.close();
250      }
251    }
252
253    const websocket = new WebSocket(websocketUrl);
254    this.websocketConnection = new WebsocketConnection(
255      websocket,
256      this.maybeClearConnection,
257      this.onTargetChange,
258    );
259  }
260
261  maybeClearConnection(connection: WebsocketConnection): void {
262    if (this.websocketConnection === connection) {
263      this.websocketConnection = undefined;
264    }
265  }
266
267  setOnTargetChange(onTargetChange: OnTargetChangeCallback) {
268    this.onTargetChange = onTargetChange;
269  }
270}
271
272// We only want to instantiate this class if Recording V2 is enabled.
273if (RECORDING_V2_FLAG.get()) {
274  targetFactoryRegistry.register(new AndroidWebsocketTargetFactory());
275}
276