1032 lines
38 KiB
JavaScript
1032 lines
38 KiB
JavaScript
/*
|
|
* Copyright (C) 2019 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
async function ConnectDevice(deviceId, serverConnector) {
|
|
console.debug('Connect: ' + deviceId);
|
|
// Prepare messages in case of connection failure
|
|
let connectionAttemptDuration = 0;
|
|
const intervalMs = 15000;
|
|
let connectionInterval = setInterval(() => {
|
|
connectionAttemptDuration += intervalMs;
|
|
if (connectionAttemptDuration > 30000) {
|
|
showError(
|
|
'Connection should have occurred by now. ' +
|
|
'Please attempt to restart the guest device.');
|
|
clearInterval(connectionInterval);
|
|
} else if (connectionAttemptDuration > 15000) {
|
|
showWarning('Connection is taking longer than expected');
|
|
}
|
|
}, intervalMs);
|
|
|
|
let module = await import('./cf_webrtc.js');
|
|
let deviceConnection = await module.Connect(deviceId, serverConnector);
|
|
console.info('Connected to ' + deviceId);
|
|
clearInterval(connectionInterval);
|
|
return deviceConnection;
|
|
}
|
|
|
|
function setupMessages() {
|
|
let closeBtn = document.querySelector('#error-message .close-btn');
|
|
closeBtn.addEventListener('click', evt => {
|
|
evt.target.parentElement.className = 'hidden';
|
|
});
|
|
}
|
|
|
|
function showMessage(msg, className) {
|
|
let element = document.getElementById('error-message');
|
|
if (element.childNodes.length < 2) {
|
|
// First time, no text node yet
|
|
element.insertAdjacentText('afterBegin', msg);
|
|
} else {
|
|
element.childNodes[0].data = msg;
|
|
}
|
|
element.className = className;
|
|
}
|
|
|
|
function showWarning(msg) {
|
|
showMessage(msg, 'warning');
|
|
}
|
|
|
|
function showError(msg) {
|
|
showMessage(msg, 'error');
|
|
}
|
|
|
|
|
|
class DeviceDetailsUpdater {
|
|
#element;
|
|
|
|
constructor() {
|
|
this.#element = document.getElementById('device-details-hardware');
|
|
}
|
|
|
|
setHardwareDetailsText(text) {
|
|
this.#element.dataset.hardwareDetailsText = text;
|
|
return this;
|
|
}
|
|
|
|
setDeviceStateDetailsText(text) {
|
|
this.#element.dataset.deviceStateDetailsText = text;
|
|
return this;
|
|
}
|
|
|
|
update() {
|
|
this.#element.textContent =
|
|
[
|
|
this.#element.dataset.hardwareDetailsText,
|
|
this.#element.dataset.deviceStateDetailsText,
|
|
].filter(e => e /*remove empty*/)
|
|
.join('\n');
|
|
}
|
|
} // DeviceDetailsUpdater
|
|
|
|
class DeviceControlApp {
|
|
#deviceConnection = {};
|
|
#currentRotation = 0;
|
|
#displayDescriptions = [];
|
|
#buttons = {};
|
|
#recording = {};
|
|
#phys = {};
|
|
#deviceCount = 0;
|
|
|
|
constructor(deviceConnection) {
|
|
this.#deviceConnection = deviceConnection;
|
|
}
|
|
|
|
start() {
|
|
console.debug('Device description: ', this.#deviceConnection.description);
|
|
this.#deviceConnection.onControlMessage(msg => this.#onControlMessage(msg));
|
|
createToggleControl(
|
|
document.getElementById('keyboard-capture-control'), 'keyboard',
|
|
enabled => this.#onKeyboardCaptureToggle(enabled));
|
|
createToggleControl(
|
|
document.getElementById('mic-capture-control'), 'mic',
|
|
enabled => this.#onMicCaptureToggle(enabled));
|
|
createToggleControl(
|
|
document.getElementById('camera-control'), 'videocam',
|
|
enabled => this.#onCameraCaptureToggle(enabled));
|
|
createToggleControl(
|
|
document.getElementById('record-video-control'), 'movie_creation',
|
|
enabled => this.#onVideoCaptureToggle(enabled));
|
|
const audioElm = document.getElementById('device-audio');
|
|
|
|
let audioPlaybackCtrl = createToggleControl(
|
|
document.getElementById('audio-playback-control'), 'speaker',
|
|
enabled => this.#onAudioPlaybackToggle(enabled), !audioElm.paused);
|
|
// The audio element may start or stop playing at any time, this ensures the
|
|
// audio control always show the right state.
|
|
audioElm.onplay = () => audioPlaybackCtrl.Set(true);
|
|
audioElm.onpause = () => audioPlaybackCtrl.Set(false);
|
|
|
|
this.#showDeviceUI();
|
|
}
|
|
|
|
#showDeviceUI() {
|
|
window.onresize = evt => this.#resizeDeviceDisplays();
|
|
// Set up control panel buttons
|
|
this.#buttons = {};
|
|
this.#buttons['power'] = createControlPanelButton(
|
|
'power', 'Power', 'power_settings_new',
|
|
evt => this.#onControlPanelButton(evt));
|
|
this.#buttons['home'] = createControlPanelButton(
|
|
'home', 'Home', 'home', evt => this.#onControlPanelButton(evt));
|
|
this.#buttons['menu'] = createControlPanelButton(
|
|
'menu', 'Menu', 'menu', evt => this.#onControlPanelButton(evt));
|
|
this.#buttons['rotate'] = createControlPanelButton(
|
|
'rotate', 'Rotate', 'screen_rotation',
|
|
evt => this.#onRotateButton(evt));
|
|
this.#buttons['rotate'].adb = true;
|
|
this.#buttons['volumedown'] = createControlPanelButton(
|
|
'volumedown', 'Volume Down', 'volume_down',
|
|
evt => this.#onControlPanelButton(evt));
|
|
this.#buttons['volumeup'] = createControlPanelButton(
|
|
'volumeup', 'Volume Up', 'volume_up',
|
|
evt => this.#onControlPanelButton(evt));
|
|
|
|
createModalButton(
|
|
'device-details-button', 'device-details-modal',
|
|
'device-details-close');
|
|
createModalButton(
|
|
'bluetooth-modal-button', 'bluetooth-prompt',
|
|
'bluetooth-prompt-close');
|
|
createModalButton(
|
|
'bluetooth-prompt-wizard', 'bluetooth-wizard',
|
|
'bluetooth-wizard-close', 'bluetooth-prompt');
|
|
createModalButton(
|
|
'bluetooth-wizard-device', 'bluetooth-wizard-confirm',
|
|
'bluetooth-wizard-confirm-close', 'bluetooth-wizard');
|
|
createModalButton(
|
|
'bluetooth-wizard-another', 'bluetooth-wizard',
|
|
'bluetooth-wizard-close', 'bluetooth-wizard-confirm');
|
|
createModalButton(
|
|
'bluetooth-prompt-list', 'bluetooth-list',
|
|
'bluetooth-list-close', 'bluetooth-prompt');
|
|
createModalButton(
|
|
'bluetooth-prompt-console', 'bluetooth-console',
|
|
'bluetooth-console-close', 'bluetooth-prompt');
|
|
createModalButton(
|
|
'bluetooth-wizard-cancel', 'bluetooth-prompt',
|
|
'bluetooth-wizard-close', 'bluetooth-wizard');
|
|
|
|
positionModal('device-details-button', 'bluetooth-modal');
|
|
positionModal('device-details-button', 'bluetooth-prompt');
|
|
positionModal('device-details-button', 'bluetooth-wizard');
|
|
positionModal('device-details-button', 'bluetooth-wizard-confirm');
|
|
positionModal('device-details-button', 'bluetooth-list');
|
|
positionModal('device-details-button', 'bluetooth-console');
|
|
|
|
createButtonListener('bluetooth-prompt-list', null, this.#deviceConnection,
|
|
evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt));
|
|
createButtonListener('bluetooth-wizard-device', null, this.#deviceConnection,
|
|
evt => this.#onRootCanalCommand(this.#deviceConnection, "add", evt));
|
|
createButtonListener('bluetooth-list-trash', null, this.#deviceConnection,
|
|
evt => this.#onRootCanalCommand(this.#deviceConnection, "del", evt));
|
|
createButtonListener('bluetooth-prompt-wizard', null, this.#deviceConnection,
|
|
evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt));
|
|
createButtonListener('bluetooth-wizard-another', null, this.#deviceConnection,
|
|
evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt));
|
|
|
|
if (this.#deviceConnection.description.custom_control_panel_buttons.length >
|
|
0) {
|
|
document.getElementById('control-panel-custom-buttons').style.display =
|
|
'flex';
|
|
for (const button of this.#deviceConnection.description
|
|
.custom_control_panel_buttons) {
|
|
if (button.shell_command) {
|
|
// This button's command is handled by sending an ADB shell command.
|
|
this.#buttons[button.command] = createControlPanelButton(
|
|
button.command, button.title, button.icon_name,
|
|
e => this.#onCustomShellButton(button.shell_command, e),
|
|
'control-panel-custom-buttons');
|
|
this.#buttons[button.command].adb = true;
|
|
} else if (button.device_states) {
|
|
// This button corresponds to variable hardware device state(s).
|
|
this.#buttons[button.command] = createControlPanelButton(
|
|
button.command, button.title, button.icon_name,
|
|
this.#getCustomDeviceStateButtonCb(button.device_states),
|
|
'control-panel-custom-buttons');
|
|
for (const device_state of button.device_states) {
|
|
// hinge_angle is currently injected via an adb shell command that
|
|
// triggers a guest binary.
|
|
if ('hinge_angle_value' in device_state) {
|
|
this.#buttons[button.command].adb = true;
|
|
}
|
|
}
|
|
} else {
|
|
// This button's command is handled by custom action server.
|
|
this.#buttons[button.command] = createControlPanelButton(
|
|
button.command, button.title, button.icon_name,
|
|
evt => this.#onControlPanelButton(evt),
|
|
'control-panel-custom-buttons');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set up displays
|
|
this.#createDeviceDisplays();
|
|
|
|
// Set up audio
|
|
const deviceAudio = document.getElementById('device-audio');
|
|
for (const audio_desc of this.#deviceConnection.description.audio_streams) {
|
|
let stream_id = audio_desc.stream_id;
|
|
this.#deviceConnection.getStream(stream_id)
|
|
.then(stream => {
|
|
deviceAudio.srcObject = stream;
|
|
let playPromise = deviceAudio.play();
|
|
if (playPromise !== undefined) {
|
|
playPromise.catch(error => {
|
|
showWarning(
|
|
'Audio playback is disabled, click on the speaker control to activate it');
|
|
});
|
|
}
|
|
})
|
|
.catch(e => console.error('Unable to get audio stream: ', e));
|
|
}
|
|
|
|
// Set up touch input
|
|
this.#startMouseTracking();
|
|
|
|
this.#updateDeviceHardwareDetails(
|
|
this.#deviceConnection.description.hardware);
|
|
|
|
// Show the error message and disable buttons when the WebRTC connection
|
|
// fails.
|
|
this.#deviceConnection.onConnectionStateChange(state => {
|
|
if (state == 'disconnected' || state == 'failed') {
|
|
this.#showWebrtcError();
|
|
}
|
|
});
|
|
|
|
let bluetoothConsole =
|
|
cmdConsole('bluetooth-console-view', 'bluetooth-console-input');
|
|
bluetoothConsole.addCommandListener(cmd => {
|
|
let inputArr = cmd.split(' ');
|
|
let command = inputArr[0];
|
|
inputArr.shift();
|
|
let args = inputArr;
|
|
this.#deviceConnection.sendBluetoothMessage(
|
|
createRootcanalMessage(command, args));
|
|
});
|
|
this.#deviceConnection.onBluetoothMessage(msg => {
|
|
let decoded = decodeRootcanalMessage(msg);
|
|
let deviceCount = btUpdateDeviceList(decoded);
|
|
if (deviceCount > 0) {
|
|
this.#deviceCount = deviceCount;
|
|
createButtonListener('bluetooth-list-trash', null, this.#deviceConnection,
|
|
evt => this.#onRootCanalCommand(this.#deviceConnection, "del", evt));
|
|
}
|
|
btUpdateAdded(decoded);
|
|
let phyList = btParsePhys(decoded);
|
|
if (phyList) {
|
|
this.#phys = phyList;
|
|
}
|
|
bluetoothConsole.addLine(decoded);
|
|
});
|
|
}
|
|
|
|
#onRootCanalCommand(deviceConnection, cmd, evt) {
|
|
if (cmd == "list") {
|
|
deviceConnection.sendBluetoothMessage(createRootcanalMessage("list", []));
|
|
}
|
|
if (cmd == "del") {
|
|
let id = evt.srcElement.getAttribute("data-device-id");
|
|
deviceConnection.sendBluetoothMessage(createRootcanalMessage("del", [id]));
|
|
deviceConnection.sendBluetoothMessage(createRootcanalMessage("list", []));
|
|
}
|
|
if (cmd == "add") {
|
|
let name = document.getElementById('bluetooth-wizard-name').value;
|
|
let type = document.getElementById('bluetooth-wizard-type').value;
|
|
if (type == "remote_loopback") {
|
|
deviceConnection.sendBluetoothMessage(createRootcanalMessage("add", [type]));
|
|
} else {
|
|
let mac = document.getElementById('bluetooth-wizard-mac').value;
|
|
deviceConnection.sendBluetoothMessage(createRootcanalMessage("add", [type, mac]));
|
|
}
|
|
let phyId = this.#phys["LOW_ENERGY"].toString();
|
|
if (type == "remote_loopback") {
|
|
phyId = this.#phys["BR_EDR"].toString();
|
|
}
|
|
let devId = this.#deviceCount.toString();
|
|
this.#deviceCount++;
|
|
deviceConnection.sendBluetoothMessage(createRootcanalMessage("add_device_to_phy", [devId, phyId]));
|
|
}
|
|
}
|
|
|
|
#showWebrtcError() {
|
|
document.getElementById('status-message').className = 'error';
|
|
document.getElementById('status-message').textContent =
|
|
'No connection to the guest device. ' +
|
|
'Please ensure the WebRTC process on the host machine is active.';
|
|
document.getElementById('status-message').style.visibility = 'visible';
|
|
const deviceDisplays = document.getElementById('device-displays');
|
|
deviceDisplays.style.display = 'none';
|
|
for (const [_, button] of Object.entries(this.#buttons)) {
|
|
button.disabled = true;
|
|
}
|
|
}
|
|
|
|
#takePhoto() {
|
|
const imageCapture = this.#deviceConnection.imageCapture;
|
|
if (imageCapture) {
|
|
const photoSettings = {
|
|
imageWidth: this.#deviceConnection.cameraWidth,
|
|
imageHeight: this.#deviceConnection.cameraHeight
|
|
};
|
|
imageCapture.takePhoto(photoSettings)
|
|
.then(blob => blob.arrayBuffer())
|
|
.then(buffer => this.#deviceConnection.sendOrQueueCameraData(buffer))
|
|
.catch(error => console.error(error));
|
|
}
|
|
}
|
|
|
|
#getCustomDeviceStateButtonCb(device_states) {
|
|
let states = device_states;
|
|
let index = 0;
|
|
return e => {
|
|
if (e.type == 'mousedown') {
|
|
// Reset any overridden device state.
|
|
adbShell('cmd device_state state reset');
|
|
// Send a device_state message for the current state.
|
|
let message = {
|
|
command: 'device_state',
|
|
...states[index],
|
|
};
|
|
this.#deviceConnection.sendControlMessage(JSON.stringify(message));
|
|
console.debug('Control message sent: ', JSON.stringify(message));
|
|
let lidSwitchOpen = null;
|
|
if ('lid_switch_open' in states[index]) {
|
|
lidSwitchOpen = states[index].lid_switch_open;
|
|
}
|
|
let hingeAngle = null;
|
|
if ('hinge_angle_value' in states[index]) {
|
|
hingeAngle = states[index].hinge_angle_value;
|
|
// TODO(b/181157794): Use a custom Sensor HAL for hinge_angle
|
|
// injection instead of this guest binary.
|
|
adbShell(
|
|
'/vendor/bin/cuttlefish_sensor_injection hinge_angle ' +
|
|
states[index].hinge_angle_value);
|
|
}
|
|
// Update the Device Details view.
|
|
this.#updateDeviceStateDetails(lidSwitchOpen, hingeAngle);
|
|
// Cycle to the next state.
|
|
index = (index + 1) % states.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
#resizeDeviceDisplays() {
|
|
// Padding between displays.
|
|
const deviceDisplayWidthPadding = 10;
|
|
// Padding for the display info above each display video.
|
|
const deviceDisplayHeightPadding = 38;
|
|
|
|
let deviceDisplayList = document.getElementsByClassName('device-display');
|
|
let deviceDisplayVideoList =
|
|
document.getElementsByClassName('device-display-video');
|
|
let deviceDisplayInfoList =
|
|
document.getElementsByClassName('device-display-info');
|
|
|
|
const deviceDisplays = document.getElementById('device-displays');
|
|
const rotationDegrees = this.#getTransformRotation(deviceDisplays);
|
|
const rotationRadians = rotationDegrees * Math.PI / 180;
|
|
|
|
// Auto-scale the screen based on window size.
|
|
let availableWidth = deviceDisplays.clientWidth;
|
|
let availableHeight = deviceDisplays.clientHeight - deviceDisplayHeightPadding;
|
|
|
|
// Reserve space for padding between the displays.
|
|
availableWidth = availableWidth -
|
|
(this.#displayDescriptions.length * deviceDisplayWidthPadding);
|
|
|
|
// Loop once over all of the displays to compute the total space needed.
|
|
let neededWidth = 0;
|
|
let neededHeight = 0;
|
|
for (let i = 0; i < deviceDisplayList.length; i++) {
|
|
let deviceDisplayDescription = this.#displayDescriptions[i];
|
|
let deviceDisplayVideo = deviceDisplayVideoList[i];
|
|
|
|
const originalDisplayWidth = deviceDisplayDescription.x_res;
|
|
const originalDisplayHeight = deviceDisplayDescription.y_res;
|
|
|
|
const neededBoundingBoxWidth =
|
|
Math.abs(Math.cos(rotationRadians) * originalDisplayWidth) +
|
|
Math.abs(Math.sin(rotationRadians) * originalDisplayHeight);
|
|
const neededBoundingBoxHeight =
|
|
Math.abs(Math.sin(rotationRadians) * originalDisplayWidth) +
|
|
Math.abs(Math.cos(rotationRadians) * originalDisplayHeight);
|
|
|
|
neededWidth = neededWidth + neededBoundingBoxWidth;
|
|
neededHeight = Math.max(neededHeight, neededBoundingBoxHeight);
|
|
}
|
|
|
|
const scaling =
|
|
Math.min(availableWidth / neededWidth, availableHeight / neededHeight);
|
|
|
|
// Loop again over all of the displays to set the sizes and positions.
|
|
let deviceDisplayLeftOffset = 0;
|
|
for (let i = 0; i < deviceDisplayList.length; i++) {
|
|
let deviceDisplay = deviceDisplayList[i];
|
|
let deviceDisplayVideo = deviceDisplayVideoList[i];
|
|
let deviceDisplayInfo = deviceDisplayInfoList[i];
|
|
let deviceDisplayDescription = this.#displayDescriptions[i];
|
|
|
|
let rotated = this.#currentRotation == 1 ? ' (Rotated)' : '';
|
|
deviceDisplayInfo.textContent = `Display ${i} - ` +
|
|
`${deviceDisplayDescription.x_res}x` +
|
|
`${deviceDisplayDescription.y_res} ` +
|
|
`(${deviceDisplayDescription.dpi} DPI)${rotated}`;
|
|
|
|
const originalDisplayWidth = deviceDisplayDescription.x_res;
|
|
const originalDisplayHeight = deviceDisplayDescription.y_res;
|
|
|
|
const scaledDisplayWidth = originalDisplayWidth * scaling;
|
|
const scaledDisplayHeight = originalDisplayHeight * scaling;
|
|
|
|
const neededBoundingBoxWidth =
|
|
Math.abs(Math.cos(rotationRadians) * originalDisplayWidth) +
|
|
Math.abs(Math.sin(rotationRadians) * originalDisplayHeight);
|
|
const neededBoundingBoxHeight =
|
|
Math.abs(Math.sin(rotationRadians) * originalDisplayWidth) +
|
|
Math.abs(Math.cos(rotationRadians) * originalDisplayHeight);
|
|
|
|
const scaledBoundingBoxWidth = neededBoundingBoxWidth * scaling;
|
|
const scaledBoundingBoxHeight = neededBoundingBoxHeight * scaling;
|
|
|
|
const offsetX = (scaledBoundingBoxWidth - scaledDisplayWidth) / 2;
|
|
const offsetY = (scaledBoundingBoxHeight - scaledDisplayHeight) / 2;
|
|
|
|
deviceDisplayVideo.style.width = scaledDisplayWidth;
|
|
deviceDisplayVideo.style.height = scaledDisplayHeight;
|
|
deviceDisplayVideo.style.transform = `translateX(${offsetX}px) ` +
|
|
`translateY(${offsetY}px) ` +
|
|
`rotateZ(${rotationDegrees}deg) `;
|
|
|
|
deviceDisplay.style.left = `${deviceDisplayLeftOffset}px`;
|
|
deviceDisplay.style.width = scaledBoundingBoxWidth;
|
|
deviceDisplay.style.height = scaledBoundingBoxHeight;
|
|
|
|
deviceDisplayLeftOffset = deviceDisplayLeftOffset + deviceDisplayWidthPadding +
|
|
scaledBoundingBoxWidth;
|
|
}
|
|
}
|
|
|
|
#getTransformRotation(element) {
|
|
if (!element.style.textIndent) {
|
|
return 0;
|
|
}
|
|
// Remove 'px' and convert to float.
|
|
return parseFloat(element.style.textIndent.slice(0, -2));
|
|
}
|
|
|
|
#onControlMessage(message) {
|
|
let message_data = JSON.parse(message.data);
|
|
console.debug('Control message received: ', message_data)
|
|
let metadata = message_data.metadata;
|
|
if (message_data.event == 'VIRTUAL_DEVICE_BOOT_STARTED') {
|
|
// Start the adb connection after receiving the BOOT_STARTED message.
|
|
// (This is after the adbd start message. Attempting to connect
|
|
// immediately after adbd starts causes issues.)
|
|
this.#initializeAdb();
|
|
}
|
|
if (message_data.event == 'VIRTUAL_DEVICE_SCREEN_CHANGED') {
|
|
if (metadata.rotation != this.#currentRotation) {
|
|
// Animate the screen rotation.
|
|
const targetRotation = metadata.rotation == 0 ? 0 : -90;
|
|
|
|
$('#device-displays')
|
|
.animate(
|
|
{
|
|
textIndent: targetRotation,
|
|
},
|
|
{
|
|
duration: 1000,
|
|
step: (now, tween) => {
|
|
this.#resizeDeviceDisplays();
|
|
},
|
|
});
|
|
}
|
|
|
|
this.#currentRotation = metadata.rotation;
|
|
}
|
|
if (message_data.event == 'VIRTUAL_DEVICE_CAPTURE_IMAGE') {
|
|
if (this.#deviceConnection.cameraEnabled) {
|
|
this.#takePhoto();
|
|
}
|
|
}
|
|
if (message_data.event == 'VIRTUAL_DEVICE_DISPLAY_POWER_MODE_CHANGED') {
|
|
this.#updateDisplayVisibility(metadata.display, metadata.mode);
|
|
}
|
|
}
|
|
|
|
#updateDeviceStateDetails(lidSwitchOpen, hingeAngle) {
|
|
let deviceStateDetailsTextLines = [];
|
|
if (lidSwitchOpen != null) {
|
|
let state = lidSwitchOpen ? 'Opened' : 'Closed';
|
|
deviceStateDetailsTextLines.push(`Lid Switch - ${state}`);
|
|
}
|
|
if (hingeAngle != null) {
|
|
deviceStateDetailsTextLines.push(`Hinge Angle - ${hingeAngle}`);
|
|
}
|
|
let deviceStateDetailsText = deviceStateDetailsTextLines.join('\n');
|
|
new DeviceDetailsUpdater()
|
|
.setDeviceStateDetailsText(deviceStateDetailsText)
|
|
.update();
|
|
}
|
|
|
|
#updateDeviceHardwareDetails(hardware) {
|
|
let hardwareDetailsTextLines = [];
|
|
Object.keys(hardware).forEach((key) => {
|
|
let value = hardware[key];
|
|
hardwareDetailsTextLines.push(`${key} - ${value}`);
|
|
});
|
|
|
|
let hardwareDetailsText = hardwareDetailsTextLines.join('\n');
|
|
new DeviceDetailsUpdater()
|
|
.setHardwareDetailsText(hardwareDetailsText)
|
|
.update();
|
|
}
|
|
|
|
// Creates a <video> element and a <div> container element for each display.
|
|
// The extra <div> container elements are used to maintain the width and
|
|
// height of the device as the CSS 'transform' property used on the <video>
|
|
// element for rotating the device only affects the visuals of the element
|
|
// and not its layout.
|
|
#createDeviceDisplays() {
|
|
console.debug(
|
|
'Display descriptions: ', this.#deviceConnection.description.displays);
|
|
this.#displayDescriptions = this.#deviceConnection.description.displays;
|
|
let anyDisplayLoaded = false;
|
|
const deviceDisplays = document.getElementById('device-displays');
|
|
for (const deviceDisplayDescription of this.#displayDescriptions) {
|
|
let deviceDisplay = document.createElement('div');
|
|
deviceDisplay.classList.add('device-display');
|
|
// Start the screen as hidden. Only show when data is ready.
|
|
deviceDisplay.style.visibility = 'hidden';
|
|
|
|
let deviceDisplayInfo = document.createElement("div");
|
|
deviceDisplayInfo.classList.add("device-display-info");
|
|
deviceDisplayInfo.id = deviceDisplayDescription.stream_id + '_info';
|
|
deviceDisplay.appendChild(deviceDisplayInfo);
|
|
|
|
let deviceDisplayVideo = document.createElement('video');
|
|
deviceDisplayVideo.autoplay = true;
|
|
deviceDisplayVideo.muted = true;
|
|
deviceDisplayVideo.id = deviceDisplayDescription.stream_id;
|
|
deviceDisplayVideo.classList.add('device-display-video');
|
|
deviceDisplayVideo.addEventListener('loadeddata', (evt) => {
|
|
if (!anyDisplayLoaded) {
|
|
anyDisplayLoaded = true;
|
|
this.#onDeviceDisplayLoaded();
|
|
}
|
|
});
|
|
deviceDisplay.appendChild(deviceDisplayVideo);
|
|
|
|
deviceDisplays.appendChild(deviceDisplay);
|
|
|
|
let stream_id = deviceDisplayDescription.stream_id;
|
|
this.#deviceConnection.getStream(stream_id)
|
|
.then(stream => {
|
|
deviceDisplayVideo.srcObject = stream;
|
|
})
|
|
.catch(e => console.error('Unable to get display stream: ', e));
|
|
}
|
|
}
|
|
|
|
#initializeAdb() {
|
|
init_adb(
|
|
this.#deviceConnection, () => this.#showAdbConnected(),
|
|
() => this.#showAdbError());
|
|
}
|
|
|
|
#showAdbConnected() {
|
|
// Screen changed messages are not reported until after boot has completed.
|
|
// Certain default adb buttons change screen state, so wait for boot
|
|
// completion before enabling these buttons.
|
|
document.getElementById('status-message').className = 'connected';
|
|
document.getElementById('status-message').textContent =
|
|
'adb connection established successfully.';
|
|
setTimeout(() => {
|
|
document.getElementById('status-message').style.visibility = 'hidden';
|
|
}, 5000);
|
|
for (const [_, button] of Object.entries(this.#buttons)) {
|
|
if (button.adb) {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
#showAdbError() {
|
|
document.getElementById('status-message').className = 'error';
|
|
document.getElementById('status-message').textContent =
|
|
'adb connection failed.';
|
|
document.getElementById('status-message').style.visibility = 'visible';
|
|
for (const [_, button] of Object.entries(this.#buttons)) {
|
|
if (button.adb) {
|
|
button.disabled = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
#onDeviceDisplayLoaded() {
|
|
document.getElementById('status-message').textContent =
|
|
'Awaiting bootup and adb connection. Please wait...';
|
|
this.#resizeDeviceDisplays();
|
|
|
|
let deviceDisplayList = document.getElementsByClassName('device-display');
|
|
for (const deviceDisplay of deviceDisplayList) {
|
|
deviceDisplay.style.visibility = 'visible';
|
|
}
|
|
|
|
// Enable the buttons after the screen is visible.
|
|
for (const [key, button] of Object.entries(this.#buttons)) {
|
|
if (!button.adb) {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
// Start the adb connection if it is not already started.
|
|
this.#initializeAdb();
|
|
}
|
|
|
|
#onRotateButton(e) {
|
|
// Attempt to init adb again, in case the initial connection failed.
|
|
// This succeeds immediately if already connected.
|
|
this.#initializeAdb();
|
|
if (e.type == 'mousedown') {
|
|
adbShell(
|
|
'/vendor/bin/cuttlefish_sensor_injection rotate ' +
|
|
(this.#currentRotation == 0 ? 'landscape' : 'portrait'))
|
|
}
|
|
}
|
|
|
|
#onControlPanelButton(e) {
|
|
if (e.type == 'mouseout' && e.which == 0) {
|
|
// Ignore mouseout events if no mouse button is pressed.
|
|
return;
|
|
}
|
|
this.#deviceConnection.sendControlMessage(JSON.stringify({
|
|
command: e.target.dataset.command,
|
|
button_state: e.type == 'mousedown' ? 'down' : 'up',
|
|
}));
|
|
}
|
|
|
|
#onKeyboardCaptureToggle(enabled) {
|
|
if (enabled) {
|
|
document.addEventListener('keydown', evt => this.#onKeyEvent(evt));
|
|
document.addEventListener('keyup', evt => this.#onKeyEvent(evt));
|
|
} else {
|
|
document.removeEventListener('keydown', evt => this.#onKeyEvent(evt));
|
|
document.removeEventListener('keyup', evt => this.#onKeyEvent(evt));
|
|
}
|
|
}
|
|
|
|
#onKeyEvent(e) {
|
|
e.preventDefault();
|
|
this.#deviceConnection.sendKeyEvent(e.code, e.type);
|
|
}
|
|
|
|
#startMouseTracking() {
|
|
let $this = this;
|
|
let mouseIsDown = false;
|
|
let mouseCtx = {
|
|
down: false,
|
|
touchIdSlotMap: new Map(),
|
|
touchSlots: [],
|
|
};
|
|
function onStartDrag(e) {
|
|
e.preventDefault();
|
|
|
|
// console.debug("mousedown at " + e.pageX + " / " + e.pageY);
|
|
mouseCtx.down = true;
|
|
|
|
$this.#sendEventUpdate(mouseCtx, e);
|
|
}
|
|
|
|
function onEndDrag(e) {
|
|
e.preventDefault();
|
|
|
|
// console.debug("mouseup at " + e.pageX + " / " + e.pageY);
|
|
mouseCtx.down = false;
|
|
|
|
$this.#sendEventUpdate(mouseCtx, e);
|
|
}
|
|
|
|
function onContinueDrag(e) {
|
|
e.preventDefault();
|
|
|
|
// console.debug("mousemove at " + e.pageX + " / " + e.pageY + ", down=" +
|
|
// mouseIsDown);
|
|
if (mouseCtx.down) {
|
|
$this.#sendEventUpdate(mouseCtx, e);
|
|
}
|
|
}
|
|
|
|
let deviceDisplayList = document.getElementsByClassName('device-display');
|
|
if (window.PointerEvent) {
|
|
for (const deviceDisplay of deviceDisplayList) {
|
|
deviceDisplay.addEventListener('pointerdown', onStartDrag);
|
|
deviceDisplay.addEventListener('pointermove', onContinueDrag);
|
|
deviceDisplay.addEventListener('pointerup', onEndDrag);
|
|
}
|
|
} else if (window.TouchEvent) {
|
|
for (const deviceDisplay of deviceDisplayList) {
|
|
deviceDisplay.addEventListener('touchstart', onStartDrag);
|
|
deviceDisplay.addEventListener('touchmove', onContinueDrag);
|
|
deviceDisplay.addEventListener('touchend', onEndDrag);
|
|
}
|
|
} else if (window.MouseEvent) {
|
|
for (const deviceDisplay of deviceDisplayList) {
|
|
deviceDisplay.addEventListener('mousedown', onStartDrag);
|
|
deviceDisplay.addEventListener('mousemove', onContinueDrag);
|
|
deviceDisplay.addEventListener('mouseup', onEndDrag);
|
|
}
|
|
}
|
|
}
|
|
|
|
#sendEventUpdate(ctx, e) {
|
|
let eventType = e.type.substring(0, 5);
|
|
|
|
// The <video> element:
|
|
const deviceDisplay = e.target;
|
|
|
|
// Before the first video frame arrives there is no way to know width and
|
|
// height of the device's screen, so turn every click into a click at 0x0.
|
|
// A click at that position is not more dangerous than anywhere else since
|
|
// the user is clicking blind anyways.
|
|
const videoWidth = deviceDisplay.videoWidth ? deviceDisplay.videoWidth : 1;
|
|
const videoHeight =
|
|
deviceDisplay.videoHeight ? deviceDisplay.videoHeight : 1;
|
|
const elementWidth =
|
|
deviceDisplay.offsetWidth ? deviceDisplay.offsetWidth : 1;
|
|
const elementHeight =
|
|
deviceDisplay.offsetHeight ? deviceDisplay.offsetHeight : 1;
|
|
|
|
// vh*ew > eh*vw? then scale h instead of w
|
|
const scaleHeight = videoHeight * elementWidth > videoWidth * elementHeight;
|
|
let elementScaling = 0, videoScaling = 0;
|
|
if (scaleHeight) {
|
|
elementScaling = elementHeight;
|
|
videoScaling = videoHeight;
|
|
} else {
|
|
elementScaling = elementWidth;
|
|
videoScaling = videoWidth;
|
|
}
|
|
|
|
// The screen uses the 'object-fit: cover' property in order to completely
|
|
// fill the element while maintaining the screen content's aspect ratio.
|
|
// Therefore:
|
|
// - If vh*ew > eh*vw, w is scaled so that content width == element width
|
|
// - Otherwise, h is scaled so that content height == element height
|
|
const scaleWidth = videoHeight * elementWidth > videoWidth * elementHeight;
|
|
|
|
// Convert to coordinates relative to the video by scaling.
|
|
// (This matches the scaling used by 'object-fit: cover'.)
|
|
//
|
|
// This scaling is needed to translate from the in-browser x/y to the
|
|
// on-device x/y.
|
|
// - When the device screen has not been resized, this is simple: scale
|
|
// the coordinates based on the ratio between the input video size and
|
|
// the in-browser size.
|
|
// - When the device screen has been resized, this scaling is still needed
|
|
// even though the in-browser size and device size are identical. This
|
|
// is due to the way WindowManager handles a resized screen, resized via
|
|
// `adb shell wm size`:
|
|
// - The ABS_X and ABS_Y max values of the screen retain their
|
|
// original values equal to the value set when launching the device
|
|
// (which equals the video size here).
|
|
// - The sent ABS_X and ABS_Y values need to be scaled based on the
|
|
// ratio between the max size (video size) and in-browser size.
|
|
const scaling =
|
|
scaleWidth ? videoWidth / elementWidth : videoHeight / elementHeight;
|
|
|
|
let xArr = [];
|
|
let yArr = [];
|
|
let idArr = [];
|
|
let slotArr = [];
|
|
|
|
if (eventType == 'mouse' || eventType == 'point') {
|
|
xArr.push(e.offsetX);
|
|
yArr.push(e.offsetY);
|
|
|
|
let thisId = -1;
|
|
if (eventType == 'point') {
|
|
thisId = e.pointerId;
|
|
}
|
|
|
|
slotArr.push(0);
|
|
idArr.push(thisId);
|
|
} else if (eventType == 'touch') {
|
|
// touchstart: list of touch points that became active
|
|
// touchmove: list of touch points that changed
|
|
// touchend: list of touch points that were removed
|
|
let changes = e.changedTouches;
|
|
let rect = e.target.getBoundingClientRect();
|
|
for (let i = 0; i < changes.length; i++) {
|
|
xArr.push(changes[i].pageX - rect.left);
|
|
yArr.push(changes[i].pageY - rect.top);
|
|
if (ctx.touchIdSlotMap.has(changes[i].identifier)) {
|
|
let slot = ctx.touchIdSlotMap.get(changes[i].identifier);
|
|
|
|
slotArr.push(slot);
|
|
if (e.type == 'touchstart') {
|
|
// error
|
|
console.error('touchstart when already have slot');
|
|
return;
|
|
} else if (e.type == 'touchmove') {
|
|
idArr.push(changes[i].identifier);
|
|
} else if (e.type == 'touchend') {
|
|
ctx.touchSlots[slot] = false;
|
|
ctx.touchIdSlotMap.delete(changes[i].identifier);
|
|
idArr.push(-1);
|
|
}
|
|
} else {
|
|
if (e.type == 'touchstart') {
|
|
let slot = -1;
|
|
for (let j = 0; j < ctx.touchSlots.length; j++) {
|
|
if (!ctx.touchSlots[j]) {
|
|
slot = j;
|
|
break;
|
|
}
|
|
}
|
|
if (slot == -1) {
|
|
slot = ctx.touchSlots.length;
|
|
ctx.touchSlots.push(true);
|
|
}
|
|
slotArr.push(slot);
|
|
ctx.touchSlots[slot] = true;
|
|
ctx.touchIdSlotMap.set(changes[i].identifier, slot);
|
|
idArr.push(changes[i].identifier);
|
|
} else if (e.type == 'touchmove') {
|
|
// error
|
|
console.error('touchmove when no slot');
|
|
return;
|
|
} else if (e.type == 'touchend') {
|
|
// error
|
|
console.error('touchend when no slot');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < xArr.length; i++) {
|
|
xArr[i] = xArr[i] * scaling;
|
|
yArr[i] = yArr[i] * scaling;
|
|
|
|
// Substract the offset produced by the difference in aspect ratio, if
|
|
// any.
|
|
if (scaleWidth) {
|
|
// Width was scaled, leaving excess content height, so subtract from y.
|
|
yArr[i] -= (elementHeight * scaling - videoHeight) / 2;
|
|
} else {
|
|
// Height was scaled, leaving excess content width, so subtract from x.
|
|
xArr[i] -= (elementWidth * scaling - videoWidth) / 2;
|
|
}
|
|
|
|
xArr[i] = Math.trunc(xArr[i]);
|
|
yArr[i] = Math.trunc(yArr[i]);
|
|
}
|
|
|
|
// NOTE: Rotation is handled automatically because the CSS rotation through
|
|
// transforms also rotates the coordinates of events on the object.
|
|
|
|
const display_label = deviceDisplay.id;
|
|
|
|
this.#deviceConnection.sendMultiTouch(
|
|
{idArr, xArr, yArr, down: ctx.down, slotArr, display_label});
|
|
}
|
|
|
|
#updateDisplayVisibility(displayId, powerMode) {
|
|
const display = document.getElementById('display_' + displayId).parentElement;
|
|
if (display == null) {
|
|
console.error('Unknown display id: ' + displayId);
|
|
return;
|
|
}
|
|
powerMode = powerMode.toLowerCase();
|
|
switch (powerMode) {
|
|
case 'on':
|
|
display.style.visibility = 'visible';
|
|
break;
|
|
case 'off':
|
|
display.style.visibility = 'hidden';
|
|
break;
|
|
default:
|
|
console.error('Display ' + displayId + ' has unknown display power mode: ' + powerMode);
|
|
}
|
|
}
|
|
|
|
#onMicCaptureToggle(enabled) {
|
|
return this.#deviceConnection.useMic(enabled);
|
|
}
|
|
|
|
#onCameraCaptureToggle(enabled) {
|
|
return this.#deviceConnection.useCamera(enabled);
|
|
}
|
|
|
|
#getZeroPaddedString(value, desiredLength) {
|
|
const s = String(value);
|
|
return '0'.repeat(desiredLength - s.length) + s;
|
|
}
|
|
|
|
#getTimestampString() {
|
|
const now = new Date();
|
|
return [
|
|
now.getFullYear(),
|
|
this.#getZeroPaddedString(now.getMonth(), 2),
|
|
this.#getZeroPaddedString(now.getDay(), 2),
|
|
this.#getZeroPaddedString(now.getHours(), 2),
|
|
this.#getZeroPaddedString(now.getMinutes(), 2),
|
|
this.#getZeroPaddedString(now.getSeconds(), 2),
|
|
].join('_');
|
|
}
|
|
|
|
#onVideoCaptureToggle(enabled) {
|
|
const recordToggle = document.getElementById('record-video-control');
|
|
if (enabled) {
|
|
let recorders = [];
|
|
|
|
const timestamp = this.#getTimestampString();
|
|
|
|
let deviceDisplayVideoList =
|
|
document.getElementsByClassName('device-display-video');
|
|
for (let i = 0; i < deviceDisplayVideoList.length; i++) {
|
|
const deviceDisplayVideo = deviceDisplayVideoList[i];
|
|
|
|
const recorder = new MediaRecorder(deviceDisplayVideo.captureStream());
|
|
const recordedData = [];
|
|
|
|
recorder.ondataavailable = event => recordedData.push(event.data);
|
|
recorder.onstop = event => {
|
|
const recording = new Blob(recordedData, { type: "video/webm" });
|
|
|
|
const downloadLink = document.createElement('a');
|
|
downloadLink.setAttribute('download', timestamp + '_display_' + i + '.webm');
|
|
downloadLink.setAttribute('href', URL.createObjectURL(recording));
|
|
downloadLink.click();
|
|
};
|
|
|
|
recorder.start();
|
|
recorders.push(recorder);
|
|
}
|
|
this.#recording['recorders'] = recorders;
|
|
|
|
recordToggle.style.backgroundColor = 'red';
|
|
} else {
|
|
for (const recorder of this.#recording['recorders']) {
|
|
recorder.stop();
|
|
}
|
|
recordToggle.style.backgroundColor = '';
|
|
}
|
|
return Promise.resolve(enabled);
|
|
}
|
|
|
|
#onAudioPlaybackToggle(enabled) {
|
|
const audioElem = document.getElementById('device-audio');
|
|
if (enabled) {
|
|
audioElem.play();
|
|
} else {
|
|
audioElem.pause();
|
|
}
|
|
}
|
|
|
|
#onCustomShellButton(shell_command, e) {
|
|
// Attempt to init adb again, in case the initial connection failed.
|
|
// This succeeds immediately if already connected.
|
|
this.#initializeAdb();
|
|
if (e.type == 'mousedown') {
|
|
adbShell(shell_command);
|
|
}
|
|
}
|
|
} // DeviceControlApp
|
|
|
|
window.addEventListener("load", async evt => {
|
|
try {
|
|
setupMessages();
|
|
let connectorModule = await import('./server_connector.js');
|
|
let deviceConnection = await ConnectDevice(
|
|
connectorModule.deviceId(), await connectorModule.createConnector());
|
|
let deviceControlApp = new DeviceControlApp(deviceConnection);
|
|
deviceControlApp.start();
|
|
document.getElementById('device-connection').style.display = 'block';
|
|
} catch(err) {
|
|
console.error('Unable to connect: ', err);
|
|
showError(
|
|
'No connection to the guest device. ' +
|
|
'Please ensure the WebRTC process on the host machine is active.');
|
|
}
|
|
document.getElementById('loader').style.display = 'none';
|
|
});
|