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 every time. 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 this.#deviceConnection.onLightsMessage(msg => this.#onLightsMessage(msg)); 156 this.#deviceConnection.onSensorsMessage(msg => this.#onSensorsMessage(msg)); 157 createToggleControl( 158 document.getElementById('camera_off_btn'), 159 enabled => this.#onCameraCaptureToggle(enabled)); 160 // disable the camera button if we are not using VSOCK camera 161 if (!this.#deviceConnection.description.hardware.camera_passthrough) { 162 document.getElementById('camera_off_btn').style.display = "none"; 163 } 164 createToggleControl( 165 document.getElementById('record_video_btn'), 166 enabled => this.#onVideoCaptureToggle(enabled)); 167 const audioElm = document.getElementById('device-audio'); 168 169 let audioPlaybackCtrl = createToggleControl( 170 document.getElementById('volume_off_btn'), 171 enabled => this.#onAudioPlaybackToggle(enabled), !audioElm.paused); 172 // The audio element may start or stop playing at any time, this ensures the 173 // audio control always show the right state. 174 audioElm.onplay = () => audioPlaybackCtrl.Set(true); 175 audioElm.onpause = () => audioPlaybackCtrl.Set(false); 176 177 // Enable non-ADB buttons, these buttons use data channels to communicate 178 // with the host, so they're ready to go as soon as the webrtc connection is 179 // established. 180 this.#getControlPanelButtons() 181 .filter(b => !b.dataset.adb) 182 .forEach(b => b.disabled = false); 183 184 this.#showDeviceUI(); 185 } 186 187 #showDeviceUI() { 188 // Set up control panel buttons 189 addMouseListeners( 190 document.querySelector('#power_btn'), 191 evt => this.#onControlPanelButton(evt, 'power')); 192 addMouseListeners( 193 document.querySelector('#back_btn'), 194 evt => this.#onControlPanelButton(evt, 'back')); 195 addMouseListeners( 196 document.querySelector('#home_btn'), 197 evt => this.#onControlPanelButton(evt, 'home')); 198 addMouseListeners( 199 document.querySelector('#menu_btn'), 200 evt => this.#onControlPanelButton(evt, 'menu')); 201 addMouseListeners( 202 document.querySelector('#rotate_left_btn'), 203 evt => this.#onRotateLeftButton(evt, 'rotate')); 204 addMouseListeners( 205 document.querySelector('#rotate_right_btn'), 206 evt => this.#onRotateRightButton(evt, 'rotate')); 207 addMouseListeners( 208 document.querySelector('#volume_up_btn'), 209 evt => this.#onControlPanelButton(evt, 'volumeup')); 210 addMouseListeners( 211 document.querySelector('#volume_down_btn'), 212 evt => this.#onControlPanelButton(evt, 'volumedown')); 213 addMouseListeners( 214 document.querySelector('#mic_btn'), evt => this.#onMicButton(evt)); 215 216 createModalButton( 217 'device-details-button', 'device-details-modal', 218 'device-details-close'); 219 createModalButton( 220 'rotation-modal-button', 'rotation-modal', 221 'rotation-modal-close'); 222 createModalButton( 223 'touchpad-modal-button', 'touchpad-modal', 224 'touchpad-modal-close'); 225 createModalButton( 226 'bluetooth-modal-button', 'bluetooth-prompt', 'bluetooth-prompt-close'); 227 createModalButton( 228 'bluetooth-prompt-wizard', 'bluetooth-wizard', 'bluetooth-wizard-close', 229 'bluetooth-prompt'); 230 createModalButton( 231 'bluetooth-wizard-device', 'bluetooth-wizard-confirm', 232 'bluetooth-wizard-confirm-close', 'bluetooth-wizard'); 233 createModalButton( 234 'bluetooth-wizard-another', 'bluetooth-wizard', 235 'bluetooth-wizard-close', 'bluetooth-wizard-confirm'); 236 createModalButton( 237 'bluetooth-prompt-list', 'bluetooth-list', 'bluetooth-list-close', 238 'bluetooth-prompt'); 239 createModalButton( 240 'bluetooth-prompt-console', 'bluetooth-console', 241 'bluetooth-console-close', 'bluetooth-prompt'); 242 createModalButton( 243 'bluetooth-wizard-cancel', 'bluetooth-prompt', 'bluetooth-wizard-close', 244 'bluetooth-wizard'); 245 246 createModalButton('location-modal-button', 'location-prompt-modal', 247 'location-prompt-modal-close'); 248 createModalButton( 249 'location-set-wizard', 'location-set-modal', 'location-set-modal-close', 250 'location-prompt-modal'); 251 252 createModalButton( 253 'locations-import-wizard', 'locations-import-modal', 'locations-import-modal-close', 254 'location-prompt-modal'); 255 createModalButton( 256 'location-set-cancel', 'location-prompt-modal', 'location-set-modal-close', 257 'location-set-modal'); 258 positionModal('rotation-modal-button', 'rotation-modal'); 259 positionModal('device-details-button', 'bluetooth-modal'); 260 positionModal('device-details-button', 'bluetooth-prompt'); 261 positionModal('device-details-button', 'bluetooth-wizard'); 262 positionModal('device-details-button', 'bluetooth-wizard-confirm'); 263 positionModal('device-details-button', 'bluetooth-list'); 264 positionModal('device-details-button', 'bluetooth-console'); 265 266 positionModal('device-details-button', 'location-modal'); 267 positionModal('device-details-button', 'location-prompt-modal'); 268 positionModal('device-details-button', 'location-set-modal'); 269 positionModal('device-details-button', 'locations-import-modal'); 270 271 createButtonListener('bluetooth-prompt-list', null, this.#deviceConnection, 272 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 273 createButtonListener('bluetooth-wizard-device', null, this.#deviceConnection, 274 evt => this.#onRootCanalCommand(this.#deviceConnection, "add", evt)); 275 createButtonListener('bluetooth-list-trash', null, this.#deviceConnection, 276 evt => this.#onRootCanalCommand(this.#deviceConnection, "del", evt)); 277 createButtonListener('bluetooth-prompt-wizard', null, this.#deviceConnection, 278 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 279 createButtonListener('bluetooth-wizard-another', null, this.#deviceConnection, 280 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 281 282 createButtonListener('locations-send-btn', null, this.#deviceConnection, 283 evt => this.#onImportLocationsFile(this.#deviceConnection,evt)); 284 285 createButtonListener('location-set-confirm', null, this.#deviceConnection, 286 evt => this.#onSendLocation(this.#deviceConnection, evt)); 287 288 createButtonListener('left-position-button', null, this.#deviceConnection, 289 () => this.#setOrientation(-90)); 290 createButtonListener('upright-position-button', null, this.#deviceConnection, 291 () => this.#setOrientation(0)); 292 293 createButtonListener('right-position-button', null, this.#deviceConnection, 294 () => this.#setOrientation(90)); 295 296 createButtonListener('upside-position-button', null, this.#deviceConnection, 297 () => this.#setOrientation(-180)); 298 299 createSliderListener('rotation-slider', () => this.#onMotionChanged(this.#deviceConnection)); 300 301 if (this.#deviceConnection.description.custom_control_panel_buttons.length > 302 0) { 303 document.getElementById('control-panel-custom-buttons').style.display = 304 'flex'; 305 for (const button of this.#deviceConnection.description 306 .custom_control_panel_buttons) { 307 if (button.shell_command) { 308 // This button's command is handled by sending an ADB shell command. 309 let element = createControlPanelButton( 310 button.title, button.icon_name, 311 e => this.#onCustomShellButton(button.shell_command, e), 312 'control-panel-custom-buttons'); 313 element.dataset.adb = true; 314 } else if (button.device_states) { 315 // This button corresponds to variable hardware device state(s). 316 let element = createControlPanelButton( 317 button.title, button.icon_name, 318 this.#getCustomDeviceStateButtonCb(button.device_states), 319 'control-panel-custom-buttons'); 320 for (const device_state of button.device_states) { 321 // hinge_angle is currently injected via an adb shell command that 322 // triggers a guest binary. 323 if ('hinge_angle_value' in device_state) { 324 element.dataset.adb = true; 325 } 326 } 327 } else { 328 // This button's command is handled by custom action server. 329 createControlPanelButton( 330 button.title, button.icon_name, 331 evt => this.#onControlPanelButton(evt, button.command), 332 'control-panel-custom-buttons'); 333 } 334 } 335 } 336 337 // Set up displays 338 this.#updateDeviceDisplays(); 339 this.#deviceConnection.onStreamChange(stream => this.#onStreamChange(stream)); 340 341 // Set up audio 342 const deviceAudio = document.getElementById('device-audio'); 343 for (const audio_desc of this.#deviceConnection.description.audio_streams) { 344 let stream_id = audio_desc.stream_id; 345 this.#deviceConnection.onStream(stream_id) 346 .then(stream => { 347 deviceAudio.srcObject = stream; 348 deviceAudio.play(); 349 }) 350 .catch(e => console.error('Unable to get audio stream: ', e)); 351 } 352 353 // Set up keyboard and wheel capture 354 this.#startKeyboardCapture(); 355 this.#startWheelCapture(); 356 357 this.#updateDeviceHardwareDetails( 358 this.#deviceConnection.description.hardware); 359 360 // Show the error message and disable buttons when the WebRTC connection 361 // fails. 362 this.#deviceConnection.onConnectionStateChange(state => { 363 if (state == 'disconnected' || state == 'failed') { 364 this.#showWebrtcError(); 365 } 366 }); 367 368 let bluetoothConsole = 369 cmdConsole('bluetooth-console-view', 'bluetooth-console-input'); 370 bluetoothConsole.addCommandListener(cmd => { 371 let inputArr = cmd.split(' '); 372 let command = inputArr[0]; 373 inputArr.shift(); 374 let args = inputArr; 375 this.#deviceConnection.sendBluetoothMessage( 376 createRootcanalMessage(command, args)); 377 }); 378 this.#deviceConnection.onBluetoothMessage(msg => { 379 let decoded = decodeRootcanalMessage(msg); 380 let deviceCount = btUpdateDeviceList(decoded); 381 console.debug("deviceCount= " +deviceCount); 382 console.debug("decoded= " +decoded); 383 if (deviceCount > 0) { 384 this.#deviceCount = deviceCount; 385 createButtonListener('bluetooth-list-trash', null, this.#deviceConnection, 386 evt => this.#onRootCanalCommand(this.#deviceConnection, "del", evt)); 387 } 388 btUpdateAdded(decoded); 389 let phyList = btParsePhys(decoded); 390 if (phyList) { 391 this.#phys = phyList; 392 } 393 bluetoothConsole.addLine(decoded); 394 }); 395 396 this.#deviceConnection.onLocationMessage(msg => { 397 console.debug("onLocationMessage = " +msg); 398 }); 399 } 400 401 #onStreamChange(stream) { 402 let stream_id = stream.id; 403 if (stream_id.startsWith('display_')) { 404 this.#updateDeviceDisplays(); 405 } 406 } 407 408 #onRootCanalCommand(deviceConnection, cmd, evt) { 409 410 if (cmd == "list") { 411 deviceConnection.sendBluetoothMessage(createRootcanalMessage("list", [])); 412 } 413 if (cmd == "del") { 414 let id = evt.srcElement.getAttribute("data-device-id"); 415 deviceConnection.sendBluetoothMessage(createRootcanalMessage("del", [id])); 416 deviceConnection.sendBluetoothMessage(createRootcanalMessage("list", [])); 417 } 418 if (cmd == "add") { 419 let name = document.getElementById('bluetooth-wizard-name').value; 420 let type = document.getElementById('bluetooth-wizard-type').value; 421 if (type == "remote_loopback") { 422 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add", [type])); 423 } else { 424 let mac = document.getElementById('bluetooth-wizard-mac').value; 425 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add", [type, mac])); 426 } 427 let phyId = this.#phys["LOW_ENERGY"].toString(); 428 if (type == "remote_loopback") { 429 phyId = this.#phys["BR_EDR"].toString(); 430 } 431 let devId = this.#deviceCount.toString(); 432 this.#deviceCount++; 433 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add_device_to_phy", [devId, phyId])); 434 } 435 } 436 437 #onSendLocation(deviceConnection, evt) { 438 439 let longitude = document.getElementById('location-set-longitude').value; 440 let latitude = document.getElementById('location-set-latitude').value; 441 let altitude = document.getElementById('location-set-altitude').value; 442 if (longitude == null || longitude == '' || latitude == null || latitude == ''|| 443 altitude == null || altitude == '') { 444 return; 445 } 446 let location_msg = longitude + "," +latitude + "," + altitude; 447 deviceConnection.sendLocationMessage(location_msg); 448 } 449 450 async #onSensorsMessage(message) { 451 var decoder = new TextDecoder("utf-8"); 452 message = decoder.decode(message.data); 453 454 // Get sensor values from message. 455 var sensor_vals = message.split(" "); 456 sensor_vals = sensor_vals.map((val) => parseFloat(val).toFixed(3)); 457 458 const acc_val = document.getElementById('accelerometer-value'); 459 const mgn_val = document.getElementById('magnetometer-value'); 460 const gyro_val = document.getElementById('gyroscope-value'); 461 const xyz_val = document.getElementsByClassName('rotation-slider-value'); 462 const xyz_range = document.getElementsByClassName('rotation-slider-range'); 463 464 // TODO: move to webrtc backend. 465 // Inject sensors with new values. 466 adbShell(`/vendor/bin/cuttlefish_sensor_injection motion ${sensor_vals[3]} ${sensor_vals[4]} ${sensor_vals[5]} ${sensor_vals[6]} ${sensor_vals[7]} ${sensor_vals[8]} ${sensor_vals[9]} ${sensor_vals[10]} ${sensor_vals[11]}`); 467 468 // Display new sensor values after injection. 469 acc_val.textContent = `${sensor_vals[3]} ${sensor_vals[4]} ${sensor_vals[5]}`; 470 mgn_val.textContent = `${sensor_vals[6]} ${sensor_vals[7]} ${sensor_vals[8]}`; 471 gyro_val.textContent = `${sensor_vals[9]} ${sensor_vals[10]} ${sensor_vals[11]}`; 472 473 // Update xyz sliders with backend values. 474 // This is needed for preserving device's state when display is turned on 475 // and off, and for having the same state for multiple clients. 476 for(let i = 0; i < 3; i++) { 477 xyz_val[i].textContent = sensor_vals[i]; 478 xyz_range[i].value = sensor_vals[i]; 479 } 480 } 481 482 // Send new rotation angles for sensor values' processing. 483 #onMotionChanged(deviceConnection = this.#deviceConnection) { 484 let values = document.getElementsByClassName('rotation-slider-value'); 485 let xyz = []; 486 for (var i = 0; i < values.length; i++) { 487 xyz[i] = values[i].innerHTML; 488 } 489 deviceConnection.sendSensorsMessage(`${xyz[0]} ${xyz[1]} ${xyz[2]}`); 490 } 491 492 // Gradually rotate to a fixed orientation. 493 #setOrientation(z) { 494 const sliders = document.getElementsByClassName('rotation-slider-range'); 495 const values = document.getElementsByClassName('rotation-slider-value'); 496 if (sliders.length != values.length && sliders.length != 3) { 497 return; 498 } 499 // Set XY axes to 0 (upright position). 500 sliders[0].value = '0'; 501 values[0].textContent = '0'; 502 sliders[1].value = '0'; 503 values[1].textContent = '0'; 504 505 // Gradually transition z axis to target angle. 506 let current_z = parseFloat(sliders[2].value); 507 const step = ((z > current_z) ? 0.5 : -0.5); 508 let move = setInterval(() => { 509 if (Math.abs(z - current_z) >= 0.5) { 510 current_z += step; 511 } 512 else { 513 current_z = z; 514 } 515 sliders[2].value = current_z; 516 values[2].textContent = `${current_z}`; 517 this.#onMotionChanged(); 518 if (current_z == z) { 519 this.#onMotionChanged(); 520 clearInterval(move); 521 } 522 }, 5); 523 } 524 525 #onImportLocationsFile(deviceConnection, evt) { 526 527 function onLoad_send_kml_data(xml) { 528 deviceConnection.sendKmlLocationsMessage(xml); 529 } 530 531 function onLoad_send_gpx_data(xml) { 532 deviceConnection.sendGpxLocationsMessage(xml); 533 } 534 535 let file_selector=document.getElementById("locations_select_file"); 536 537 if (!file_selector.files) { 538 alert("input parameter is not of file type"); 539 return; 540 } 541 542 if (!file_selector.files[0]) { 543 alert("Please select a file "); 544 return; 545 } 546 547 var filename= file_selector.files[0]; 548 if (filename.type.match('\gpx')) { 549 console.debug("import Gpx locations handling"); 550 loadFile(onLoad_send_gpx_data); 551 } else if(filename.type.match('\kml')){ 552 console.debug("import Kml locations handling"); 553 loadFile(onLoad_send_kml_data); 554 } 555 556 } 557 558 #showWebrtcError() { 559 showError( 560 'No connection to the guest device. Please ensure the WebRTC' + 561 'process on the host machine is active.'); 562 const deviceDisplays = document.getElementById('device-displays'); 563 deviceDisplays.style.display = 'none'; 564 this.#getControlPanelButtons().forEach(b => b.disabled = true); 565 } 566 567 #getControlPanelButtons() { 568 return [ 569 ...document.querySelectorAll('#control-panel-default-buttons button'), 570 ...document.querySelectorAll('#control-panel-custom-buttons button'), 571 ]; 572 } 573 574 #takePhoto() { 575 const imageCapture = this.#deviceConnection.imageCapture; 576 if (imageCapture) { 577 const photoSettings = { 578 imageWidth: this.#deviceConnection.cameraWidth, 579 imageHeight: this.#deviceConnection.cameraHeight 580 }; 581 imageCapture.takePhoto(photoSettings) 582 .then(blob => blob.arrayBuffer()) 583 .then(buffer => this.#deviceConnection.sendOrQueueCameraData(buffer)) 584 .catch(error => console.error(error)); 585 } 586 } 587 588 #getCustomDeviceStateButtonCb(device_states) { 589 let states = device_states; 590 let index = 0; 591 return e => { 592 if (e.type == 'mousedown') { 593 // Reset any overridden device state. 594 adbShell('cmd device_state state reset'); 595 // Send a device_state message for the current state. 596 let message = { 597 command: 'device_state', 598 ...states[index], 599 }; 600 this.#deviceConnection.sendControlMessage(JSON.stringify(message)); 601 console.debug('Control message sent: ', JSON.stringify(message)); 602 let lidSwitchOpen = null; 603 if ('lid_switch_open' in states[index]) { 604 lidSwitchOpen = states[index].lid_switch_open; 605 } 606 let hingeAngle = null; 607 if ('hinge_angle_value' in states[index]) { 608 hingeAngle = states[index].hinge_angle_value; 609 // TODO(b/181157794): Use a custom Sensor HAL for hinge_angle 610 // injection instead of this guest binary. 611 adbShell( 612 '/vendor/bin/cuttlefish_sensor_injection hinge_angle ' + 613 states[index].hinge_angle_value); 614 } 615 // Update the Device Details view. 616 this.#updateDeviceStateDetails(lidSwitchOpen, hingeAngle); 617 // Cycle to the next state. 618 index = (index + 1) % states.length; 619 } 620 } 621 } 622 623 #rotateDisplays(rotation) { 624 if ((rotation - this.#currentRotation) % 360 == 0) { 625 return; 626 } 627 628 document.querySelectorAll('.device-display-video').forEach((v, i) => { 629 const width = v.videoWidth; 630 const height = v.videoHeight; 631 if (!width || !height) { 632 console.error('Stream dimensions not yet available?', v); 633 return; 634 } 635 636 const aspectRatio = width / height; 637 638 let keyFrames = []; 639 let from = this.#currentScreenStyles[v.id]; 640 if (from) { 641 // If the screen was already rotated, use that state as starting point, 642 // otherwise the animation will start at the element's default state. 643 keyFrames.push(from); 644 } 645 let to = getStyleAfterRotation(rotation, aspectRatio); 646 keyFrames.push(to); 647 v.animate(keyFrames, {duration: 400 /*ms*/, fill: 'forwards'}); 648 this.#currentScreenStyles[v.id] = to; 649 }); 650 651 this.#currentRotation = rotation; 652 this.#updateDeviceDisplaysInfo(); 653 } 654 655 #updateDeviceDisplaysInfo() { 656 let labels = document.querySelectorAll('.device-display-info'); 657 658 // #currentRotation is device's physical rotation and currently used to 659 // determine display's rotation. It would be obtained from device's 660 // accelerometer sensor. 661 let deviceDisplaysMessage = 662 this.#parentController.createDeviceDisplaysMessage( 663 this.#currentRotation); 664 665 labels.forEach(l => { 666 let deviceDisplay = l.closest('.device-display'); 667 if (deviceDisplay == null) { 668 console.error('Missing corresponding device display', l); 669 return; 670 } 671 672 let deviceDisplayVideo = 673 deviceDisplay.querySelector('.device-display-video'); 674 if (deviceDisplayVideo == null) { 675 console.error('Missing corresponding device display video', l); 676 return; 677 } 678 679 const DISPLAY_PREFIX = 'display_'; 680 let displayId = deviceDisplayVideo.id; 681 if (displayId == null || !displayId.startsWith(DISPLAY_PREFIX)) { 682 console.error('Unexpected device display video id', displayId); 683 return; 684 } 685 displayId = displayId.slice(DISPLAY_PREFIX.length); 686 687 let stream = deviceDisplayVideo.srcObject; 688 if (stream == null) { 689 console.error('Missing corresponding device display video stream', l); 690 return; 691 } 692 693 let text = `Display ${displayId} `; 694 695 let streamVideoTracks = stream.getVideoTracks(); 696 if (streamVideoTracks.length > 0) { 697 let streamSettings = stream.getVideoTracks()[0].getSettings(); 698 // Width and height may not be available immediately after the track is 699 // added but before frames arrive. 700 if ('width' in streamSettings && 'height' in streamSettings) { 701 let streamWidth = streamSettings.width; 702 let streamHeight = streamSettings.height; 703 704 deviceDisplaysMessage.addDisplay( 705 displayId, streamWidth, streamHeight); 706 707 text += `${streamWidth}x${streamHeight}`; 708 } 709 } 710 711 if (this.#currentRotation != 0) { 712 text += ` (Rotated ${this.#currentRotation}deg)` 713 } 714 715 l.textContent = text; 716 }); 717 718 deviceDisplaysMessage.send(); 719 } 720 721 #onControlMessage(message) { 722 let message_data = JSON.parse(message.data); 723 console.debug('Control message received: ', message_data) 724 let metadata = message_data.metadata; 725 if (message_data.event == 'VIRTUAL_DEVICE_BOOT_STARTED') { 726 // Start the adb connection after receiving the BOOT_STARTED message. 727 // (This is after the adbd start message. Attempting to connect 728 // immediately after adbd starts causes issues.) 729 this.#initializeAdb(); 730 } 731 if (message_data.event == 'VIRTUAL_DEVICE_SCREEN_CHANGED') { 732 this.#rotateDisplays(+metadata.rotation); 733 } 734 if (message_data.event == 'VIRTUAL_DEVICE_CAPTURE_IMAGE') { 735 if (this.#deviceConnection.cameraEnabled) { 736 this.#takePhoto(); 737 } 738 } 739 if (message_data.event == 'VIRTUAL_DEVICE_DISPLAY_POWER_MODE_CHANGED') { 740 this.#deviceConnection.expectStreamChange(); 741 this.#updateDisplayVisibility(metadata.display, metadata.mode); 742 } 743 } 744 745 #onLightsMessage(message) { 746 let message_data = JSON.parse(message.data); 747 // TODO(286106270): Add an UI component for this 748 console.debug('Lights message received: ', message_data) 749 } 750 751 #updateDeviceStateDetails(lidSwitchOpen, hingeAngle) { 752 let deviceStateDetailsTextLines = []; 753 if (lidSwitchOpen != null) { 754 let state = lidSwitchOpen ? 'Opened' : 'Closed'; 755 deviceStateDetailsTextLines.push(`Lid Switch - ${state}`); 756 } 757 if (hingeAngle != null) { 758 deviceStateDetailsTextLines.push(`Hinge Angle - ${hingeAngle}`); 759 } 760 let deviceStateDetailsText = deviceStateDetailsTextLines.join('\n'); 761 new DeviceDetailsUpdater() 762 .setDeviceStateDetailsText(deviceStateDetailsText) 763 .update(); 764 } 765 766 #updateDeviceHardwareDetails(hardware) { 767 let hardwareDetailsTextLines = []; 768 Object.keys(hardware).forEach((key) => { 769 let value = hardware[key]; 770 hardwareDetailsTextLines.push(`${key} - ${value}`); 771 }); 772 773 let hardwareDetailsText = hardwareDetailsTextLines.join('\n'); 774 new DeviceDetailsUpdater() 775 .setHardwareDetailsText(hardwareDetailsText) 776 .update(); 777 } 778 779 // Creates a <video> element and a <div> container element for each display. 780 // The extra <div> container elements are used to maintain the width and 781 // height of the device as the CSS 'transform' property used on the <video> 782 // element for rotating the device only affects the visuals of the element 783 // and not its layout. 784 #updateDeviceDisplays() { 785 let anyDisplayLoaded = false; 786 const deviceDisplays = document.getElementById('device-displays'); 787 788 const MAX_DISPLAYS = 16; 789 for (let i = 0; i < MAX_DISPLAYS; i++) { 790 const stream_id = 'display_' + i.toString(); 791 const stream = this.#deviceConnection.getStream(stream_id); 792 793 let deviceDisplayVideo = document.querySelector('#' + stream_id); 794 const deviceDisplayIsPresent = deviceDisplayVideo != null; 795 const deviceDisplayStreamIsActive = stream != null && stream.active; 796 if (deviceDisplayStreamIsActive == deviceDisplayIsPresent) { 797 continue; 798 } 799 800 if (deviceDisplayStreamIsActive) { 801 console.debug('Adding display', i); 802 803 let displayFragment = 804 document.querySelector('#display-template').content.cloneNode(true); 805 806 let deviceDisplayInfo = 807 displayFragment.querySelector('.device-display-info'); 808 deviceDisplayInfo.id = stream_id + '_info'; 809 810 deviceDisplayVideo = displayFragment.querySelector('video'); 811 deviceDisplayVideo.id = stream_id; 812 deviceDisplayVideo.srcObject = stream; 813 deviceDisplayVideo.addEventListener('loadeddata', (evt) => { 814 if (!anyDisplayLoaded) { 815 anyDisplayLoaded = true; 816 this.#onDeviceDisplayLoaded(); 817 } 818 }); 819 deviceDisplayVideo.addEventListener('loadedmetadata', (evt) => { 820 this.#updateDeviceDisplaysInfo(); 821 }); 822 823 this.#addMouseTracking(deviceDisplayVideo, scaleDisplayCoordinates); 824 825 deviceDisplays.appendChild(displayFragment); 826 827 // Confusingly, events for adding tracks occur on the peer connection 828 // but events for removing tracks occur on the stream. 829 stream.addEventListener('removetrack', evt => { 830 this.#updateDeviceDisplays(); 831 }); 832 833 this.#requestNewFrameForDisplay(i); 834 } else { 835 console.debug('Removing display', i); 836 837 let deviceDisplay = deviceDisplayVideo.closest('.device-display'); 838 if (deviceDisplay == null) { 839 console.error('Failed to find device display for ', stream_id); 840 } else { 841 deviceDisplays.removeChild(deviceDisplay); 842 } 843 } 844 } 845 846 this.#updateDeviceDisplaysInfo(); 847 } 848 849 #requestNewFrameForDisplay(display_number) { 850 let message = { 851 command: "display", 852 refresh_display: display_number, 853 }; 854 this.#deviceConnection.sendControlMessage(JSON.stringify(message)); 855 console.debug('Control message sent: ', JSON.stringify(message)); 856 } 857 858 #initializeAdb() { 859 init_adb( 860 this.#deviceConnection, () => this.#onAdbConnected(), 861 () => this.#showAdbError()); 862 } 863 864 #onAdbConnected() { 865 if (this.#adbConnected) { 866 return; 867 } 868 // Screen changed messages are not reported until after boot has completed. 869 // Certain default adb buttons change screen state, so wait for boot 870 // completion before enabling these buttons. 871 showInfo('adb connection established successfully.', 5000); 872 this.#adbConnected = true; 873 this.#getControlPanelButtons() 874 .filter(b => b.dataset.adb) 875 .forEach(b => b.disabled = false); 876 } 877 878 #showAdbError() { 879 showError('adb connection failed.'); 880 this.#getControlPanelButtons() 881 .filter(b => b.dataset.adb) 882 .forEach(b => b.disabled = true); 883 } 884 885 #initializeTouchpads() { 886 const touchpadListElem = document.getElementById("touchpad-list"); 887 const touchpadElementContainer = touchpadListElem.querySelector(".touchpads"); 888 const touchpadSelectorContainer = touchpadListElem.querySelector(".selectors"); 889 const touchpads = this.#deviceConnection.description.touchpads; 890 891 let setActiveTouchpad = (tab_touchpad_id, touchpad_num) => { 892 const touchPadElem = document.getElementById(tab_touchpad_id); 893 const tabButtonElem = document.getElementById("touch_button_" + touchpad_num); 894 895 touchpadElementContainer.querySelectorAll(".selected").forEach(e => e.classList.remove("selected")); 896 touchpadSelectorContainer.querySelectorAll(".selected").forEach(e => e.classList.remove("selected")); 897 898 touchPadElem.classList.add("selected"); 899 tabButtonElem.classList.add("selected"); 900 }; 901 902 for (let i = 0; i < touchpads.length; i++) { 903 const touchpad = touchpads[i]; 904 905 let touchPadElem = document.createElement("div"); 906 touchPadElem.classList.add("touchpad"); 907 touchPadElem.style.aspectRatio = touchpad.x_res / touchpad.y_res; 908 touchPadElem.id = touchpad.label; 909 this.#addMouseTracking(touchPadElem, makeScaleTouchpadCoordinates(touchpad)); 910 touchpadElementContainer.appendChild(touchPadElem); 911 912 let tabButtonElem = document.createElement("button"); 913 tabButtonElem.id = "touch_button_" + i; 914 tabButtonElem.innerHTML = "Touchpad " + i; 915 tabButtonElem.class = "touchpad-tab-button" 916 tabButtonElem.onclick = () => { 917 setActiveTouchpad(touchpad.label, i); 918 }; 919 touchpadSelectorContainer.appendChild(tabButtonElem); 920 } 921 922 if (touchpads.length > 0) { 923 document.getElementById("touchpad-modal-button").style.display = "block"; 924 setActiveTouchpad(touchpads[0].label, 0); 925 } 926 } 927 928 #onDeviceDisplayLoaded() { 929 if (!this.#adbConnected) { 930 // ADB may have connected before, don't show this message in that case 931 showInfo('Awaiting bootup and adb connection. Please wait...', 10000); 932 } 933 this.#updateDeviceDisplaysInfo(); 934 935 let deviceDisplayList = document.getElementsByClassName('device-display'); 936 for (const deviceDisplay of deviceDisplayList) { 937 deviceDisplay.style.visibility = 'visible'; 938 } 939 940 this.#initializeTouchpads(); 941 942 // Start the adb connection if it is not already started. 943 this.#initializeAdb(); 944 // TODO(b/297361564) 945 this.#onMotionChanged(); 946 } 947 948 #onRotateLeftButton(e) { 949 if (e.type == 'mousedown') { 950 this.#onRotateButton(this.#currentRotation + 90); 951 } 952 } 953 954 #onRotateRightButton(e) { 955 if (e.type == 'mousedown') { 956 this.#onRotateButton(this.#currentRotation - 90); 957 } 958 } 959 960 #onRotateButton(rotation) { 961 // Attempt to init adb again, in case the initial connection failed. 962 // This succeeds immediately if already connected. 963 this.#initializeAdb(); 964 this.#rotateDisplays(rotation); 965 adbShell(`/vendor/bin/cuttlefish_sensor_injection rotate ${rotation}`); 966 } 967 968 #onControlPanelButton(e, command) { 969 if (e.type == 'mouseout' && e.which == 0) { 970 // Ignore mouseout events if no mouse button is pressed. 971 return; 972 } 973 this.#deviceConnection.sendControlMessage(JSON.stringify({ 974 command: command, 975 button_state: e.type == 'mousedown' ? 'down' : 'up', 976 })); 977 } 978 979 #startKeyboardCapture() { 980 const deviceArea = document.querySelector('#device-displays'); 981 deviceArea.addEventListener('keydown', evt => this.#onKeyEvent(evt)); 982 deviceArea.addEventListener('keyup', evt => this.#onKeyEvent(evt)); 983 } 984 985 #onKeyEvent(e) { 986 if (e.cancelable) { 987 // Some keyboard events cause unwanted side effects, like elements losing 988 // focus, if the default behavior is not prevented. 989 e.preventDefault(); 990 } 991 this.#deviceConnection.sendKeyEvent(e.code, e.type); 992 } 993 994 #startWheelCapture() { 995 const deviceArea = document.querySelector('#device-displays'); 996 deviceArea.addEventListener('wheel', evt => this.#onWheelEvent(evt), 997 { passive: false }); 998 } 999 1000 #onWheelEvent(e) { 1001 e.preventDefault(); 1002 // Vertical wheel pixel events only 1003 if (e.deltaMode == WheelEvent.DOM_DELTA_PIXEL && e.deltaY != 0.0) { 1004 this.#deviceConnection.sendWheelEvent(e.deltaY); 1005 } 1006 } 1007 1008 #addMouseTracking(touchInputElement, scaleCoordinates) { 1009 trackPointerEvents(touchInputElement, this.#deviceConnection, scaleCoordinates); 1010 } 1011 1012 #updateDisplayVisibility(displayId, powerMode) { 1013 const displayVideo = document.getElementById('display_' + displayId); 1014 if (displayVideo == null) { 1015 console.error('Unknown display id: ' + displayId); 1016 return; 1017 } 1018 1019 const display = displayVideo.parentElement; 1020 if (display == null) { 1021 console.error('Unknown display id: ' + displayId); 1022 return; 1023 } 1024 1025 const display_number = parseInt(displayId); 1026 if (isNaN(display_number)) { 1027 console.error('Invalid display id: ' + displayId); 1028 return; 1029 } 1030 1031 powerMode = powerMode.toLowerCase(); 1032 switch (powerMode) { 1033 case 'on': 1034 display.style.visibility = 'visible'; 1035 this.#requestNewFrameForDisplay(display_number); 1036 break; 1037 case 'off': 1038 display.style.visibility = 'hidden'; 1039 break; 1040 default: 1041 console.error('Display ' + displayId + ' has unknown display power mode: ' + powerMode); 1042 } 1043 } 1044 1045 #onMicButton(evt) { 1046 let nextState = evt.type == 'mousedown'; 1047 if (this.#micActive == nextState) { 1048 return; 1049 } 1050 this.#micActive = nextState; 1051 this.#deviceConnection.useMic(nextState); 1052 } 1053 1054 #onCameraCaptureToggle(enabled) { 1055 return this.#deviceConnection.useCamera(enabled); 1056 } 1057 1058 #getZeroPaddedString(value, desiredLength) { 1059 const s = String(value); 1060 return '0'.repeat(desiredLength - s.length) + s; 1061 } 1062 1063 #getTimestampString() { 1064 const now = new Date(); 1065 return [ 1066 now.getFullYear(), 1067 this.#getZeroPaddedString(now.getMonth(), 2), 1068 this.#getZeroPaddedString(now.getDay(), 2), 1069 this.#getZeroPaddedString(now.getHours(), 2), 1070 this.#getZeroPaddedString(now.getMinutes(), 2), 1071 this.#getZeroPaddedString(now.getSeconds(), 2), 1072 ].join('_'); 1073 } 1074 1075 #onVideoCaptureToggle(enabled) { 1076 const recordToggle = document.getElementById('record-video-control'); 1077 if (enabled) { 1078 let recorders = []; 1079 1080 const timestamp = this.#getTimestampString(); 1081 1082 let deviceDisplayVideoList = 1083 document.getElementsByClassName('device-display-video'); 1084 for (let i = 0; i < deviceDisplayVideoList.length; i++) { 1085 const deviceDisplayVideo = deviceDisplayVideoList[i]; 1086 1087 const recorder = new MediaRecorder(deviceDisplayVideo.captureStream()); 1088 const recordedData = []; 1089 1090 recorder.ondataavailable = event => recordedData.push(event.data); 1091 recorder.onstop = event => { 1092 const recording = new Blob(recordedData, { type: "video/webm" }); 1093 1094 const downloadLink = document.createElement('a'); 1095 downloadLink.setAttribute('download', timestamp + '_display_' + i + '.webm'); 1096 downloadLink.setAttribute('href', URL.createObjectURL(recording)); 1097 downloadLink.click(); 1098 }; 1099 1100 recorder.start(); 1101 recorders.push(recorder); 1102 } 1103 this.#recording['recorders'] = recorders; 1104 1105 recordToggle.style.backgroundColor = 'red'; 1106 } else { 1107 for (const recorder of this.#recording['recorders']) { 1108 recorder.stop(); 1109 } 1110 recordToggle.style.backgroundColor = ''; 1111 } 1112 return Promise.resolve(enabled); 1113 } 1114 1115 #onAudioPlaybackToggle(enabled) { 1116 const audioElem = document.getElementById('device-audio'); 1117 if (enabled) { 1118 audioElem.play(); 1119 } else { 1120 audioElem.pause(); 1121 } 1122 } 1123 1124 #onCustomShellButton(shell_command, e) { 1125 // Attempt to init adb again, in case the initial connection failed. 1126 // This succeeds immediately if already connected. 1127 this.#initializeAdb(); 1128 if (e.type == 'mousedown') { 1129 adbShell(shell_command); 1130 } 1131 } 1132} // DeviceControlApp 1133 1134window.addEventListener("load", async evt => { 1135 try { 1136 setupMessages(); 1137 let connectorModule = await import('./server_connector.js'); 1138 let deviceId = connectorModule.deviceId(); 1139 document.title = deviceId; 1140 let deviceConnection = 1141 await ConnectDevice(deviceId, await connectorModule.createConnector()); 1142 let parentController = null; 1143 if (connectorModule.createParentController) { 1144 parentController = connectorModule.createParentController(); 1145 } 1146 if (!parentController) { 1147 parentController = new EmptyParentController(); 1148 } 1149 let deviceControlApp = new DeviceControlApp(deviceConnection, parentController); 1150 deviceControlApp.start(); 1151 document.getElementById('device-connection').style.display = 'block'; 1152 } catch(err) { 1153 console.error('Unable to connect: ', err); 1154 showError( 1155 'No connection to the guest device. ' + 1156 'Please ensure the WebRTC process on the host machine is active.'); 1157 } 1158 document.getElementById('loader').style.display = 'none'; 1159}); 1160 1161// The formulas in this function are derived from the following facts: 1162// * The video element's aspect ratio (ar) is fixed. 1163// * CSS rotations are centered on the geometrical center of the element. 1164// * The aspect ratio is the tangent of the angle between the left-top to 1165// right-bottom diagonal (d) and the left side. 1166// * d = w/sin(arctan(ar)) = h/cos(arctan(ar)), with w = width and h = height. 1167// * After any rotation, the element's total width is the maximum size of the 1168// projection of the diagonals on the X axis (Y axis for height). 1169// Deriving the formulas is left as an exercise to the reader. 1170function getStyleAfterRotation(rotationDeg, ar) { 1171 // Convert the rotation angle to radians 1172 let r = Math.PI * rotationDeg / 180; 1173 1174 // width <= parent_with / abs(cos(r) + sin(r)/ar) 1175 // and 1176 // width <= parent_with / abs(cos(r) - sin(r)/ar) 1177 let den1 = Math.abs((Math.sin(r) / ar) + Math.cos(r)); 1178 let den2 = Math.abs((Math.sin(r) / ar) - Math.cos(r)); 1179 let denominator = Math.max(den1, den2); 1180 let maxWidth = `calc(100% / ${denominator})`; 1181 1182 // height <= parent_height / abs(cos(r) + sin(r)*ar) 1183 // and 1184 // height <= parent_height / abs(cos(r) - sin(r)*ar) 1185 den1 = Math.abs(Math.cos(r) - (Math.sin(r) * ar)); 1186 den2 = Math.abs(Math.cos(r) + (Math.sin(r) * ar)); 1187 denominator = Math.max(den1, den2); 1188 let maxHeight = `calc(100% / ${denominator})`; 1189 1190 // rotated_left >= left * (abs(cos(r)+sin(r)/ar)-1)/2 1191 // and 1192 // rotated_left >= left * (abs(cos(r)-sin(r)/ar)-1)/2 1193 let tmp1 = Math.max( 1194 Math.abs(Math.cos(r) + (Math.sin(r) / ar)), 1195 Math.abs(Math.cos(r) - (Math.sin(r) / ar))); 1196 let leftFactor = (tmp1 - 1) / 2; 1197 // rotated_top >= top * (abs(cos(r)+sin(r)*ar)-1)/2 1198 // and 1199 // rotated_top >= top * (abs(cos(r)-sin(r)*ar)-1)/2 1200 let tmp2 = Math.max( 1201 Math.abs(Math.cos(r) - (Math.sin(r) * ar)), 1202 Math.abs(Math.cos(r) + (Math.sin(r) * ar))); 1203 let rightFactor = (tmp2 - 1) / 2; 1204 1205 // CSS rotations are in the opposite direction as Android screen rotations 1206 rotationDeg = -rotationDeg; 1207 1208 let transform = `translate(calc(100% * ${leftFactor}), calc(100% * ${ 1209 rightFactor})) rotate(${rotationDeg}deg)`; 1210 1211 return {transform, maxWidth, maxHeight}; 1212} 1213