blob: f26862a13895ca2090a6bab7a78f565982dd2566 [file] [log] [blame]
/*
* 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';
});