chromium/chrome/browser/resources/chromeos/emulator/bluetooth_settings.js

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_checkbox/cr_checkbox.js';
import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/ash/common/cr_elements/cr_radio_button/cr_radio_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_radio_group/cr_radio_group.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import './icons.js';
import './shared_styles.js';

import {WebUIListenerBehavior} from 'chrome://resources/ash/common/web_ui_listener_behavior.js';
import {html, Polymer} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';


/**
 * A bluetooth device.
 * @constructor
 */
const BluetoothDevice = function() {
  // The device's address (MAC format, must be unique).
  this.address = '';

  // The label which shows up in the devices list for this device.
  this.alias = '';

  // The text label of the selected device class.
  this.class = 'Computer';

  // The uint32 value of the selected device class.
  this.classValue = 0x104;

  // Whether or not the device shows up in the system tray's observed list of
  // bluetooth devices.
  this.discoverable = false;

  // Whether Chrome OS pairs with this device, or this device tries to pair
  // with Chrome OS.
  this.incoming = false;

  // A trusted device is one which is plugged directly Chrome OS and therefore
  // is paired by default, but not connected.
  this.isTrusted = false;

  // The device's name.This is not the label which shows up in the devices list
  // here or in the system tray--use |.alias| to edit that label.
  this.name = '';

  // The designated path for the device. Must be unique.
  this.path = '';

  // Whether or not the device is paired with Chrome OS.
  this.paired = false;

  // The label of the selected pairing method option.
  this.pairingMethod = 'None';

  // The text containing a PIN key or passkey for pairing.
  this.pairingAuthToken = '';

  // The label of the selected pairing action option.
  this.pairingAction = '';
};

