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