chromium/ash/webui/common/resources/bluetooth/bluetooth_pairing_ui.ts

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

/**
 * @fileoverview
 * Root UI element for Bluetooth pairing dialog.
 */

import '//resources/ash/common/cr_elements/cr_button/cr_button.js';
import './bluetooth_pairing_device_selection_page.js';
import './bluetooth_pairing_enter_code_page.js';
import './bluetooth_pairing_request_code_page.js';
import './bluetooth_pairing_confirm_code_page.js';
import './bluetooth_spinner_page.js';

import {assert, assertNotReached} from '//resources/js/assert.js';
import {mojoString16ToString} from '//resources/js/mojo_type_util.js';
import {BluetoothDeviceProperties, BluetoothDiscoveryDelegateInterface, BluetoothDiscoveryDelegateReceiver, BluetoothSystemProperties, BluetoothSystemState, DevicePairingDelegateInterface, DevicePairingDelegateReceiver, DevicePairingHandlerInterface, KeyEnteredHandlerInterface, KeyEnteredHandlerPendingReceiver, KeyEnteredHandlerReceiver, PairingResult, SystemPropertiesObserverInterface, SystemPropertiesObserverReceiver} from '//resources/mojo/chromeos/ash/services/bluetooth_config/public/mojom/cros_bluetooth_config.mojom-webui.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './bluetooth_pairing_ui.html.js';
import {PairingAuthType} from './bluetooth_types.js';
import {getBluetoothConfig} from './cros_bluetooth_config.js';

class KeyEnteredHandler implements KeyEnteredHandlerInterface {

  private page_: SettingsBluetoothPairingUiElement;
  private keyEnteredHandlerReceiver_: KeyEnteredHandlerReceiver;

  constructor(page: SettingsBluetoothPairingUiElement,
      keyEnteredHandlerReceiver: KeyEnteredHandlerPendingReceiver) {
    this.page_ = page;

    this.keyEnteredHandlerReceiver_ = new KeyEnteredHandlerReceiver(this);
    this.keyEnteredHandlerReceiver_.$.bindHandle(
        keyEnteredHandlerReceiver.handle);
  }

  handleKeyEntered(numKeysEntered: number) {
    this.page_.handleKeyEntered(numKeysEntered);
  }

  close() {
    this.keyEnteredHandlerReceiver_.$.close();
  }
}

enum BluetoothPairingSubpageId {
  DEVICE_SELECTION_PAGE = 'deviceSelectionPage',
  DEVICE_ENTER_CODE_PAGE  = 'deviceEnterCodePage',
  DEVICE_REQUEST_CODE_PAGE = 'deviceRequestCodePage',
  DEVICE_CONFIRM_CODE_PAGE = 'deviceConfirmCodePage',
  SPINNER_PAGE = 'spinnerPage',
}

interface RequestCodeCallback {
  resolve: ((param: string) => void)|null;
  reject: (() => void)|null;
}

interface ConfirmCodeCallback {
  resolve: (() => void)|null;
  reject: (() => void)|null;
}

