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