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