export class SettingsBluetoothPairingUiElement extends PolymerElement
    implements SystemPropertiesObserverInterface,
                BluetoothDiscoveryDelegateInterface,
                DevicePairingDelegateInterface,
                KeyEnteredHandlerInterface {
  static get is() {
    return 'bluetooth-pairing-ui' as const;
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      /**
       * The address, when set, of the specific device that will be attempted to
       * be paired with by the pairing dialog. If null, no specific device will
       * be paired with and the user will be allowed to select a device to pair
       * with. This is set when the dialog is opened if the purpose of the
       * dialog is to pair with a specific device.
       */
      pairingDeviceAddress: {
        type: String,
        value: null,
      },

      /**
       * Flag indicating whether links should be displayed or not. In some
       * cases, such as the user being in OOBE or the login screen, links will
       * not work and should not be displayed.
       */
      shouldOmitLinks: {
        type: Boolean,
        value: false,
      },

      /**
       * Id of the currently selected Bluetooth pairing subpage.
       */
      selectedPageId_: {
        type: String,
        value: BluetoothPairingSubpageId.DEVICE_SELECTION_PAGE,
        observer: 'onSelectedPageIdChanged_',
      },

      discoveredDevices_: {
        type: Array,
        value: [],
      },

      /**
       * This can be null if no pairing attempt was started or a pairing attempt
       * was cancelled by user.
       */
      devicePendingPairing_: {
        type: Object,
        value: null,
      },

      pairingAuthType_: {
        type: Object,
        value: null,
      },

      pairingCode_: {
        type: String,
        value: '',
      },

      numKeysEntered_: {
        type: Number,
        value: 0,
      },

      /**
       * Id of a device who's pairing attempt failed.
       */
      lastFailedPairingDeviceId_: {
        type: String,
        value: '',
      },

      isBluetoothEnabled_: {
        type: Boolean,
        value: false,
      },

      /**
       * Used to access |BluetoothPairingSubpageId| type in HTML.
       */
      SubpageId: {
        type: Object,
        value: BluetoothPairingSubpageId,
      },
    };
  }

  pairingDeviceAddress: string|null;
  shouldOmitLinks: boolean;
  private selectedPageId_: BluetoothPairingSubpageId;
  private discoveredDevices_: BluetoothDeviceProperties[];
  private devicePendingPairing_: BluetoothDeviceProperties|null;
  private pairingAuthType_: PairingAuthType|null;
  private pairingCode_: string;
  private numKeysEntered_: number;
  private lastFailedPairingDeviceId_: string;
  private isBluetoothEnabled_: boolean;
  private systemPropertiesObserverReceiver_: SystemPropertiesObserverReceiver;
  private bluetoothDiscoveryDelegateReceiver_: BluetoothDiscoveryDelegateReceiver;
  private devicePairingHandler_: DevicePairingHandlerInterface|null;
  /**
   * The device to be paired with after the current pairDevice_() request has
   * finished.
   */
  private queuedDevicePendingPairing_: BluetoothDeviceProperties|null;

  /**
   * The Mojo receiver of the current ongoing pairing. If null indicates no
   * pairing is occurring.
   */
  private pairingDelegateReceiver_: DevicePairingDelegateReceiver|null = null;
  private requestCodeCallback_: RequestCodeCallback|null = null;
  private keyEnteredReceiver_: KeyEnteredHandler|null = null;
  private confirmCodeCallback_: ConfirmCodeCallback|null = null;
  private onBluetoothDiscoveryStartedCallbackForTest_:  (() => void)|null = null;
  private handlePairDeviceResultCallbackForTest_: (() => void)|null = null;

  constructor() {
    super();

    this.systemPropertiesObserverReceiver_ =
        new SystemPropertiesObserverReceiver(this);

    this.bluetoothDiscoveryDelegateReceiver_ =
        new BluetoothDiscoveryDelegateReceiver(this);
  }

  override ready(): void {
    super.ready();

    // If there's a specific device to pair with, immediately go to the spinner
    // page.
    if (this.pairingDeviceAddress) {
      this.selectedPageId_ = BluetoothPairingSubpageId.SPINNER_PAGE;
    }
  }

  override connectedCallback(): void {
    super.connectedCallback();

    getBluetoothConfig().observeSystemProperties(
        this.systemPropertiesObserverReceiver_.$.bindNewPipeAndPassRemote());
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();

    if (this.systemPropertiesObserverReceiver_) {
      this.systemPropertiesObserverReceiver_.$.close();
    }
    if (this.bluetoothDiscoveryDelegateReceiver_) {
      this.bluetoothDiscoveryDelegateReceiver_.$.close();
    }
    if (this.pairingDelegateReceiver_) {
      this.pairingDelegateReceiver_.$.close();
    }
    if (this.keyEnteredReceiver_) {
      this.keyEnteredReceiver_.close();
    }
  }

  onPropertiesUpdated(properties: BluetoothSystemProperties): void {
    const wasBluetoothEnabled = this.isBluetoothEnabled_;
    this.isBluetoothEnabled_ =
        properties.systemState === BluetoothSystemState.kEnabled;

    if (!wasBluetoothEnabled && this.isBluetoothEnabled_) {
      // If Bluetooth enables after being disabled, initialize the UI state.
      this.lastFailedPairingDeviceId_ = '';

      // Start discovery.
      getBluetoothConfig().startDiscovery(
          this.bluetoothDiscoveryDelegateReceiver_.$
              .bindNewPipeAndPassRemote());
    }
  }

  onDiscoveredDevicesListChanged(
      discoveredDevices: BluetoothDeviceProperties[]): void {
    this.discoveredDevices_ = discoveredDevices;

    this.updateLastFailedPairingDeviceId_(discoveredDevices);

    // Check if this dialog needs to pair to a specific device.
    if (!this.pairingDeviceAddress) {
      return;
    }

    // Check if a pairing is already occurring.
    if (this.pairingDelegateReceiver_) {
      return;
    }

    // If |this.pairingDeviceAddress| exists and no ongoing pairing is
    // occurring, search for the device with address |this.pairingDeviceAddress|
    // and attempt to pair with it.
    this.attemptPairDeviceByAddress_();
  }

  private updateLastFailedPairingDeviceId_(
      devices: BluetoothDeviceProperties[]):void {
    if (devices.some(device =>
          device.id === this.lastFailedPairingDeviceId_)) {
      return;
    }

    this.lastFailedPairingDeviceId_ = '';
  }

  onBluetoothDiscoveryStarted(handler: DevicePairingHandlerInterface): void {
    this.devicePairingHandler_ = handler;

    // Inform tests that onBluetoothDiscoveryStarted() has been called. This is
    // to ensure tests don't progress until |devicePairingHandler_| has been
    // set.
    if (this.onBluetoothDiscoveryStartedCallbackForTest_) {
      this.onBluetoothDiscoveryStartedCallbackForTest_();
    }
  }

  onBluetoothDiscoveryStopped(): void {
    // Discovery will stop if Bluetooth disables. Reset the UI back to the
    // selection page.
    this.bluetoothDiscoveryDelegateReceiver_.$.close();
    this.selectedPageId_ = BluetoothPairingSubpageId.DEVICE_SELECTION_PAGE;
    this.devicePairingHandler_ = null;
  }

  /**
   * Returns a promise that will be resolved the next time
   * onBluetoothDiscoveryStarted() is called.
   */
  waitForOnBluetoothDiscoveryStartedForTest(): Promise<void> {
    return new Promise((resolve) => {
      this.onBluetoothDiscoveryStartedCallbackForTest_ = resolve;
    });
  }

  /**
   * Returns a promise that will be resolved the next time
   * handlePairDeviceResult_() is called.
   */
  waitForHandlePairDeviceResultForTest(): Promise<void> {
    return new Promise((resolve) => {
      this.handlePairDeviceResultCallbackForTest_ = resolve;
    });
  }

  private onPairDevice_(event: CustomEvent<{device:BluetoothDeviceProperties}>)
      : void {
    if (!event.detail.device) {
      return;
    }
    // If a pairing operation is currently underway, close it and queue
    // the current device to be paired after pairDevice_() promise is
    // returned.
    if (this.pairingDelegateReceiver_) {
      this.queuedDevicePendingPairing_ = event.detail.device;
      this.pairingDelegateReceiver_.$.close();
      return;
    }
    this.pairDevice_(event.detail.device);
  }

  /**
   * Searches for the device with address |this.pairingDeviceAddress| in
   * |this.discoveredDevices| and attempts to pair with it.
   */
  private attemptPairDeviceByAddress_(): void {
    assert(this.pairingDeviceAddress);
    assert(!this.pairingDelegateReceiver_);

    if (!this.devicePairingHandler_) {
      console.error('Attempted pairing with no device pairing handler.');
      return;
    }

    this.devicePairingHandler_.fetchDevice(this.pairingDeviceAddress)
        .then(result => {
          if (!result.device) {
            console.warn(
                'Attempted pairing with a device that was not found, address: ' +
                this.pairingDeviceAddress);
            return;
          }

          this.pairDevice_(result.device);
        });
  }

  private pairDevice_(device: BluetoothDeviceProperties): void {
    assert(
        this.devicePairingHandler_, 'devicePairingHandler_ has not been set.');

    this.pairingDelegateReceiver_ = new DevicePairingDelegateReceiver(this);

    this.devicePendingPairing_ = device;
    assert(this.devicePendingPairing_);

    this.lastFailedPairingDeviceId_ = '';

    this.devicePairingHandler_
        .pairDevice(
            this.devicePendingPairing_.id,
            this.pairingDelegateReceiver_.$.bindNewPipeAndPassRemote())
        .then(result => {
          this.handlePairDeviceResult_(result.result);
        })
        .catch(() => {
          // Pairing failed due to external issues, such as Mojo pipe
          // disconnecting from Bluetooth disabling.
          this.handlePairDeviceResult_(PairingResult.kNonAuthFailure);
        });
  }

  private handlePairDeviceResult_(result: PairingResult): void {
    if (this.pairingDelegateReceiver_) {
      this.pairingDelegateReceiver_.$.close();
    }
    this.pairingAuthType_ = null;

    if (this.keyEnteredReceiver_) {
      this.keyEnteredReceiver_.close();
      this.keyEnteredReceiver_ = null;
    }

    this.pairingDelegateReceiver_ = null;

    if (result === PairingResult.kSuccess) {
      this.closeDialog_();
      return;
    }

    // If |pairingDeviceAddress| is defined, this was a device-specific pairing
    // request that has failed. Clear |pairingDeviceAddress| so that we don't
    // automatically attempt to re-pair with the same device again.
    this.pairingDeviceAddress = null;

    this.selectedPageId_ = BluetoothPairingSubpageId.DEVICE_SELECTION_PAGE;
    if (this.devicePendingPairing_) {
      this.lastFailedPairingDeviceId_ = this.devicePendingPairing_.id;
    }

    this.devicePendingPairing_ = null;

    if (this.queuedDevicePendingPairing_ && this.devicePairingHandler_) {
      this.pairDevice_(this.queuedDevicePendingPairing_);
    }

    this.queuedDevicePendingPairing_ = null;

    // Inform tests that handlePairDeviceResult_() has been called. This is
    // to ensure tests don't progress until the correct state has been
    // set.
    if (this.handlePairDeviceResultCallbackForTest_) {
      this.handlePairDeviceResultCallbackForTest_();
    }
  }

  requestPinCode(): Promise<{pinCode: string}>{
    return this.requestCode_(PairingAuthType.REQUEST_PIN_CODE);
  }

  requestPasskey(): Promise<{passkey: string}> {
    return this.requestCode_(PairingAuthType.REQUEST_PASSKEY);
  }

  private requestCode_(authType: PairingAuthType): Promise<any> {
    this.pairingAuthType_ = authType;
    this.selectedPageId_ = BluetoothPairingSubpageId.DEVICE_REQUEST_CODE_PAGE;
    this.requestCodeCallback_ = {
      reject: null,
      resolve: null,
    };

    const promise: Promise<any> = new Promise((resolve, reject) => {
      this.requestCodeCallback_!.resolve = (code: string) => {
        if (authType === PairingAuthType.REQUEST_PIN_CODE) {
          resolve({'pinCode': code});
          return;
        }

        if (authType === PairingAuthType.REQUEST_PASSKEY) {
          resolve({'passkey': code});
          return;
        }

        assertNotReached();
      };
      this.requestCodeCallback_!.reject = reject;
    });

    return promise ;
  }

  private onRequestCodeEntered_(event: CustomEvent<{code: string}>): void {
    this.selectedPageId_ = BluetoothPairingSubpageId.SPINNER_PAGE;
    event.stopPropagation();
    assert(this.pairingAuthType_);
    assert(this.requestCodeCallback_&& this.requestCodeCallback_.resolve);
    this.requestCodeCallback_.resolve(event.detail.code);
  }

  displayPinCode(pinCode: string, handler: KeyEnteredHandlerPendingReceiver)
      : void {
    this.displayCode_(handler, pinCode);
  }

  displayPasskey(passkey: string, handler: KeyEnteredHandlerPendingReceiver) {
    this.displayCode_(handler, passkey);
  }

  private displayCode_(handler: KeyEnteredHandlerPendingReceiver, code: string)
      : void {
    this.pairingCode_ = code;
    this.selectedPageId_ = BluetoothPairingSubpageId.DEVICE_ENTER_CODE_PAGE;
    this.keyEnteredReceiver_ = new KeyEnteredHandler(this, handler);
  }

  handleKeyEntered(numKeysEntered: number): void {
    this.numKeysEntered_ = numKeysEntered;
  }

  confirmPasskey(passkey: string): Promise<{confirmed: boolean}> {
    this.pairingAuthType_ = PairingAuthType.CONFIRM_PASSKEY;
    this.selectedPageId_ = BluetoothPairingSubpageId.DEVICE_CONFIRM_CODE_PAGE;
    this.pairingCode_ = passkey;

    this.confirmCodeCallback_ = {
      resolve: null,
      reject: null,
    };

    return new Promise((resolve, reject) => {
      this.confirmCodeCallback_!.resolve = () => {
        resolve({'confirmed': true});
      };
      this.confirmCodeCallback_!.reject = reject;
    });
  }

  private onConfirmCode_(event: Event): void {
    this.selectedPageId_ = BluetoothPairingSubpageId.SPINNER_PAGE;
    event.stopPropagation();
    assert(this.pairingAuthType_);
    assert(this.confirmCodeCallback_&& this.confirmCodeCallback_.resolve);
    this.confirmCodeCallback_.resolve();
  }

  authorizePairing(): Promise<{ confirmed: boolean }> {
    // TODO(crbug.com/1010321): Implement this function.
    return new Promise(() => {});
  }

  private shouldShowSubpage_(subpageId: BluetoothPairingSubpageId): boolean {
    return this.selectedPageId_ === subpageId;
  }

  private onCancelClick_(event: Event): void {
    event.stopPropagation();
    this.devicePendingPairing_ = null;
    if (this.pairingDelegateReceiver_) {
      this.pairingDelegateReceiver_.$.close();
      this.finishPendingCallbacksForTest_();
      this.pairingDelegateReceiver_ = null;
      return;
    }

    // If there is no receiver, this means pairing was not initiated and we
    // we are currently in DEVICE_SELECTION_PAGE or something went wrong and
    // |pairingDelegateReceiver_| was not instantiated. (b/218368694)
    this.closeDialog_();
  }


  private closeDialog_(): void {
    this.dispatchEvent(new CustomEvent('finished', {
      bubbles: true,
      composed: true,
    }));
  }

  private onSelectedPageIdChanged_(): void {
    // If the current page changes to the device selection page, focus the item
    // corresponding to the last device attempted to be paired with.
    if (this.selectedPageId_ !==
        BluetoothPairingSubpageId.DEVICE_SELECTION_PAGE) {
      return;
    }

    const deviceSelectionPage: any =
        this.shadowRoot!.querySelector('#deviceSelectionPage');
    if (!deviceSelectionPage) {
      return;
    }

    deviceSelectionPage.attemptFocusLastSelectedItem();
  }

  private finishPendingCallbacksForTest_(): void {
    if (this.requestCodeCallback_ && this.requestCodeCallback_.reject) {
      // |requestCodeCallback_| promise is held by FakeDevicePairingHandler
      // in test. This does not get resolved for the test case where user
      // cancels request while in request code page. Calling reject is
      // necessary here to make sure the promise is resolved.
      this.requestCodeCallback_.reject();
    }

    // |confirmCodeCallback_| promise is held by FakeDevicePairingHandler
    // in test. This does not get resolved for the test case where user
    // cancels request while in request code page. Calling reject is
    // necessary here to make sure the promise is resolved.
    if (this.confirmCodeCallback_ && this.confirmCodeCallback_.reject) {
      this.confirmCodeCallback_.reject();
    }
  }

  private getDeviceName_(): string {
    if (!this.devicePendingPairing_) {
      return '';
    }
    return mojoString16ToString(this.devicePendingPairing_.publicName);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [SettingsBluetoothPairingUiElement.is]: SettingsBluetoothPairingUiElement;
  }
}
customElements.define(
    SettingsBluetoothPairingUiElement.is, SettingsBluetoothPairingUiElement);