Polymer({
  is: 'bluetooth-settings',

  _template: html`{__html_template__}`,

  behaviors: [WebUIListenerBehavior],

  properties: {
    /**
     * A set of bluetooth devices.
     * @type !Array<!BluetoothDevice>
     */
    devices: {
      type: Array,
      value() {
        return [];
      },
    },

    /**
     * A set of predefined bluetooth devices.
     * @type !Array<!BluetoothDevice>
     */
    predefinedDevices: {
      type: Array,
      value() {
        return [];
      },
    },

    /**
     * A bluetooth device object which is currently being edited.
     * @type {?BluetoothDevice}
     */
    currentEditableObject: {
      type: Object,
      value: null,
    },

    /**
     * The index of the bluetooth device object which is currently being edited.
     * This is initially set to -1 (i.e. no device selected) because not custom
     * devices exist when the page loads.
     */
    currentEditIndex: {
      type: Number,
      value() {
        return -1;
      },
    },

    /**
     * A set of options for the possible bluetooth device classes/types.
     * Object |value| attribute comes from values in the WebUI, set in
     * setDeviceClassOptions.
     * @type !Array<!{text: string, value: number}>
     */
    deviceClassOptions: {
      type: Array,
      value() {
        return [
          {text: 'Unknown', value: 0},
          {text: 'Mouse', value: 0x2580},
          {text: 'Keyboard', value: 0x2540},
          {text: 'Audio', value: 0x240408},
          {text: 'Phone', value: 0x7a020c},
          {text: 'Computer', value: 0x104},
        ];
      },
    },

    /**
     * A set of strings representing the method to be used for
     * authenticating a device during a pair request.
     * @type !Array<string>
     */
    deviceAuthenticationMethods: {
      type: Array,
      value() {
        return [];
      },
    },

    /**
     * A set of strings representing the actions which can be done when
     * a secure device is paired/requests a pair.
     * @type !Array<string>
     */
    deviceAuthenticationActions: {
      type: Array,
      value() {
        return [];
      },
    },
  },

  /**
   * Contains keys for all the device paths which have been discovered. Used
   * to look up whether or not a device is listed already.
   * @type {Object}
   */
  devicePaths: {},

  ready() {
    this.addWebUIListener(
        'bluetooth-device-added', this.addBluetoothDevice_.bind(this));
    this.addWebUIListener(
        'device-paired-from-tray', this.devicePairedFromTray_.bind(this));
    this.addWebUIListener(
        'device-removed-from-main-adapter',
        this.deviceRemovedFromMainAdapter_.bind(this));
    this.addWebUIListener('pair-failed', this.pairFailed_.bind(this));
    this.addWebUIListener(
        'bluetooth-info-updated', this.updateBluetoothInfo_.bind(this));
    chrome.send('requestBluetoothInfo');
  },

  observers: ['currentEditableObjectChanged(currentEditableObject.*)'],

  /**
   * Called when a property of the currently editable object is edited.
   * Sets the corresponding property for the object in |this.devices|.
   * @param {Object} obj An object containing event information (ex. which
   *     property of |this.currentEditableObject| was changed, what its value
   *     is, etc.)
   */
  currentEditableObjectChanged(obj) {
    if (this.currentEditIndex >= 0) {
      const prop = obj.path.split('.')[1];
      this.set(
          'devices.' + this.currentEditIndex.toString() + '.' + prop,
          obj.value);
    }
  },

  handleAddressInput() {
    this.autoFormatAddress();
    this.validateAddress();
  },

  autoFormatAddress() {
    const input = this.$.deviceAddressInput;
    const regex = /([a-f0-9]{2})([a-f0-9]{2})/i;
    // Remove things that aren't hex characters from the string.
    let val = input.value.replace(/[^a-f0-9]/ig, '');

    // Insert a ':' in the middle of every four hex characters.
    while (regex.test(val)) {
      val = val.replace(regex, '$1:$2');
    }

    input.value = val;
  },

  /**
   * Called on-input from an input element and on edit dialog open.
   * Validates whether or not the
   * input's content matches a regular expression. If the input's value
   * satisfies the regex, then make sure that the address is not already
   * in use.
   */
  validateAddress() {
    const input = this.$.deviceAddressInput;
    const val = input.value;
    let exists = false;
    const addressRegex = RegExp('^([\\da-fA-F]{2}:){5}[\\da-fA-F]{2}$');
    if (addressRegex.test(val)) {
      for (let i = 0; i < this.predefinedDevices.length; ++i) {
        if (this.predefinedDevices[i].address === val) {
          exists = true;
          break;
        }
      }

      if (!exists) {
        for (let i = 0; i < this.devices.length; ++i) {
          if (this.devices[i].address === val && i !== this.currentEditIndex) {
            exists = true;
            break;
          }
        }
      }

      if (exists) {
        input.invalid = true;
        input.errorMessage = 'This address is already being used.';
      } else {
        input.invalid = false;
      }
    } else {
      input.invalid = true;
      input.errorMessage = 'Invalid address.';
    }
  },

  /**
   * Makes sure that a path is not already used.
   */
  validatePath() {
    const input = this.$.devicePathInput;
    const val = input.value;
    let exists = false;

    for (let i = 0; i < this.predefinedDevices.length; ++i) {
      if (this.predefinedDevices[i].path === val) {
        exists = true;
        break;
      }
    }

    if (!exists) {
      for (let i = 0; i < this.devices.length; ++i) {
        if (this.devices[i].path === val && i !== this.currentEditIndex) {
          exists = true;
          break;
        }
      }
    }

    if (exists) {
      input.invalid = true;
      input.errorMessage = 'This path is already being used.';
    } else {
      input.invalid = false;
    }
  },

  /**
   * Checks whether or not the PIN/passkey input field should be shown.
   * It should only be shown when the pair method is not 'None' or empty.
   * @param {string} pairMethod The label of the selected pair method option
   *     for a particular device.
   * @return {boolean} Whether the PIN/passkey input field should be shown.
   */
  showAuthToken(pairMethod) {
    return !!pairMethod && pairMethod !== 'None';
  },

  /**
   * Called by the WebUI which provides a list of devices which are connected
   * to the main adapter.
   * @param {{
   *   predefined_devices: !Array<!BluetoothDevice>,
   *   devices: !Array<!BluetoothDevice>,
   *   pairing_method_options: !Array<string>,
   *   pairing_action_options: !Array<string>,
   * }} info
   * @private
   */
  updateBluetoothInfo_(info) {
    this.predefinedDevices =
        this.loadDevicesFromList(info.predefined_devices, true);
    this.devices = this.loadDevicesFromList(info.devices, false);
    this.deviceAuthenticationMethods = info.pairing_method_options;
    this.deviceAuthenticationActions = info.pairing_action_options;
  },

  /**
   * Builds complete BluetoothDevice objects for each element in |devices_list|.
   * @param {!Array<!BluetoothDevice>} devices A list of incomplete
   *     BluetoothDevice provided by the C++ WebUI.
   * @param {boolean} predefined Whether or not the device is a predefined one.
   */
  loadDevicesFromList(devices, predefined) {
    /** @type {!Array<!BluetoothDevice>} */ const deviceList = [];

    for (let i = 0; i < devices.length; ++i) {
      if (this.devicePaths[devices[i].path] !== undefined) {
        continue;
      }

      // Get the label for the device class which should be selected.
      devices[i].class = this.getTextForDeviceClass(devices[i].classValue);
      devices[i].pairingAuthToken = devices[i].pairingAuthToken.toString();
      deviceList.push(devices[i]);
      this.devicePaths[devices[i].path] = {
        predefined: predefined,
        index: deviceList.length - 1,
      };
    }

    return deviceList;
  },

  /**
   * Called when a device is paired from the Tray. Checks the paired box for
   * the device with path |path|.
   * @private
   */
  devicePairedFromTray_(path) {
    const obj = this.devicePaths[path];

    if (obj === undefined) {
      return;
    }

    const index = obj.index;
    let devicePath = (obj.predefined ? 'predefinedDevices.' : 'devices.');
    devicePath += obj.index.toString();
    this.set(devicePath + '.paired', true);
  },

  /**
   * On-change handler for a checkbox in the device list. Pairs/unpairs the
   * device associated with the box checked/unchecked.
   * @param {Event} event Contains event data. |event.model.index| is the index
   *     of the item which the target is contained in.
   */
  pairDevice(event) {
    const index = event.model.index;
    const predefined =
        /** @type {boolean} */ (event.target.dataset.predefined === 'true');
    const device =
        predefined ? this.predefinedDevices[index] : this.devices[index];

    if (event.target.checked) {
      let devicePath = (predefined ? 'predefinedDevices.' : 'devices.');
      devicePath += index.toString();
      this.set(devicePath + '.discoverable', true);

      // Send device info to the WebUI.
      chrome.send('requestBluetoothPair', [device]);
      this.devicePaths[device.path] = {predefined: predefined, index: index};

      devicePath = (predefined ? 'predefinedDevices.' : 'devices.');
      devicePath += index.toString();
      this.set(devicePath + '.paired', false);
    } else {
      chrome.send('removeBluetoothDevice', [device.path]);

      let devicePath = (predefined ? 'predefinedDevices.' : 'devices.');
      devicePath += index.toString();
      this.set(devicePath + '.discoverable', false);
    }
  },

  /**
   * Called from Chrome OS back-end when a pair request fails.
   * @param {string} path The path of the device which failed to pair.
   * @private
   */
  pairFailed_(path) {
    const obj = this.devicePaths[path];

    if (obj === undefined) {
      return;
    }

    let devicePath = (obj.predefined ? 'predefinedDevices.' : 'devices.');
    devicePath += obj.index.toString();
    this.set(devicePath + '.paired', false);
  },

  /**
   * On-change event handler for a checkbox in the device list.
   * @param {Event} event Contains event data. |event.model.index| is the index
   *     of the item which the target is contained in.
   */
  discoverDevice(event) {
    const index = event.model.index;
    const predefined =
        /** @type {boolean} */ (event.target.dataset.predefined === 'true');
    const device =
        predefined ? this.predefinedDevices[index] : this.devices[index];

    if (event.target.checked) {
      device.classValue = this.getValueForDeviceClass(device.class);

      // Send device info to WebUI.
      chrome.send('requestBluetoothDiscover', [device]);

      this.devicePaths[device.path] = {predefined: predefined, index: index};
    } else {
      chrome.send('removeBluetoothDevice', [device.path]);

      let devicePath = (predefined ? 'predefinedDevices.' : 'devices.');
      devicePath += index.toString();
      this.set(devicePath + '.paired', false);
    }
  },

  // Adds a new device with default settings to the list of devices.
  appendNewDevice() {
    const newDevice = new BluetoothDevice();
    newDevice.alias = 'New Device';
    this.push('devices', newDevice);
  },

  /**
   * This is called when a new device is discovered by the main adapter.
   * The device is only added to the view's list if it is not already in
   * the list (i.e. its path has not yet been recorded in |devicePaths|).
   * @param {BluetoothDevice} device A bluetooth device.
   * @private
   */
  addBluetoothDevice_(device) {
    if (this.devicePaths[device.path] !== undefined) {
      const obj = this.devicePaths[device.path];
      let devicePath = (obj.predefined ? 'predefinedDevices.' : 'devices.');
      devicePath += obj.index.toString();
      this.set(devicePath + '.discoverable', true);
      return;
    }

    device.class = this.getTextForDeviceClass(device.classValue);
    device.discoverable = true;
    this.push('devices', device);
    this.devicePaths[device.path] = {
      predefined: false,
      index: this.devices.length - 1,
    };
  },

  /**
   * Called on "copy" button from the device list clicked. Creates a copy of
   * the selected device and adds it to the "custom" devices list.
   * @param {Event} event Contains event data. |event.model.index| is the index
   *     of the item which the target is contained in.
   */
  copyDevice(event) {
    const predefined = (event.target.dataset.predefined === 'true');
    const index = event.model.index;
    const copyDevice =
        predefined ? this.predefinedDevices[index] : this.devices[index];
    // Create a deep copy of the selected device.
    const newDevice = new BluetoothDevice();
    Object.assign(newDevice, copyDevice);
    newDevice.path = '';
    newDevice.address = '';
    newDevice.name += ' (Copy)';
    newDevice.alias += ' (Copy)';
    newDevice.discoverable = false;
    newDevice.paired = false;
    this.push('devices', newDevice);
  },

  /**
   * Shows a dialog to edit the selected device's properties.
   * @param {Event} event Contains event data. |event.model.index| is the index
   *     of the item which the target is contained in.
   */
  showEditDialog(event) {
    const index = event.model.index;
    this.currentEditIndex = index;
    this.currentEditableObject = this.devices[index];
    this.$.editDialog.showModal();
    this.validateAddress();
    this.validatePath();
  },

  /** @private */
  onCloseClick_() {
    this.$.editDialog.close();
  },

  /**
   * A click handler for the delete button on bluetooth devices.
   * @param {Event} event Contains event data. |event.model.index| is the index
   *     of the item which the target is contained in.
   */
  deleteDevice(event) {
    const index = event.model.index;
    const device = this.devices[index];

    chrome.send('removeBluetoothDevice', [device.path]);

    this.devicePaths[device.path] = undefined;
    this.splice('devices', index, 1);
  },

  /**
   * This function is called when a device is removed from the main bluetooth
   * adapter's device list. It sets that device's |.discoverable| and |.paired|
   * attributes to false.
   * @param {string} path A bluetooth device's path.
   * @private
   */
  deviceRemovedFromMainAdapter_(path) {
    if (this.devicePaths[path] === undefined) {
      return;
    }

    const obj = this.devicePaths[path];
    let devicePath = (obj.predefined ? 'predefinedDevices.' : 'devices.');
    devicePath += obj.index.toString();
    this.set(devicePath + '.discoverable', false);
    this.set(devicePath + '.paired', false);
  },

  /**
   * Returns the text for the label that corresponds to |classValue|.
   * @param {number} classValue A number representing the bluetooth class
   *     of a device.
   * @return {string} The label which represents |classValue|.
   */
  getTextForDeviceClass(classValue) {
    for (let i = 0; i < this.deviceClassOptions.length; ++i) {
      if (this.deviceClassOptions[i].value === classValue) {
        return this.deviceClassOptions[i].text;
      }
    }
    return '';
  },

  /**
   * Returns the integer value which corresponds with the label |classText|.
   * @param {string} classText The label for a device class option.
   * @return {number} The value which |classText| represents.
   */
  getValueForDeviceClass(classText) {
    for (let i = 0; i < this.deviceClassOptions.length; ++i) {
      if (this.deviceClassOptions[i].text === classText) {
        return this.deviceClassOptions[i].value;
      }
    }
    return 0;
  },
});