• 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 {_TextDecoder, _TextEncoder} from 'custom_utils';
16
17import {defer, Deferred} from '../../base/deferred';
18import {assertExists, assertFalse, assertTrue} from '../../base/logging';
19import {CmdType} from '../../controller/adb_interfaces';
20
21import {AdbConnectionImpl} from './adb_connection_impl';
22import {AdbKeyManager, maybeStoreKey} from './auth/adb_key_manager';
23import {
24  RecordingError,
25  wrapRecordingError,
26} from './recording_error_handling';
27import {
28  ByteStream,
29  OnStreamCloseCallback,
30  OnStreamDataCallback,
31} from './recording_interfaces_v2';
32import {ALLOW_USB_DEBUGGING, findInterfaceAndEndpoint} from './recording_utils';
33
34const textEncoder = new _TextEncoder();
35const textDecoder = new _TextDecoder();
36
37export const VERSION_WITH_CHECKSUM = 0x01000000;
38export const VERSION_NO_CHECKSUM = 0x01000001;
39export const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
40
41export enum AdbState {
42  DISCONNECTED = 0,
43  // Authentication steps, see AdbConnectionOverWebUsb's handleAuthentication().
44  AUTH_STARTED = 1,
45  AUTH_WITH_PRIVATE = 2,
46  AUTH_WITH_PUBLIC = 3,
47
48  CONNECTED = 4,
49}
50
51enum AuthCmd {
52  TOKEN = 1,
53  SIGNATURE = 2,
54  RSAPUBLICKEY = 3,
55}
56
57function generateChecksum(data: Uint8Array): number {
58  let res = 0;
59  for (let i = 0; i < data.byteLength; i++) res += data[i];
60  return res & 0xFFFFFFFF;
61}
62
63// Message to be written to the adb connection. Contains the message itself
64// and the corresponding stream identifier.
65interface WriteQueueElement {
66  message: Uint8Array;
67  localStreamId: number;
68}
69
70export class AdbConnectionOverWebusb extends AdbConnectionImpl {
71  private state: AdbState = AdbState.DISCONNECTED;
72  private connectingStreams = new Map<number, Deferred<AdbOverWebusbStream>>();
73  private streams = new Set<AdbOverWebusbStream>();
74  private maxPayload = DEFAULT_MAX_PAYLOAD_BYTES;
75  private writeInProgress = false;
76  private writeQueue: WriteQueueElement[] = [];
77
78  // Devices after Dec 2017 don't use checksum. This will be auto-detected
79  // during the connection.
80  private useChecksum = true;
81
82  private lastStreamId = 0;
83  private usbInterfaceNumber?: number;
84  private usbReadEndpoint = -1;
85  private usbWriteEpEndpoint = -1;
86  private isUsbReceiveLoopRunning = false;
87
88  private pendingConnPromises: Array<Deferred<void>> = [];
89
90  // We use a key pair for authenticating with the device, which we do in
91  // two ways:
92  // - Firstly, signing with the private key.
93  // - Secondly, sending over the public key (at which point the device asks the
94  //   user for permissions).
95  // Once we've sent the public key, for future recordings we only need to
96  // sign with the private key, so the user doesn't need to give permissions
97  // again.
98  constructor(private device: USBDevice, private keyManager: AdbKeyManager) {
99    super();
100  }
101
102
103  shell(cmd: string): Promise<AdbOverWebusbStream> {
104    return this.openStream('shell:' + cmd);
105  }
106
107  connectSocket(path: string): Promise<AdbOverWebusbStream> {
108    return this.openStream(path);
109  }
110
111  async canConnectWithoutContention(): Promise<boolean> {
112    await this.device.open();
113    const usbInterfaceNumber = await this.setupUsbInterface();
114    try {
115      await this.device.claimInterface(usbInterfaceNumber);
116      await this.device.releaseInterface(usbInterfaceNumber);
117      return true;
118    } catch (e) {
119      return false;
120    }
121  }
122
123  protected async openStream(destination: string):
124      Promise<AdbOverWebusbStream> {
125    const streamId = ++this.lastStreamId;
126    const connectingStream = defer<AdbOverWebusbStream>();
127    this.connectingStreams.set(streamId, connectingStream);
128    // We create the stream before trying to establish the connection, so
129    // that if we fail to connect, we will reject the connecting stream.
130    await this.ensureConnectionEstablished();
131    await this.sendMessage('OPEN', streamId, 0, destination);
132    return connectingStream;
133  }
134
135  private async ensureConnectionEstablished(): Promise<void> {
136    if (this.state === AdbState.CONNECTED) {
137      return;
138    }
139
140    if (this.state === AdbState.DISCONNECTED) {
141      await this.device.open();
142      if (!(await this.canConnectWithoutContention())) {
143        await this.device.reset();
144      }
145      const usbInterfaceNumber = await this.setupUsbInterface();
146      await this.device.claimInterface(usbInterfaceNumber);
147    }
148
149    await this.startAdbAuth();
150    if (!this.isUsbReceiveLoopRunning) {
151      this.usbReceiveLoop();
152    }
153    const connPromise = defer<void>();
154    this.pendingConnPromises.push(connPromise);
155    await connPromise;
156  }
157
158  private async setupUsbInterface(): Promise<number> {
159    const interfaceAndEndpoint = findInterfaceAndEndpoint(this.device);
160    // `findInterfaceAndEndpoint` will always return a non-null value because
161    // we check for this in 'android_webusb_target_factory'. If no interface and
162    // endpoints are found, we do not create a target, so we can not connect to
163    // it, so we will never reach this logic.
164    const {configurationValue, usbInterfaceNumber, endpoints} =
165        assertExists(interfaceAndEndpoint);
166    this.usbInterfaceNumber = usbInterfaceNumber;
167    this.usbReadEndpoint = this.findEndpointNumber(endpoints, 'in');
168    this.usbWriteEpEndpoint = this.findEndpointNumber(endpoints, 'out');
169    assertTrue(this.usbReadEndpoint >= 0 && this.usbWriteEpEndpoint >= 0);
170    await this.device.selectConfiguration(configurationValue);
171    return usbInterfaceNumber;
172  }
173
174  async streamClose(stream: AdbOverWebusbStream): Promise<void> {
175    const otherStreamsQueue = this.writeQueue.filter(
176        (queueElement) => queueElement.localStreamId !== stream.localStreamId);
177    const droppedPacketCount =
178        this.writeQueue.length - otherStreamsQueue.length;
179    if (droppedPacketCount > 0) {
180      console.debug(`Dropping ${
181          droppedPacketCount} queued messages due to stream closing.`);
182      this.writeQueue = otherStreamsQueue;
183    }
184
185    this.streams.delete(stream);
186    if (this.streams.size === 0) {
187      // We disconnect BEFORE calling `signalStreamClosed`. Otherwise, there can
188      // be a race condition:
189      // Stream A: streamA.onStreamClose
190      // Stream B: device.open
191      // Stream A: device.releaseInterface
192      // Stream B: device.transferOut -> CRASH
193      await this.disconnect();
194    }
195    stream.signalStreamClosed();
196  }
197
198  streamWrite(msg: string|Uint8Array, stream: AdbOverWebusbStream): void {
199    const raw = (typeof msg === 'string') ? textEncoder.encode(msg) : msg;
200    if (this.writeInProgress) {
201      this.writeQueue.push({message: raw, localStreamId: stream.localStreamId});
202      return;
203    }
204    this.writeInProgress = true;
205    this.sendMessage('WRTE', stream.localStreamId, stream.remoteStreamId, raw);
206  }
207
208  // We disconnect in 2 cases:
209  // 1. When we close the last stream of the connection. This is to prevent the
210  // browser holding onto the USB interface after having finished a trace
211  // recording, which would make it impossible to use "adb shell" from the same
212  // machine until the browser is closed.
213  // 2. When we get a USB disconnect event. This happens for instance when the
214  // device is unplugged.
215  async disconnect(disconnectMessage?: string): Promise<void> {
216    if (this.state === AdbState.DISCONNECTED) {
217      return;
218    }
219    // Clear the resources in a synchronous method, because this can be used
220    // for error handling callbacks as well.
221    this.reachDisconnectState(disconnectMessage);
222
223    // We have already disconnected so there is no need to pass a callback
224    // which clears resources or notifies the user into 'wrapRecordingError'.
225    await wrapRecordingError(
226        this.device.releaseInterface(assertExists(this.usbInterfaceNumber)),
227        () => {});
228    this.usbInterfaceNumber = undefined;
229  }
230
231  // This is a synchronous method which clears all resources.
232  // It can be used as a callback for error handling.
233  reachDisconnectState(disconnectMessage?: string): void {
234    // We need to delete the streams BEFORE checking the Adb state because:
235    //
236    // We create streams before changing the Adb state from DISCONNECTED.
237    // In case we can not claim the device, we will create a stream, but fail
238    // to connect to the WebUSB device so the state will remain DISCONNECTED.
239    const streamsToDelete = this.connectingStreams.entries();
240    // Clear the streams before rejecting so we are not caught in a loop of
241    // handling promise rejections.
242    this.connectingStreams.clear();
243    for (const [id, stream] of streamsToDelete) {
244      stream.reject(
245          `Failed to open stream with id ${id} because adb was disconnected.`);
246    }
247
248    if (this.state === AdbState.DISCONNECTED) {
249      return;
250    }
251
252    this.state = AdbState.DISCONNECTED;
253    this.writeInProgress = false;
254
255    this.writeQueue = [];
256
257    this.streams.forEach((stream) => stream.close());
258    this.onDisconnect(disconnectMessage);
259  }
260
261  private async startAdbAuth(): Promise<void> {
262    const VERSION =
263        this.useChecksum ? VERSION_WITH_CHECKSUM : VERSION_NO_CHECKSUM;
264    this.state = AdbState.AUTH_STARTED;
265    await this.sendMessage('CNXN', VERSION, this.maxPayload, 'host:1:UsbADB');
266  }
267
268  private findEndpointNumber(
269      endpoints: USBEndpoint[], direction: 'out'|'in', type = 'bulk'): number {
270    const ep =
271        endpoints.find((ep) => ep.type === type && ep.direction === direction);
272
273    if (ep) return ep.endpointNumber;
274
275    throw new RecordingError(`Cannot find ${direction} endpoint`);
276  }
277
278  private async usbReceiveLoop(): Promise<void> {
279    assertFalse(this.isUsbReceiveLoopRunning);
280    this.isUsbReceiveLoopRunning = true;
281    for (; this.state !== AdbState.DISCONNECTED;) {
282      const res = await this.wrapUsb(
283          this.device.transferIn(this.usbReadEndpoint, ADB_MSG_SIZE));
284      if (!res) {
285        this.isUsbReceiveLoopRunning = false;
286        return;
287      }
288      if (res.status !== 'ok') {
289        // Log and ignore messages with invalid status. These can occur
290        // when the device is connected/disconnected repeatedly.
291        console.error(
292            `Received message with unexpected status '${res.status}'`);
293        continue;
294      }
295
296      const msg = AdbMsg.decodeHeader(res.data!);
297      if (msg.dataLen > 0) {
298        const resp = await this.wrapUsb(
299            this.device.transferIn(this.usbReadEndpoint, msg.dataLen));
300        if (!resp) {
301          this.isUsbReceiveLoopRunning = false;
302          return;
303        }
304        msg.data = new Uint8Array(
305            resp.data!.buffer, resp.data!.byteOffset, resp.data!.byteLength);
306      }
307
308      if (this.useChecksum && generateChecksum(msg.data) !== msg.dataChecksum) {
309        // We ignore messages with an invalid checksum. These sometimes appear
310        // when the page is re-loaded in a middle of a recording.
311        continue;
312      }
313      // The server can still send messages streams for previous streams.
314      // This happens for instance if we record, reload the recording page and
315      // then record again. We can also receive a 'WRTE' or 'OKAY' after
316      // we have sent a 'CLSE' and marked the state as disconnected.
317      if ((msg.cmd === 'CLSE' || msg.cmd === 'WRTE') &&
318          !this.getStreamForLocalStreamId(msg.arg1)) {
319        continue;
320      } else if (
321          msg.cmd === 'OKAY' && !this.connectingStreams.has(msg.arg1) &&
322          !this.getStreamForLocalStreamId(msg.arg1)) {
323        continue;
324      } else if (
325          msg.cmd === 'AUTH' && msg.arg0 === AuthCmd.TOKEN &&
326          this.state === AdbState.AUTH_WITH_PUBLIC) {
327        // If we start a recording but fail because of a faulty physical
328        // connection to the device, when we start a new recording, we will
329        // received multiple AUTH tokens, of which we should ignore all but
330        // one.
331        continue;
332      }
333
334      // handle the ADB message from the device
335      if (msg.cmd === 'CLSE') {
336        assertExists(this.getStreamForLocalStreamId(msg.arg1)).close();
337      } else if (msg.cmd === 'AUTH' && msg.arg0 === AuthCmd.TOKEN) {
338        const key = await this.keyManager.getKey();
339        if (this.state === AdbState.AUTH_STARTED) {
340          // During this step, we send back the token received signed with our
341          // private key. If the device has previously received our public key,
342          // the dialog asking for user confirmation will not be displayed on
343          // the device.
344          this.state = AdbState.AUTH_WITH_PRIVATE;
345          await this.sendMessage(
346              'AUTH', AuthCmd.SIGNATURE, 0, key.sign(msg.data));
347        } else {
348          // If our signature with the private key is not accepted by the
349          // device, we generate a new keypair and send the public key.
350          this.state = AdbState.AUTH_WITH_PUBLIC;
351          await this.sendMessage(
352              'AUTH', AuthCmd.RSAPUBLICKEY, 0, key.getPublicKey() + '\0');
353          this.onStatus(ALLOW_USB_DEBUGGING);
354          await maybeStoreKey(key);
355        }
356      } else if (msg.cmd === 'CNXN') {
357        assertTrue(
358            [AdbState.AUTH_WITH_PRIVATE, AdbState.AUTH_WITH_PUBLIC].includes(
359                this.state));
360        this.state = AdbState.CONNECTED;
361        this.maxPayload = msg.arg1;
362
363        const deviceVersion = msg.arg0;
364
365        if (![VERSION_WITH_CHECKSUM, VERSION_NO_CHECKSUM].includes(
366                deviceVersion)) {
367          throw new RecordingError(`Version ${msg.arg0} not supported.`);
368        }
369        this.useChecksum = deviceVersion === VERSION_WITH_CHECKSUM;
370        this.state = AdbState.CONNECTED;
371
372        // This will resolve the promises awaited by
373        // "ensureConnectionEstablished".
374        this.pendingConnPromises.forEach(
375            (connPromise) => connPromise.resolve());
376        this.pendingConnPromises = [];
377      } else if (msg.cmd === 'OKAY') {
378        if (this.connectingStreams.has(msg.arg1)) {
379          const connectingStream =
380              assertExists(this.connectingStreams.get(msg.arg1));
381          const stream = new AdbOverWebusbStream(this, msg.arg1, msg.arg0);
382          this.streams.add(stream);
383          this.connectingStreams.delete(msg.arg1);
384          connectingStream.resolve(stream);
385        } else {
386          assertTrue(this.writeInProgress);
387          this.writeInProgress = false;
388          for (; this.writeQueue.length;) {
389            // We go through the queued writes and choose the first one
390            // corresponding to a stream that's still active.
391            const queuedElement = assertExists(this.writeQueue.shift());
392            const queuedStream =
393                this.getStreamForLocalStreamId(queuedElement.localStreamId);
394            if (queuedStream) {
395              queuedStream.write(queuedElement.message);
396              break;
397            }
398          }
399        }
400      } else if (msg.cmd === 'WRTE') {
401        const stream = assertExists(this.getStreamForLocalStreamId(msg.arg1));
402        await this.sendMessage(
403            'OKAY', stream.localStreamId, stream.remoteStreamId);
404        stream.signalStreamData(msg.data);
405      } else {
406        this.isUsbReceiveLoopRunning = false;
407        throw new RecordingError(
408            `Unexpected message ${msg} in state ${this.state}`);
409      }
410    }
411    this.isUsbReceiveLoopRunning = false;
412  }
413
414  private getStreamForLocalStreamId(localStreamId: number): AdbOverWebusbStream
415      |undefined {
416    for (const stream of this.streams) {
417      if (stream.localStreamId === localStreamId) {
418        return stream;
419      }
420    }
421    return undefined;
422  }
423
424  //  The header and the message data must be sent consecutively. Using 2 awaits
425  //  Another message can interleave after the first header has been sent,
426  //  resulting in something like [header1] [header2] [data1] [data2];
427  //  In this way we are waiting both promises to be resolved before continuing.
428  private async sendMessage(
429      cmd: CmdType, arg0: number, arg1: number,
430      data?: Uint8Array|string): Promise<void> {
431    const msg =
432        AdbMsg.create({cmd, arg0, arg1, data, useChecksum: this.useChecksum});
433
434    const msgHeader = msg.encodeHeader();
435    const msgData = msg.data;
436    assertTrue(
437        msgHeader.length <= this.maxPayload &&
438        msgData.length <= this.maxPayload);
439
440    const sendPromises = [this.wrapUsb(
441        this.device.transferOut(this.usbWriteEpEndpoint, msgHeader.buffer))];
442    if (msg.data.length > 0) {
443      sendPromises.push(this.wrapUsb(
444          this.device.transferOut(this.usbWriteEpEndpoint, msgData.buffer)));
445    }
446    await Promise.all(sendPromises);
447  }
448
449  private wrapUsb<T>(promise: Promise<T>): Promise<T|undefined> {
450    return wrapRecordingError(promise, this.reachDisconnectState.bind(this));
451  }
452}
453
454// An AdbOverWebusbStream is instantiated after the creation of a socket to the
455// device. Thanks to this, we can send commands and receive their output.
456// Messages are received in the main adb class, and are forwarded to an instance
457// of this class based on a stream id match.
458export class AdbOverWebusbStream implements ByteStream {
459  private adbConnection: AdbConnectionOverWebusb;
460  private _isConnected: boolean;
461  private onStreamDataCallbacks: OnStreamDataCallback[] = [];
462  private onStreamCloseCallbacks: OnStreamCloseCallback[] = [];
463  localStreamId: number;
464  remoteStreamId = -1;
465
466  constructor(
467      adb: AdbConnectionOverWebusb, localStreamId: number,
468      remoteStreamId: number) {
469    this.adbConnection = adb;
470    this.localStreamId = localStreamId;
471    this.remoteStreamId = remoteStreamId;
472    // When the stream is created, the connection has been already established.
473    this._isConnected = true;
474  }
475
476  addOnStreamDataCallback(onStreamData: OnStreamDataCallback): void {
477    this.onStreamDataCallbacks.push(onStreamData);
478  }
479
480  addOnStreamCloseCallback(onStreamClose: OnStreamCloseCallback): void {
481    this.onStreamCloseCallbacks.push(onStreamClose);
482  }
483
484  // Used by the connection object to signal newly received data, not exposed
485  // in the interface.
486  signalStreamData(data: Uint8Array): void {
487    for (const onStreamData of this.onStreamDataCallbacks) {
488      onStreamData(data);
489    }
490  }
491
492  // Used by the connection object to signal the stream is closed, not exposed
493  // in the interface.
494  signalStreamClosed(): void {
495    for (const onStreamClose of this.onStreamCloseCallbacks) {
496      onStreamClose();
497    }
498    this.onStreamDataCallbacks = [];
499    this.onStreamCloseCallbacks = [];
500  }
501
502
503  close(): void {
504    this.closeAndWaitForTeardown();
505  }
506
507  async closeAndWaitForTeardown(): Promise<void> {
508    this._isConnected = false;
509    await this.adbConnection.streamClose(this);
510  }
511
512  write(msg: string|Uint8Array): void {
513    this.adbConnection.streamWrite(msg, this);
514  }
515
516  isConnected(): boolean {
517    return this._isConnected;
518  }
519}
520
521const ADB_MSG_SIZE = 6 * 4;  // 6 * int32.
522
523class AdbMsg {
524  data: Uint8Array;
525  readonly cmd: CmdType;
526  readonly arg0: number;
527  readonly arg1: number;
528  readonly dataLen: number;
529  readonly dataChecksum: number;
530  readonly useChecksum: boolean;
531
532  constructor(
533      cmd: CmdType, arg0: number, arg1: number, dataLen: number,
534      dataChecksum: number, useChecksum = false) {
535    assertTrue(cmd.length === 4);
536    this.cmd = cmd;
537    this.arg0 = arg0;
538    this.arg1 = arg1;
539    this.dataLen = dataLen;
540    this.data = new Uint8Array(dataLen);
541    this.dataChecksum = dataChecksum;
542    this.useChecksum = useChecksum;
543  }
544
545  static create({cmd, arg0, arg1, data, useChecksum = true}: {
546    cmd: CmdType; arg0: number; arg1: number;
547    data?: Uint8Array | string;
548    useChecksum?: boolean;
549  }): AdbMsg {
550    const encodedData = this.encodeData(data);
551    const msg = new AdbMsg(cmd, arg0, arg1, encodedData.length, 0, useChecksum);
552    msg.data = encodedData;
553    return msg;
554  }
555
556  get dataStr() {
557    return textDecoder.decode(this.data);
558  }
559
560  toString() {
561    return `${this.cmd} [${this.arg0},${this.arg1}] ${this.dataStr}`;
562  }
563
564  // A brief description of the message can be found here:
565  // https://android.googlesource.com/platform/system/core/+/master/adb/protocol.txt
566  //
567  // struct amessage {
568  //     uint32_t command;    // command identifier constant
569  //     uint32_t arg0;       // first argument
570  //     uint32_t arg1;       // second argument
571  //     uint32_t data_length;// length of payload (0 is allowed)
572  //     uint32_t data_check; // checksum of data payload
573  //     uint32_t magic;      // command ^ 0xffffffff
574  // };
575  static decodeHeader(dv: DataView): AdbMsg {
576    assertTrue(dv.byteLength === ADB_MSG_SIZE);
577    const cmd = textDecoder.decode(dv.buffer.slice(0, 4)) as CmdType;
578    const cmdNum = dv.getUint32(0, true);
579    const arg0 = dv.getUint32(4, true);
580    const arg1 = dv.getUint32(8, true);
581    const dataLen = dv.getUint32(12, true);
582    const dataChecksum = dv.getUint32(16, true);
583    const cmdChecksum = dv.getUint32(20, true);
584    assertTrue(cmdNum === (cmdChecksum ^ 0xFFFFFFFF));
585    return new AdbMsg(cmd, arg0, arg1, dataLen, dataChecksum);
586  }
587
588  encodeHeader(): Uint8Array {
589    const buf = new Uint8Array(ADB_MSG_SIZE);
590    const dv = new DataView(buf.buffer);
591    const cmdBytes: Uint8Array = textEncoder.encode(this.cmd);
592    const rawMsg = AdbMsg.encodeData(this.data);
593    const checksum = this.useChecksum ? generateChecksum(rawMsg) : 0;
594    for (let i = 0; i < 4; i++) dv.setUint8(i, cmdBytes[i]);
595
596    dv.setUint32(4, this.arg0, true);
597    dv.setUint32(8, this.arg1, true);
598    dv.setUint32(12, rawMsg.byteLength, true);
599    dv.setUint32(16, checksum, true);
600    dv.setUint32(20, dv.getUint32(0, true) ^ 0xFFFFFFFF, true);
601
602    return buf;
603  }
604
605  static encodeData(data?: Uint8Array|string): Uint8Array {
606    if (data === undefined) return new Uint8Array([]);
607    if (typeof data === 'string') return textEncoder.encode(data + '\0');
608    return data;
609  }
610}
611