• 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 '../../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/+/master: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, listedDevice.connectionState);
90      }
91    }
92    message = message.substring(prefixAndPayloadLength);
93  }
94  const listedDevices: ListedDevice[] = [];
95  for (const [serialNumber, connectionState] of latestStatusByDevice
96           .entries()) {
97    listedDevices.push({serialNumber, connectionState});
98  }
99  return {listedDevices, messageRemainder: message};
100}
101
102export class WebsocketConnection {
103  private targets: Map<string, AndroidWebsocketTarget> =
104      new Map<string, AndroidWebsocketTarget>();
105  private pendingData: string = '';
106
107  constructor(
108      private websocket: WebSocket,
109      private maybeClearConnection: (connection: WebsocketConnection) => void,
110      private onTargetChange: OnTargetChangeCallback) {
111    this.initWebsocket();
112  }
113
114  listTargets(): RecordingTargetV2[] {
115    return Array.from(this.targets.values());
116  }
117
118  // Setup websocket callbacks.
119  initWebsocket(): void {
120    this.websocket.onclose = (ev: CloseEvent) => {
121      if (ev.code === WEBSOCKET_CLOSED_ABNORMALLY_CODE) {
122        console.info(
123            `It's safe to ignore the 'WebSocket connection to ${
124                this.websocket.url} error above, if present. It occurs when ` +
125            'checking the connection to the local Websocket server.');
126      }
127      this.maybeClearConnection(this);
128      this.close();
129    };
130
131    // once the websocket is open, we start tracking the devices
132    this.websocket.onopen = () => {
133      this.websocket.send(buildAbdWebsocketCommand('host:track-devices'));
134    };
135
136    this.websocket.onmessage = async (evt: MessageEvent) => {
137      let resp = await evt.data.text();
138      if (resp.substr(0, 4) === 'OKAY') {
139        resp = resp.substr(4);
140      }
141      const parsingResult = parseWebsocketResponse(this.pendingData + resp);
142      this.pendingData = parsingResult.messageRemainder;
143      this.trackDevices(parsingResult.listedDevices);
144    };
145  }
146
147  close() {
148    // The websocket connection may have already been closed by the websocket
149    // server.
150    if (this.websocket.readyState === this.websocket.OPEN) {
151      this.websocket.close();
152    }
153    // Disconnect all the targets, to release all the websocket connections that
154    // they hold and end their tracing sessions.
155    for (const target of this.targets.values()) {
156      target.disconnect();
157    }
158    this.targets.clear();
159    if (this.onTargetChange) {
160      this.onTargetChange();
161    }
162  }
163
164  getUrl() {
165    return this.websocket.url;
166  }
167
168  // Handle messages received over the websocket regarding devices connecting
169  // or disconnecting.
170  private trackDevices(listedDevices: ListedDevice[]) {
171    // When a SN becomes offline, we should remove it from the list
172    // of targets. Otherwise, we should check if it maps to a target. If the
173    // SN does not map to a target, we should create one for it.
174    let targetsUpdated = false;
175    for (const listedDevice of listedDevices) {
176      if (['offline', 'unknown'].includes(listedDevice.connectionState)) {
177        const target = this.targets.get(listedDevice.serialNumber);
178        if (target === undefined) {
179          continue;
180        }
181        target.disconnect();
182        this.targets.delete(listedDevice.serialNumber);
183        targetsUpdated = true;
184      } else if (!this.targets.has(listedDevice.serialNumber)) {
185        this.targets.set(
186            listedDevice.serialNumber,
187            new AndroidWebsocketTarget(
188                listedDevice.serialNumber,
189                this.websocket.url,
190                this.onTargetChange));
191        targetsUpdated = true;
192      }
193    }
194
195    // Notify the calling code that the list of targets has been updated.
196    if (targetsUpdated) {
197      this.onTargetChange();
198    }
199  }
200}
201
202export class AndroidWebsocketTargetFactory implements TargetFactory {
203  readonly kind = ANDROID_WEBSOCKET_TARGET_FACTORY;
204  private onTargetChange: OnTargetChangeCallback = () => {};
205  private websocketConnection?: WebsocketConnection;
206
207  getName() {
208    return 'Android Websocket';
209  }
210
211  listTargets(): RecordingTargetV2[] {
212    return this.websocketConnection ? this.websocketConnection.listTargets() :
213                                      [];
214  }
215
216  listRecordingProblems(): string[] {
217    return [];
218  }
219
220  // This interface method can not return anything because a websocket target
221  // can not be created on user input. It can only be created when the websocket
222  // server detects a new target.
223  connectNewTarget(): Promise<RecordingTargetV2> {
224    return Promise.reject(new Error(
225        'The websocket can only automatically connect targets ' +
226        'when they become available.'));
227  }
228
229  tryEstablishWebsocket(websocketUrl: string) {
230    if (this.websocketConnection) {
231      if (this.websocketConnection.getUrl() === websocketUrl) {
232        return;
233      } else {
234        this.websocketConnection.close();
235      }
236    }
237
238    const websocket = new WebSocket(websocketUrl);
239    this.websocketConnection = new WebsocketConnection(
240        websocket, this.maybeClearConnection, this.onTargetChange);
241  }
242
243  maybeClearConnection(connection: WebsocketConnection): void {
244    if (this.websocketConnection === connection) {
245      this.websocketConnection = undefined;
246    }
247  }
248
249  setOnTargetChange(onTargetChange: OnTargetChangeCallback) {
250    this.onTargetChange = onTargetChange;
251  }
252}
253
254// We only want to instantiate this class if Recording V2 is enabled.
255if (RECORDING_V2_FLAG.get()) {
256  targetFactoryRegistry.register(new AndroidWebsocketTargetFactory());
257}
258