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