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