1/* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17'use strict'; 18 19// Set the theme as soon as possible. 20const params = new URLSearchParams(location.search); 21let theme = params.get('theme'); 22if (theme === 'light') { 23 document.querySelector('body').classList.add('light-theme'); 24} else if (theme === 'dark') { 25 document.querySelector('body').classList.add('dark-theme'); 26} 27 28async function ConnectDevice(deviceId, serverConnector) { 29 console.debug('Connect: ' + deviceId); 30 // Prepare messages in case of connection failure 31 let connectionAttemptDuration = 0; 32 const intervalMs = 15000; 33 let connectionInterval = setInterval(() => { 34 connectionAttemptDuration += intervalMs; 35 if (connectionAttemptDuration > 30000) { 36 showError( 37 'Connection should have occurred by now. ' + 38 'Please attempt to restart the guest device.'); 39 clearInterval(connectionInterval); 40 } else if (connectionAttemptDuration > 15000) { 41 showWarning('Connection is taking longer than expected'); 42 } 43 }, intervalMs); 44 45 let module = await import('./cf_webrtc.js'); 46 let deviceConnection = await module.Connect(deviceId, serverConnector); 47 console.info('Connected to ' + deviceId); 48 clearInterval(connectionInterval); 49 return deviceConnection; 50} 51 52function setupMessages() { 53 let closeBtn = document.querySelector('#error-message .close-btn'); 54 closeBtn.addEventListener('click', evt => { 55 evt.target.parentElement.className = 'hidden'; 56 }); 57} 58 59function showMessage(msg, className, duration) { 60 let element = document.getElementById('error-message'); 61 let previousTimeout = element.dataset.timeout; 62 if (previousTimeout !== undefined) { 63 clearTimeout(previousTimeout); 64 } 65 if (element.childNodes.length < 2) { 66 // First time, no text node yet 67 element.insertAdjacentText('afterBegin', msg); 68 } else { 69 element.childNodes[0].data = msg; 70 } 71 element.className = className; 72 73 if (duration !== undefined) { 74 element.dataset.timeout = setTimeout(() => { 75 element.className = 'hidden'; 76 }, duration); 77 } 78} 79 80function showInfo(msg, duration) { 81 showMessage(msg, 'info', duration); 82} 83 84function showWarning(msg, duration) { 85 showMessage(msg, 'warning', duration); 86} 87 88function showError(msg, duration) { 89 showMessage(msg, 'error', duration); 90} 91 92 93class DeviceDetailsUpdater { 94 #element; 95 96 constructor() { 97 this.#element = document.getElementById('device-details-hardware'); 98 } 99 100 setHardwareDetailsText(text) { 101 this.#element.dataset.hardwareDetailsText = text; 102 return this; 103 } 104 105 setDeviceStateDetailsText(text) { 106 this.#element.dataset.deviceStateDetailsText = text; 107 return this; 108 } 109 110 update() { 111 this.#element.textContent = 112 [ 113 this.#element.dataset.hardwareDetailsText, 114 this.#element.dataset.deviceStateDetailsText, 115 ].filter(e => e /*remove empty*/) 116 .join('\n'); 117 } 118} // DeviceDetailsUpdater 119 120// These classes provide the same interface as those from the server_connector, 121// but can't inherit from them because older versions of server_connector.js 122// don't provide them. 123// These classes are only meant to avoid having to check for null everytime. 124class EmptyDeviceDisplaysMessage { 125 addDisplay(display_id, width, height) {} 126 send() {} 127} 128 129class EmptyParentController { 130 createDeviceDisplaysMessage(rotation) { 131 return new EmptyDeviceDisplaysMessage(); 132 } 133} 134 135class DeviceControlApp { 136 #deviceConnection = {}; 137 #parentController = null; 138 #currentRotation = 0; 139 #currentScreenStyles = {}; 140 #displayDescriptions = []; 141 #recording = {}; 142 #phys = {}; 143 #deviceCount = 0; 144 #micActive = false; 145 #adbConnected = false; 146 147 constructor(deviceConnection, parentController) { 148 this.#deviceConnection = deviceConnection; 149 this.#parentController = parentController; 150 } 151 152 start() { 153 console.debug('Device description: ', this.#deviceConnection.description); 154 this.#deviceConnection.onControlMessage(msg => this.#onControlMessage(msg)); 155 createToggleControl( 156 document.getElementById('camera_off_btn'), 157 enabled => this.#onCameraCaptureToggle(enabled)); 158 createToggleControl( 159 document.getElementById('record_video_btn'), 160 enabled => this.#onVideoCaptureToggle(enabled)); 161 const audioElm = document.getElementById('device-audio'); 162 163 let audioPlaybackCtrl = createToggleControl( 164 document.getElementById('volume_off_btn'), 165 enabled => this.#onAudioPlaybackToggle(enabled), !audioElm.paused); 166 // The audio element may start or stop playing at any time, this ensures the 167 // audio control always show the right state. 168 audioElm.onplay = () => audioPlaybackCtrl.Set(true); 169 audioElm.onpause = () => audioPlaybackCtrl.Set(false); 170 171 // Enable non-ADB buttons, these buttons use data channels to communicate 172 // with the host, so they're ready to go as soon as the webrtc connection is 173 // established. 174 this.#getControlPanelButtons() 175 .filter(b => !b.dataset.adb) 176 .forEach(b => b.disabled = false); 177 178 this.#showDeviceUI(); 179 } 180 181 #showDeviceUI() { 182 // Set up control panel buttons 183 addMouseListeners( 184 document.querySelector('#power_btn'), 185 evt => this.#onControlPanelButton(evt, 'power')); 186 addMouseListeners( 187 document.querySelector('#back_btn'), 188 evt => this.#onControlPanelButton(evt, 'back')); 189 addMouseListeners( 190 document.querySelector('#home_btn'), 191 evt => this.#onControlPanelButton(evt, 'home')); 192 addMouseListeners( 193 document.querySelector('#menu_btn'), 194 evt => this.#onControlPanelButton(evt, 'menu')); 195 addMouseListeners( 196 document.querySelector('#rotate_left_btn'), 197 evt => this.#onRotateLeftButton(evt, 'rotate')); 198 addMouseListeners( 199 document.querySelector('#rotate_right_btn'), 200 evt => this.#onRotateRightButton(evt, 'rotate')); 201 addMouseListeners( 202 document.querySelector('#volume_up_btn'), 203 evt => this.#onControlPanelButton(evt, 'volumeup')); 204 addMouseListeners( 205 document.querySelector('#volume_down_btn'), 206 evt => this.#onControlPanelButton(evt, 'volumedown')); 207 addMouseListeners( 208 document.querySelector('#mic_btn'), evt => this.#onMicButton(evt)); 209 210 createModalButton( 211 'device-details-button', 'device-details-modal', 212 'device-details-close'); 213 createModalButton( 214 'bluetooth-modal-button', 'bluetooth-prompt', 'bluetooth-prompt-close'); 215 createModalButton( 216 'bluetooth-prompt-wizard', 'bluetooth-wizard', 'bluetooth-wizard-close', 217 'bluetooth-prompt'); 218 createModalButton( 219 'bluetooth-wizard-device', 'bluetooth-wizard-confirm', 220 'bluetooth-wizard-confirm-close', 'bluetooth-wizard'); 221 createModalButton( 222 'bluetooth-wizard-another', 'bluetooth-wizard', 223 'bluetooth-wizard-close', 'bluetooth-wizard-confirm'); 224 createModalButton( 225 'bluetooth-prompt-list', 'bluetooth-list', 'bluetooth-list-close', 226 'bluetooth-prompt'); 227 createModalButton( 228 'bluetooth-prompt-console', 'bluetooth-console', 229 'bluetooth-console-close', 'bluetooth-prompt'); 230 createModalButton( 231 'bluetooth-wizard-cancel', 'bluetooth-prompt', 'bluetooth-wizard-close', 232 'bluetooth-wizard'); 233 234 createModalButton('location-modal-button', 'location-prompt-modal', 235 'location-prompt-modal-close'); 236 createModalButton( 237 'location-set-wizard', 'location-set-modal', 'location-set-modal-close', 238 'location-prompt-modal'); 239 240 createModalButton( 241 'locations-import-wizard', 'locations-import-modal', 'locations-import-modal-close', 242 'location-prompt-modal'); 243 createModalButton( 244 'location-set-cancel', 'location-prompt-modal', 'location-set-modal-close', 245 'location-set-modal'); 246 247 positionModal('device-details-button', 'bluetooth-modal'); 248 positionModal('device-details-button', 'bluetooth-prompt'); 249 positionModal('device-details-button', 'bluetooth-wizard'); 250 positionModal('device-details-button', 'bluetooth-wizard-confirm'); 251 positionModal('device-details-button', 'bluetooth-list'); 252 positionModal('device-details-button', 'bluetooth-console'); 253 254 positionModal('device-details-button', 'location-modal'); 255 positionModal('device-details-button', 'location-prompt-modal'); 256 positionModal('device-details-button', 'location-set-modal'); 257 positionModal('device-details-button', 'locations-import-modal'); 258 259 createButtonListener('bluetooth-prompt-list', null, this.#deviceConnection, 260 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 261 createButtonListener('bluetooth-wizard-device', null, this.#deviceConnection, 262 evt => this.#onRootCanalCommand(this.#deviceConnection, "add", evt)); 263 createButtonListener('bluetooth-list-trash', null, this.#deviceConnection, 264 evt => this.#onRootCanalCommand(this.#deviceConnection, "del", evt)); 265 createButtonListener('bluetooth-prompt-wizard', null, this.#deviceConnection, 266 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 267 createButtonListener('bluetooth-wizard-another', null, this.#deviceConnection, 268 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 269 270 createButtonListener('locations-send-btn', null, this.#deviceConnection, 271 evt => this.#onImportLocationsFile(this.#deviceConnection,evt)); 272 273 createButtonListener('location-set-confirm', null, this.#deviceConnection, 274 evt => this.#onSendLocation(this.#deviceConnection, evt)); 275 276 if (this.#deviceConnection.description.custom_control_panel_buttons.length > 277 0) { 278 document.getElementById('control-panel-custom-buttons').style.display = 279 'flex'; 280 for (const button of this.#deviceConnection.description 281 .custom_control_panel_buttons) { 282 if (button.shell_command) { 283 // This button's command is handled by sending an ADB shell command. 284 let element = createControlPanelButton( 285 button.title, button.icon_name, 286 e => this.#onCustomShellButton(button.shell_command, e), 287 'control-panel-custom-buttons'); 288 element.dataset.adb = true; 289 } else if (button.device_states) { 290 // This button corresponds to variable hardware device state(s). 291 let element = createControlPanelButton( 292 button.title, button.icon_name, 293 this.#getCustomDeviceStateButtonCb(button.device_states), 294 'control-panel-custom-buttons'); 295 for (const device_state of button.device_states) { 296 // hinge_angle is currently injected via an adb shell command that 297 // triggers a guest binary. 298 if ('hinge_angle_value' in device_state) { 299 element.dataset.adb = true; 300 } 301 } 302 } else { 303 // This button's command is handled by custom action server. 304 createControlPanelButton( 305 button.title, button.icon_name, 306 evt => this.#onControlPanelButton(evt, button.command), 307 'control-panel-custom-buttons'); 308 } 309 } 310 } 311 312 // Set up displays 313 this.#updateDeviceDisplays(); 314 this.#deviceConnection.onStreamChange(stream => this.#onStreamChange(stream)); 315 316 // Set up audio 317 const deviceAudio = document.getElementById('device-audio'); 318 for (const audio_desc of this.#deviceConnection.description.audio_streams) { 319 let stream_id = audio_desc.stream_id; 320 this.#deviceConnection.onStream(stream_id) 321 .then(stream => { 322 deviceAudio.srcObject = stream; 323 deviceAudio.play(); 324 }) 325 .catch(e => console.error('Unable to get audio stream: ', e)); 326 } 327 328 // Set up keyboard capture 329 this.#startKeyboardCapture(); 330 331 this.#updateDeviceHardwareDetails( 332 this.#deviceConnection.description.hardware); 333 334 // Show the error message and disable buttons when the WebRTC connection 335 // fails. 336 this.#deviceConnection.onConnectionStateChange(state => { 337 if (state == 'disconnected' || state == 'failed') { 338 this.#showWebrtcError(); 339 } 340 }); 341 342 let bluetoothConsole = 343 cmdConsole('bluetooth-console-view', 'bluetooth-console-input'); 344 bluetoothConsole.addCommandListener(cmd => { 345 let inputArr = cmd.split(' '); 346 let command = inputArr[0]; 347 inputArr.shift(); 348 let args = inputArr; 349 this.#deviceConnection.sendBluetoothMessage( 350 createRootcanalMessage(command, args)); 351 }); 352 this.#deviceConnection.onBluetoothMessage(msg => { 353 let decoded = decodeRootcanalMessage(msg); 354 let deviceCount = btUpdateDeviceList(decoded); 355 console.debug("deviceCount= " +deviceCount); 356 console.debug("decoded= " +decoded); 357 if (deviceCount > 0) { 358 this.#deviceCount = deviceCount; 359 createButtonListener('bluetooth-list-trash', null, this.#deviceConnection, 360 evt => this.#onRootCanalCommand(this.#deviceConnection, "del", evt)); 361 } 362 btUpdateAdded(decoded); 363 let phyList = btParsePhys(decoded); 364 if (phyList) { 365 this.#phys = phyList; 366 } 367 bluetoothConsole.addLine(decoded); 368 }); 369 370 this.#deviceConnection.onLocationMessage(msg => { 371 console.debug("onLocationMessage = " +msg); 372 }); 373 } 374 375 #onStreamChange(stream) { 376 let stream_id = stream.id; 377 if (stream_id.startsWith('display_')) { 378 this.#updateDeviceDisplays(); 379 } 380 } 381 382 #onRootCanalCommand(deviceConnection, cmd, evt) { 383 384 if (cmd == "list") { 385 deviceConnection.sendBluetoothMessage(createRootcanalMessage("list", [])); 386 } 387 if (cmd == "del") { 388 let id = evt.srcElement.getAttribute("data-device-id"); 389 deviceConnection.sendBluetoothMessage(createRootcanalMessage("del", [id])); 390 deviceConnection.sendBluetoothMessage(createRootcanalMessage("list", [])); 391 } 392 if (cmd == "add") { 393 let name = document.getElementById('bluetooth-wizard-name').value; 394 let type = document.getElementById('bluetooth-wizard-type').value; 395 if (type == "remote_loopback") { 396 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add", [type])); 397 } else { 398 let mac = document.getElementById('bluetooth-wizard-mac').value; 399 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add", [type, mac])); 400 } 401 let phyId = this.#phys["LOW_ENERGY"].toString(); 402 if (type == "remote_loopback") { 403 phyId = this.#phys["BR_EDR"].toString(); 404 } 405 let devId = this.#deviceCount.toString(); 406 this.#deviceCount++; 407 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add_device_to_phy", [devId, phyId])); 408 } 409 } 410 411 #onSendLocation(deviceConnection, evt) { 412 413 let longitude = document.getElementById('location-set-longitude').value; 414 let latitude = document.getElementById('location-set-latitude').value; 415 let altitude = document.getElementById('location-set-altitude').value; 416 if (longitude == null || longitude == '' || latitude == null || latitude == ''|| 417 altitude == null || altitude == '') { 418 return; 419 } 420 let location_msg = longitude + "," +latitude + "," + altitude; 421 deviceConnection.sendLocationMessage(location_msg); 422 } 423 #onImportLocationsFile(deviceConnection, evt) { 424 425 function onLoad_send_kml_data(xml) { 426 deviceConnection.sendKmlLocationsMessage(xml); 427 } 428 429 function onLoad_send_gpx_data(xml) { 430 deviceConnection.sendGpxLocationsMessage(xml); 431 } 432 433 let file_selector=document.getElementById("locations_select_file"); 434 435 if (!file_selector.files) { 436 alert("input parameter is not of file type"); 437 return; 438 } 439 440 if (!file_selector.files[0]) { 441 alert("Please select a file "); 442 return; 443 } 444 445 var filename= file_selector.files[0]; 446 if (filename.type.match('\gpx')) { 447 console.debug("import Gpx locations handling"); 448 loadFile(onLoad_send_gpx_data); 449 } else if(filename.type.match('\kml')){ 450 console.debug("import Kml locations handling"); 451 loadFile(onLoad_send_kml_data); 452 } 453 454 } 455 456 #showWebrtcError() { 457 showError( 458 'No connection to the guest device. Please ensure the WebRTC' + 459 'process on the host machine is active.'); 460 const deviceDisplays = document.getElementById('device-displays'); 461 deviceDisplays.style.display = 'none'; 462 this.#getControlPanelButtons().forEach(b => b.disabled = true); 463 } 464 465 #getControlPanelButtons() { 466 return [ 467 ...document.querySelectorAll('#control-panel-default-buttons button'), 468 ...document.querySelectorAll('#control-panel-custom-buttons button'), 469 ]; 470 } 471 472 #takePhoto() { 473 const imageCapture = this.#deviceConnection.imageCapture; 474 if (imageCapture) { 475 const photoSettings = { 476 imageWidth: this.#deviceConnection.cameraWidth, 477 imageHeight: this.#deviceConnection.cameraHeight 478 }; 479 imageCapture.takePhoto(photoSettings) 480 .then(blob => blob.arrayBuffer()) 481 .then(buffer => this.#deviceConnection.sendOrQueueCameraData(buffer)) 482 .catch(error => console.error(error)); 483 } 484 } 485 486 #getCustomDeviceStateButtonCb(device_states) { 487 let states = device_states; 488 let index = 0; 489 return e => { 490 if (e.type == 'mousedown') { 491 // Reset any overridden device state. 492 adbShell('cmd device_state state reset'); 493 // Send a device_state message for the current state. 494 let message = { 495 command: 'device_state', 496 ...states[index], 497 }; 498 this.#deviceConnection.sendControlMessage(JSON.stringify(message)); 499 console.debug('Control message sent: ', JSON.stringify(message)); 500 let lidSwitchOpen = null; 501 if ('lid_switch_open' in states[index]) { 502 lidSwitchOpen = states[index].lid_switch_open; 503 } 504 let hingeAngle = null; 505 if ('hinge_angle_value' in states[index]) { 506 hingeAngle = states[index].hinge_angle_value; 507 // TODO(b/181157794): Use a custom Sensor HAL for hinge_angle 508 // injection instead of this guest binary. 509 adbShell( 510 '/vendor/bin/cuttlefish_sensor_injection hinge_angle ' + 511 states[index].hinge_angle_value); 512 } 513 // Update the Device Details view. 514 this.#updateDeviceStateDetails(lidSwitchOpen, hingeAngle); 515 // Cycle to the next state. 516 index = (index + 1) % states.length; 517 } 518 } 519 } 520 521 #rotateDisplays(rotation) { 522 if ((rotation - this.#currentRotation) % 360 == 0) { 523 return; 524 } 525 526 document.querySelectorAll('.device-display-video').forEach((v, i) => { 527 const stream = v.srcObject; 528 if (stream == null) { 529 console.error('Missing corresponding device display video stream', l); 530 return; 531 } 532 533 const streamVideoTracks = stream.getVideoTracks(); 534 if (streamVideoTracks == null || streamVideoTracks.length == 0) { 535 return; 536 } 537 538 const streamSettings = stream.getVideoTracks()[0].getSettings(); 539 const streamWidth = streamSettings.width; 540 const streamHeight = streamSettings.height; 541 if (streamWidth == 0 || streamHeight == 0) { 542 console.error('Stream dimensions not yet available?', stream); 543 return; 544 } 545 546 const aspectRatio = streamWidth / streamHeight; 547 548 let keyFrames = []; 549 let from = this.#currentScreenStyles[v.id]; 550 if (from) { 551 // If the screen was already rotated, use that state as starting point, 552 // otherwise the animation will start at the element's default state. 553 keyFrames.push(from); 554 } 555 let to = getStyleAfterRotation(rotation, aspectRatio); 556 keyFrames.push(to); 557 v.animate(keyFrames, {duration: 400 /*ms*/, fill: 'forwards'}); 558 this.#currentScreenStyles[v.id] = to; 559 }); 560 561 this.#currentRotation = rotation; 562 this.#updateDeviceDisplaysInfo(); 563 } 564 565 #updateDeviceDisplaysInfo() { 566 let labels = document.querySelectorAll('.device-display-info'); 567 568 // #currentRotation is device's physical rotation and currently used to 569 // determine display's rotation. It would be obtained from device's 570 // accelerometer sensor. 571 let deviceDisplaysMessage = 572 this.#parentController.createDeviceDisplaysMessage( 573 this.#currentRotation); 574 575 labels.forEach(l => { 576 let deviceDisplay = l.closest('.device-display'); 577 if (deviceDisplay == null) { 578 console.error('Missing corresponding device display', l); 579 return; 580 } 581 582 let deviceDisplayVideo = 583 deviceDisplay.querySelector('.device-display-video'); 584 if (deviceDisplayVideo == null) { 585 console.error('Missing corresponding device display video', l); 586 return; 587 } 588 589 const DISPLAY_PREFIX = 'display_'; 590 let displayId = deviceDisplayVideo.id; 591 if (displayId == null || !displayId.startsWith(DISPLAY_PREFIX)) { 592 console.error('Unexpected device display video id', displayId); 593 return; 594 } 595 displayId = displayId.slice(DISPLAY_PREFIX.length); 596 597 let stream = deviceDisplayVideo.srcObject; 598 if (stream == null) { 599 console.error('Missing corresponding device display video stream', l); 600 return; 601 } 602 603 let text = `Display ${displayId} `; 604 605 let streamVideoTracks = stream.getVideoTracks(); 606 if (streamVideoTracks.length > 0) { 607 let streamSettings = stream.getVideoTracks()[0].getSettings(); 608 // Width and height may not be available immediately after the track is 609 // added but before frames arrive. 610 if ('width' in streamSettings && 'height' in streamSettings) { 611 let streamWidth = streamSettings.width; 612 let streamHeight = streamSettings.height; 613 614 deviceDisplaysMessage.addDisplay( 615 displayId, streamWidth, streamHeight); 616 617 text += `${streamWidth}x${streamHeight}`; 618 } 619 } 620 621 if (this.#currentRotation != 0) { 622 text += ` (Rotated ${this.#currentRotation}deg)` 623 } 624 625 l.textContent = text; 626 }); 627 628 deviceDisplaysMessage.send(); 629 } 630 631 #onControlMessage(message) { 632 let message_data = JSON.parse(message.data); 633 console.debug('Control message received: ', message_data) 634 let metadata = message_data.metadata; 635 if (message_data.event == 'VIRTUAL_DEVICE_BOOT_STARTED') { 636 // Start the adb connection after receiving the BOOT_STARTED message. 637 // (This is after the adbd start message. Attempting to connect 638 // immediately after adbd starts causes issues.) 639 this.#initializeAdb(); 640 } 641 if (message_data.event == 'VIRTUAL_DEVICE_SCREEN_CHANGED') { 642 this.#rotateDisplays(+metadata.rotation); 643 } 644 if (message_data.event == 'VIRTUAL_DEVICE_CAPTURE_IMAGE') { 645 if (this.#deviceConnection.cameraEnabled) { 646 this.#takePhoto(); 647 } 648 } 649 if (message_data.event == 'VIRTUAL_DEVICE_DISPLAY_POWER_MODE_CHANGED') { 650 this.#updateDisplayVisibility(metadata.display, metadata.mode); 651 } 652 } 653 654 #updateDeviceStateDetails(lidSwitchOpen, hingeAngle) { 655 let deviceStateDetailsTextLines = []; 656 if (lidSwitchOpen != null) { 657 let state = lidSwitchOpen ? 'Opened' : 'Closed'; 658 deviceStateDetailsTextLines.push(`Lid Switch - ${state}`); 659 } 660 if (hingeAngle != null) { 661 deviceStateDetailsTextLines.push(`Hinge Angle - ${hingeAngle}`); 662 } 663 let deviceStateDetailsText = deviceStateDetailsTextLines.join('\n'); 664 new DeviceDetailsUpdater() 665 .setDeviceStateDetailsText(deviceStateDetailsText) 666 .update(); 667 } 668 669 #updateDeviceHardwareDetails(hardware) { 670 let hardwareDetailsTextLines = []; 671 Object.keys(hardware).forEach((key) => { 672 let value = hardware[key]; 673 hardwareDetailsTextLines.push(`${key} - ${value}`); 674 }); 675 676 let hardwareDetailsText = hardwareDetailsTextLines.join('\n'); 677 new DeviceDetailsUpdater() 678 .setHardwareDetailsText(hardwareDetailsText) 679 .update(); 680 } 681 682 // Creates a <video> element and a <div> container element for each display. 683 // The extra <div> container elements are used to maintain the width and 684 // height of the device as the CSS 'transform' property used on the <video> 685 // element for rotating the device only affects the visuals of the element 686 // and not its layout. 687 #updateDeviceDisplays() { 688 let anyDisplayLoaded = false; 689 const deviceDisplays = document.getElementById('device-displays'); 690 691 const MAX_DISPLAYS = 16; 692 for (let i = 0; i < MAX_DISPLAYS; i++) { 693 const stream_id = 'display_' + i.toString(); 694 const stream = this.#deviceConnection.getStream(stream_id); 695 696 let deviceDisplayVideo = document.querySelector('#' + stream_id); 697 const deviceDisplayIsPresent = deviceDisplayVideo != null; 698 const deviceDisplayStreamIsActive = stream != null && stream.active; 699 if (deviceDisplayStreamIsActive == deviceDisplayIsPresent) { 700 continue; 701 } 702 703 if (deviceDisplayStreamIsActive) { 704 console.debug('Adding display', i); 705 706 let displayFragment = 707 document.querySelector('#display-template').content.cloneNode(true); 708 709 let deviceDisplayInfo = 710 displayFragment.querySelector('.device-display-info'); 711 deviceDisplayInfo.id = stream_id + '_info'; 712 713 deviceDisplayVideo = displayFragment.querySelector('video'); 714 deviceDisplayVideo.id = stream_id; 715 deviceDisplayVideo.srcObject = stream; 716 deviceDisplayVideo.addEventListener('loadeddata', (evt) => { 717 if (!anyDisplayLoaded) { 718 anyDisplayLoaded = true; 719 this.#onDeviceDisplayLoaded(); 720 } 721 }); 722 deviceDisplayVideo.addEventListener('loadedmetadata', (evt) => { 723 this.#updateDeviceDisplaysInfo(); 724 }); 725 726 this.#addMouseTracking(deviceDisplayVideo); 727 728 deviceDisplays.appendChild(displayFragment); 729 730 // Confusingly, events for adding tracks occur on the peer connection 731 // but events for removing tracks occur on the stream. 732 stream.addEventListener('removetrack', evt => { 733 this.#updateDeviceDisplays(); 734 }); 735 } else { 736 console.debug('Removing display', i); 737 738 let deviceDisplay = deviceDisplayVideo.closest('.device-display'); 739 if (deviceDisplay == null) { 740 console.error('Failed to find device display for ', stream_id); 741 } else { 742 deviceDisplays.removeChild(deviceDisplay); 743 } 744 } 745 } 746 747 this.#updateDeviceDisplaysInfo(); 748 } 749 750 #initializeAdb() { 751 init_adb( 752 this.#deviceConnection, () => this.#onAdbConnected(), 753 () => this.#showAdbError()); 754 } 755 756 #onAdbConnected() { 757 if (this.#adbConnected) { 758 return; 759 } 760 // Screen changed messages are not reported until after boot has completed. 761 // Certain default adb buttons change screen state, so wait for boot 762 // completion before enabling these buttons. 763 showInfo('adb connection established successfully.', 5000); 764 this.#adbConnected = true; 765 this.#getControlPanelButtons() 766 .filter(b => b.dataset.adb) 767 .forEach(b => b.disabled = false); 768 } 769 770 #showAdbError() { 771 showError('adb connection failed.'); 772 this.#getControlPanelButtons() 773 .filter(b => b.dataset.adb) 774 .forEach(b => b.disabled = true); 775 } 776 777 #onDeviceDisplayLoaded() { 778 if (!this.#adbConnected) { 779 // ADB may have connected before, don't show this message in that case 780 showInfo('Awaiting bootup and adb connection. Please wait...', 10000); 781 } 782 this.#updateDeviceDisplaysInfo(); 783 784 let deviceDisplayList = document.getElementsByClassName('device-display'); 785 for (const deviceDisplay of deviceDisplayList) { 786 deviceDisplay.style.visibility = 'visible'; 787 } 788 789 // Start the adb connection if it is not already started. 790 this.#initializeAdb(); 791 } 792 793 #onRotateLeftButton(e) { 794 if (e.type == 'mousedown') { 795 this.#onRotateButton(this.#currentRotation + 90); 796 } 797 } 798 799 #onRotateRightButton(e) { 800 if (e.type == 'mousedown') { 801 this.#onRotateButton(this.#currentRotation - 90); 802 } 803 } 804 805 #onRotateButton(rotation) { 806 // Attempt to init adb again, in case the initial connection failed. 807 // This succeeds immediately if already connected. 808 this.#initializeAdb(); 809 this.#rotateDisplays(rotation); 810 adbShell(`/vendor/bin/cuttlefish_sensor_injection rotate ${rotation}`); 811 } 812 813 #onControlPanelButton(e, command) { 814 if (e.type == 'mouseout' && e.which == 0) { 815 // Ignore mouseout events if no mouse button is pressed. 816 return; 817 } 818 this.#deviceConnection.sendControlMessage(JSON.stringify({ 819 command: command, 820 button_state: e.type == 'mousedown' ? 'down' : 'up', 821 })); 822 } 823 824 #startKeyboardCapture() { 825 const deviceArea = document.querySelector('#device-displays'); 826 deviceArea.addEventListener('keydown', evt => this.#onKeyEvent(evt)); 827 deviceArea.addEventListener('keyup', evt => this.#onKeyEvent(evt)); 828 } 829 830 #onKeyEvent(e) { 831 this.#deviceConnection.sendKeyEvent(e.code, e.type); 832 } 833 834 #addMouseTracking(displayDeviceVideo) { 835 let $this = this; 836 let mouseIsDown = false; 837 let mouseCtx = { 838 down: false, 839 touchIdSlotMap: new Map(), 840 touchSlots: [], 841 }; 842 function onStartDrag(e) { 843 // Can't prevent event default behavior to allow the element gain focus 844 // when touched and start capturing keyboard input in the parent. 845 // console.debug("mousedown at " + e.pageX + " / " + e.pageY); 846 mouseCtx.down = true; 847 848 $this.#sendEventUpdate(mouseCtx, e); 849 } 850 851 function onEndDrag(e) { 852 // Can't prevent event default behavior to allow the element gain focus 853 // when touched and start capturing keyboard input in the parent. 854 // console.debug("mouseup at " + e.pageX + " / " + e.pageY); 855 mouseCtx.down = false; 856 857 $this.#sendEventUpdate(mouseCtx, e); 858 } 859 860 function onContinueDrag(e) { 861 // Can't prevent event default behavior to allow the element gain focus 862 // when touched and start capturing keyboard input in the parent. 863 // console.debug("mousemove at " + e.pageX + " / " + e.pageY + ", down=" + 864 // mouseIsDown); 865 if (mouseCtx.down) { 866 $this.#sendEventUpdate(mouseCtx, e); 867 } 868 } 869 870 if (window.PointerEvent) { 871 displayDeviceVideo.addEventListener('pointerdown', onStartDrag); 872 displayDeviceVideo.addEventListener('pointermove', onContinueDrag); 873 displayDeviceVideo.addEventListener('pointerup', onEndDrag); 874 } else if (window.TouchEvent) { 875 displayDeviceVideo.addEventListener('touchstart', onStartDrag); 876 displayDeviceVideo.addEventListener('touchmove', onContinueDrag); 877 displayDeviceVideo.addEventListener('touchend', onEndDrag); 878 } else if (window.MouseEvent) { 879 displayDeviceVideo.addEventListener('mousedown', onStartDrag); 880 displayDeviceVideo.addEventListener('mousemove', onContinueDrag); 881 displayDeviceVideo.addEventListener('mouseup', onEndDrag); 882 } 883 } 884 885 #sendEventUpdate(ctx, e) { 886 let eventType = e.type.substring(0, 5); 887 888 // The <video> element: 889 const deviceDisplay = e.target; 890 891 // Before the first video frame arrives there is no way to know width and 892 // height of the device's screen, so turn every click into a click at 0x0. 893 // A click at that position is not more dangerous than anywhere else since 894 // the user is clicking blind anyways. 895 const videoWidth = deviceDisplay.videoWidth ? deviceDisplay.videoWidth : 1; 896 const elementWidth = 897 deviceDisplay.offsetWidth ? deviceDisplay.offsetWidth : 1; 898 const scaling = videoWidth / elementWidth; 899 900 let xArr = []; 901 let yArr = []; 902 let idArr = []; 903 let slotArr = []; 904 905 if (eventType == 'mouse' || eventType == 'point') { 906 xArr.push(e.offsetX); 907 yArr.push(e.offsetY); 908 909 let thisId = -1; 910 if (eventType == 'point') { 911 thisId = e.pointerId; 912 } 913 914 slotArr.push(0); 915 idArr.push(thisId); 916 } else if (eventType == 'touch') { 917 // touchstart: list of touch points that became active 918 // touchmove: list of touch points that changed 919 // touchend: list of touch points that were removed 920 let changes = e.changedTouches; 921 let rect = e.target.getBoundingClientRect(); 922 for (let i = 0; i < changes.length; i++) { 923 xArr.push(changes[i].pageX - rect.left); 924 yArr.push(changes[i].pageY - rect.top); 925 if (ctx.touchIdSlotMap.has(changes[i].identifier)) { 926 let slot = ctx.touchIdSlotMap.get(changes[i].identifier); 927 928 slotArr.push(slot); 929 if (e.type == 'touchstart') { 930 // error 931 console.error('touchstart when already have slot'); 932 return; 933 } else if (e.type == 'touchmove') { 934 idArr.push(changes[i].identifier); 935 } else if (e.type == 'touchend') { 936 ctx.touchSlots[slot] = false; 937 ctx.touchIdSlotMap.delete(changes[i].identifier); 938 idArr.push(-1); 939 } 940 } else { 941 if (e.type == 'touchstart') { 942 let slot = -1; 943 for (let j = 0; j < ctx.touchSlots.length; j++) { 944 if (!ctx.touchSlots[j]) { 945 slot = j; 946 break; 947 } 948 } 949 if (slot == -1) { 950 slot = ctx.touchSlots.length; 951 ctx.touchSlots.push(true); 952 } 953 slotArr.push(slot); 954 ctx.touchSlots[slot] = true; 955 ctx.touchIdSlotMap.set(changes[i].identifier, slot); 956 idArr.push(changes[i].identifier); 957 } else if (e.type == 'touchmove') { 958 // error 959 console.error('touchmove when no slot'); 960 return; 961 } else if (e.type == 'touchend') { 962 // error 963 console.error('touchend when no slot'); 964 return; 965 } 966 } 967 } 968 } 969 970 for (let i = 0; i < xArr.length; i++) { 971 xArr[i] = Math.trunc(xArr[i] * scaling); 972 yArr[i] = Math.trunc(yArr[i] * scaling); 973 } 974 975 // NOTE: Rotation is handled automatically because the CSS rotation through 976 // transforms also rotates the coordinates of events on the object. 977 978 const display_label = deviceDisplay.id; 979 980 this.#deviceConnection.sendMultiTouch( 981 {idArr, xArr, yArr, down: ctx.down, slotArr, display_label}); 982 } 983 984 #updateDisplayVisibility(displayId, powerMode) { 985 const displayVideo = document.getElementById('display_' + displayId); 986 if (displayVideo == null) { 987 console.error('Unknown display id: ' + displayId); 988 return; 989 } 990 991 const display = displayVideo.parentElement; 992 if (display == null) { 993 console.error('Unknown display id: ' + displayId); 994 return; 995 } 996 powerMode = powerMode.toLowerCase(); 997 switch (powerMode) { 998 case 'on': 999 display.style.visibility = 'visible'; 1000 break; 1001 case 'off': 1002 display.style.visibility = 'hidden'; 1003 break; 1004 default: 1005 console.error('Display ' + displayId + ' has unknown display power mode: ' + powerMode); 1006 } 1007 } 1008 1009 #onMicButton(evt) { 1010 let nextState = evt.type == 'mousedown'; 1011 if (this.#micActive == nextState) { 1012 return; 1013 } 1014 this.#micActive = nextState; 1015 this.#deviceConnection.useMic(nextState); 1016 } 1017 1018 #onCameraCaptureToggle(enabled) { 1019 return this.#deviceConnection.useCamera(enabled); 1020 } 1021 1022 #getZeroPaddedString(value, desiredLength) { 1023 const s = String(value); 1024 return '0'.repeat(desiredLength - s.length) + s; 1025 } 1026 1027 #getTimestampString() { 1028 const now = new Date(); 1029 return [ 1030 now.getFullYear(), 1031 this.#getZeroPaddedString(now.getMonth(), 2), 1032 this.#getZeroPaddedString(now.getDay(), 2), 1033 this.#getZeroPaddedString(now.getHours(), 2), 1034 this.#getZeroPaddedString(now.getMinutes(), 2), 1035 this.#getZeroPaddedString(now.getSeconds(), 2), 1036 ].join('_'); 1037 } 1038 1039 #onVideoCaptureToggle(enabled) { 1040 const recordToggle = document.getElementById('record-video-control'); 1041 if (enabled) { 1042 let recorders = []; 1043 1044 const timestamp = this.#getTimestampString(); 1045 1046 let deviceDisplayVideoList = 1047 document.getElementsByClassName('device-display-video'); 1048 for (let i = 0; i < deviceDisplayVideoList.length; i++) { 1049 const deviceDisplayVideo = deviceDisplayVideoList[i]; 1050 1051 const recorder = new MediaRecorder(deviceDisplayVideo.captureStream()); 1052 const recordedData = []; 1053 1054 recorder.ondataavailable = event => recordedData.push(event.data); 1055 recorder.onstop = event => { 1056 const recording = new Blob(recordedData, { type: "video/webm" }); 1057 1058 const downloadLink = document.createElement('a'); 1059 downloadLink.setAttribute('download', timestamp + '_display_' + i + '.webm'); 1060 downloadLink.setAttribute('href', URL.createObjectURL(recording)); 1061 downloadLink.click(); 1062 }; 1063 1064 recorder.start(); 1065 recorders.push(recorder); 1066 } 1067 this.#recording['recorders'] = recorders; 1068 1069 recordToggle.style.backgroundColor = 'red'; 1070 } else { 1071 for (const recorder of this.#recording['recorders']) { 1072 recorder.stop(); 1073 } 1074 recordToggle.style.backgroundColor = ''; 1075 } 1076 return Promise.resolve(enabled); 1077 } 1078 1079 #onAudioPlaybackToggle(enabled) { 1080 const audioElem = document.getElementById('device-audio'); 1081 if (enabled) { 1082 audioElem.play(); 1083 } else { 1084 audioElem.pause(); 1085 } 1086 } 1087 1088 #onCustomShellButton(shell_command, e) { 1089 // Attempt to init adb again, in case the initial connection failed. 1090 // This succeeds immediately if already connected. 1091 this.#initializeAdb(); 1092 if (e.type == 'mousedown') { 1093 adbShell(shell_command); 1094 } 1095 } 1096} // DeviceControlApp 1097 1098window.addEventListener("load", async evt => { 1099 try { 1100 setupMessages(); 1101 let connectorModule = await import('./server_connector.js'); 1102 let deviceId = connectorModule.deviceId(); 1103 document.title = deviceId; 1104 let deviceConnection = 1105 await ConnectDevice(deviceId, await connectorModule.createConnector()); 1106 let parentController = null; 1107 if (connectorModule.createParentController) { 1108 parentController = connectorModule.createParentController(); 1109 } 1110 if (!parentController) { 1111 parentController = new EmptyParentController(); 1112 } 1113 let deviceControlApp = new DeviceControlApp(deviceConnection, parentController); 1114 deviceControlApp.start(); 1115 document.getElementById('device-connection').style.display = 'block'; 1116 } catch(err) { 1117 console.error('Unable to connect: ', err); 1118 showError( 1119 'No connection to the guest device. ' + 1120 'Please ensure the WebRTC process on the host machine is active.'); 1121 } 1122 document.getElementById('loader').style.display = 'none'; 1123}); 1124 1125// The formulas in this function are derived from the following facts: 1126// * The video element's aspect ratio (ar) is fixed. 1127// * CSS rotations are centered on the geometrical center of the element. 1128// * The aspect ratio is the tangent of the angle between the left-top to 1129// right-bottom diagonal (d) and the left side. 1130// * d = w/sin(arctan(ar)) = h/cos(arctan(ar)), with w = width and h = height. 1131// * After any rotation, the element's total width is the maximum size of the 1132// projection of the diagonals on the X axis (Y axis for height). 1133// Deriving the formulas is left as an exercise to the reader. 1134function getStyleAfterRotation(rotationDeg, ar) { 1135 // Convert the rotation angle to radians 1136 let r = Math.PI * rotationDeg / 180; 1137 1138 // width <= parent_with / abs(cos(r) + sin(r)/ar) 1139 // and 1140 // width <= parent_with / abs(cos(r) - sin(r)/ar) 1141 let den1 = Math.abs((Math.sin(r) / ar) + Math.cos(r)); 1142 let den2 = Math.abs((Math.sin(r) / ar) - Math.cos(r)); 1143 let denominator = Math.max(den1, den2); 1144 let maxWidth = `calc(100% / ${denominator})`; 1145 1146 // height <= parent_height / abs(cos(r) + sin(r)*ar) 1147 // and 1148 // height <= parent_height / abs(cos(r) - sin(r)*ar) 1149 den1 = Math.abs(Math.cos(r) - (Math.sin(r) * ar)); 1150 den2 = Math.abs(Math.cos(r) + (Math.sin(r) * ar)); 1151 denominator = Math.max(den1, den2); 1152 let maxHeight = `calc(100% / ${denominator})`; 1153 1154 // rotated_left >= left * (abs(cos(r)+sin(r)/ar)-1)/2 1155 // and 1156 // rotated_left >= left * (abs(cos(r)-sin(r)/ar)-1)/2 1157 let tmp1 = Math.max( 1158 Math.abs(Math.cos(r) + (Math.sin(r) / ar)), 1159 Math.abs(Math.cos(r) - (Math.sin(r) / ar))); 1160 let leftFactor = (tmp1 - 1) / 2; 1161 // rotated_top >= top * (abs(cos(r)+sin(r)*ar)-1)/2 1162 // and 1163 // rotated_top >= top * (abs(cos(r)-sin(r)*ar)-1)/2 1164 let tmp2 = Math.max( 1165 Math.abs(Math.cos(r) - (Math.sin(r) * ar)), 1166 Math.abs(Math.cos(r) + (Math.sin(r) * ar))); 1167 let rightFactor = (tmp2 - 1) / 2; 1168 1169 // CSS rotations are in the opposite direction as Android screen rotations 1170 rotationDeg = -rotationDeg; 1171 1172 let transform = `translate(calc(100% * ${leftFactor}), calc(100% * ${ 1173 rightFactor})) rotate(${rotationDeg}deg)`; 1174 1175 return {transform, maxWidth, maxHeight}; 1176} 1177