chromium/chrome/browser/resources/bluetooth_internals/bluetooth_internals.js

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

/**
 * Javascript for bluetooth_internals.html, served from
 *     chrome://bluetooth-internals/.
 */

import {assert} from 'chrome://resources/js/assert.js';
import {$} from 'chrome://resources/js/util.js';

import {DiscoverySessionRemote} from './adapter.mojom-webui.js';
import {AdapterBroker, AdapterProperty, getAdapterBroker} from './adapter_broker.js';
import {AdapterPage} from './adapter_page.js';
import {BluetoothInternalsHandler, BluetoothInternalsHandlerRemote} from './bluetooth_internals.mojom-webui.js';
import {DebugLogPage} from './debug_log_page.js';
import {DeviceCollection} from './device_collection.js';
import {DeviceDetailsPage} from './device_details_page.js';
import {DevicesPage, ScanStatus} from './devices_page.js';
import {PageManager, PageManagerObserver} from './page_manager.js';
import {Sidebar} from './sidebar.js';
import {showSnackbar, SnackbarType} from './snackbar.js';



// Expose for testing.
/** @type {AdapterBroker} */
export let adapterBroker = null;

/** @type {DeviceCollection} */
export let devices = null;

/** @type {Sidebar} */
export let sidebarObj = null;

/** @type {PageManager} */
export const pageManager = PageManager.getInstance();

devices = new DeviceCollection([]);

/** @type {AdapterPage} */
let adapterPage = null;
/** @type {DevicesPage} */
let devicesPage = null;
/** @type {DebugLogPage} */
let debugLogPage = null;

/** @type {DiscoverySessionRemote} */
let discoverySession = null;

/** @type {boolean} */
let userRequestedScanStop = false;

/**
 * Observer for page changes. Used to update page title header.
 */
const PageObserver = class extends PageManagerObserver {
  updateHistory(path) {
    window.location.hash = '#' + path;
  }

  /**
   * Sets the page title. Called by PageManager.
   * @override
   * @param {string} title
   */
  updateTitle(title) {
    document.querySelector('.page-title').textContent = title;
  }
};

/**
 * Removes DeviceDetailsPage with matching device |address|. The associated
 * sidebar item is also removed.
 * @param {string} address
 */
function removeDeviceDetailsPage(address) {
  const id = 'devices/' + address.toLowerCase();
  sidebarObj.removeItem(id);

  const deviceDetailsPage =
      /** @type {!DeviceDetailsPage} */ (pageManager.registeredPages.get(id));

  // The device details page does not necessarily exist, return early if it is
  // not found.
  if (!deviceDetailsPage) {
    return;
  }

  deviceDetailsPage.disconnect();
  deviceDetailsPage.pageDiv.parentNode.removeChild(deviceDetailsPage.pageDiv);

  // Inform the devices page that the user is inspecting this device.
  // This will update the links in the device table.
  devicesPage.setInspecting(
      deviceDetailsPage.deviceInfo, false /* isInspecting */);

  pageManager.unregister(deviceDetailsPage);
}

/**
 * Creates a DeviceDetailsPage with the given |deviceInfo|, appends it to
 * '#page-container', and adds a sidebar item to show the new page. If a
 * page exists that matches |deviceInfo.address|, nothing is created and the
 * existing page is returned.
 * @param {!DeviceInfo} deviceInfo
 * @return {!DeviceDetailsPage}
 */
function makeDeviceDetailsPage(deviceInfo) {
  const deviceDetailsPageId = 'devices/' + deviceInfo.address.toLowerCase();
  let deviceDetailsPage =
      /** @type {?DeviceDetailsPage} */ (
          pageManager.registeredPages.get(deviceDetailsPageId));
  if (deviceDetailsPage) {
    return deviceDetailsPage;
  }

  const pageSection = document.createElement('section');
  pageSection.hidden = true;
  pageSection.id = deviceDetailsPageId;
  $('page-container').appendChild(pageSection);

  deviceDetailsPage = new DeviceDetailsPage(deviceDetailsPageId, deviceInfo);

  deviceDetailsPage.pageDiv.addEventListener('infochanged', function(event) {
    devices.addOrUpdate(event.detail.info);
  });

  deviceDetailsPage.pageDiv.addEventListener('forgetpressed', function(event) {
    pageManager.showPageByName(devicesPage.name);
    removeDeviceDetailsPage(event.detail.address);
  });

  // Inform the devices page that the user is inspecting this device.
  // This will update the links in the device table.
  devicesPage.setInspecting(deviceInfo, true /* isInspecting */);
  pageManager.register(deviceDetailsPage);

  sidebarObj.addItem({
    pageName: deviceDetailsPageId,
    text: deviceInfo.nameForDisplay,
  });

  deviceDetailsPage.connect();
  return deviceDetailsPage;
}

/**
 * Updates the DeviceDetailsPage with the matching device |address| and
 * redraws it.
 * @param {string} address
 */
function updateDeviceDetailsPage(address) {
  const detailPageId = 'devices/' + address.toLowerCase();
  const page = pageManager.registeredPages.get(detailPageId);
  if (page) {
    /** @type {!DeviceDetailsPage} */ (page).redraw();
  }
}

function updateStoppedDiscoverySession() {
  devicesPage.setScanStatus(ScanStatus.OFF);
  discoverySession = null;
}

function setupAdapterSystem(response) {
  adapterBroker.addEventListener('adapterchanged', function(event) {
    const oldValue = adapterPage.adapterFieldSet.value;
    const newValue = Object.assign({}, oldValue);
    newValue[event.detail.property] = event.detail.value;
    adapterPage.setAdapterInfo(newValue);

    if (event.detail.property === AdapterProperty.POWERED) {
      devicesPage.updatedScanButtonVisibility(event.detail.value);
    }

    if (event.detail.property === AdapterProperty.DISCOVERING &&
        !event.detail.value && !userRequestedScanStop && discoverySession) {
      updateStoppedDiscoverySession();
      showSnackbar(
          'Discovery session ended unexpectedly', SnackbarType.WARNING);
    }
  });

  adapterPage.setAdapterInfo(response.info);

  adapterPage.pageDiv.addEventListener('refreshpressed', function() {
    adapterBroker.getInfo().then(function(response) {
      if (response && response.info) {
        adapterPage.setAdapterInfo(response.info);
      } else {
        console.error('Failed to fetch adapter info.');
      }
    });
  });

  // <if expr="chromeos_ash">
  adapterPage.pageDiv.addEventListener('restart-bluetooth-click', function() {
    const restartBluetoothBtn =
        document.querySelector('#restart-bluetooth-btn');
    restartBluetoothBtn.textContent = 'Restarting system Bluetooth..';
    BluetoothInternalsHandler.getRemote()
        .restartSystemBluetooth()
        .catch((e) => {
          console.error('Failed to restart system Bluetooth');
        })
        .finally(() => {
          restartBluetoothBtn.textContent = 'Restart system Bluetooth';
          restartBluetoothBtn.disabled = false;
        });
  });
  // </if>
}

function setupDeviceSystem(response) {
  // Hook up device collection events.
  adapterBroker.addEventListener('deviceadded', function(event) {
    devices.addOrUpdate(event.detail.deviceInfo);
    updateDeviceDetailsPage(event.detail.deviceInfo.address);
  });
  adapterBroker.addEventListener('devicechanged', function(event) {
    devices.addOrUpdate(event.detail.deviceInfo);
    updateDeviceDetailsPage(event.detail.deviceInfo.address);
  });
  adapterBroker.addEventListener('deviceremoved', function(event) {
    devices.remove(event.detail.deviceInfo);
    updateDeviceDetailsPage(event.detail.deviceInfo.address);
  });

  response.devices.forEach(devices.addOrUpdate, devices /* this */);

  devicesPage.setDevices(devices);

  devicesPage.pageDiv.addEventListener('inspectpressed', function(event) {
    const detailsPage = makeDeviceDetailsPage(
        devices.item(devices.getByAddress(event.detail.address)));
    pageManager.showPageByName(detailsPage.name);
  });

  devicesPage.pageDiv.addEventListener('forgetpressed', function(event) {
    pageManager.showPageByName(devicesPage.name);
    removeDeviceDetailsPage(event.detail.address);
  });

  devicesPage.pageDiv.addEventListener('scanpressed', function(event) {
    if (discoverySession) {
      userRequestedScanStop = true;
      devicesPage.setScanStatus(ScanStatus.STOPPING);

      discoverySession.stop().then(function(response) {
        if (response.success) {
          updateStoppedDiscoverySession();
          userRequestedScanStop = false;
          return;
        }

        devicesPage.setScanStatus(ScanStatus.ON);
        showSnackbar('Failed to stop discovery session', SnackbarType.ERROR);
        userRequestedScanStop = false;
      });

      return;
    }

    devicesPage.setScanStatus(ScanStatus.STARTING);
    adapterBroker.startDiscoverySession()
        .then(function(session) {
          assert(session);
          discoverySession = session;

          discoverySession.onConnectionError.addListener(() => {
            updateStoppedDiscoverySession();
            showSnackbar('Discovery session ended', SnackbarType.WARNING);
          });

          devicesPage.setScanStatus(ScanStatus.ON);
        })
        .catch(function(error) {
          devicesPage.setScanStatus(ScanStatus.OFF);
          showSnackbar('Failed to start discovery session', SnackbarType.ERROR);
          console.error(error);
        });
  });
}

function setupPages(bluetoothInternalsHandler) {
  sidebarObj = new Sidebar(/** @type {!HTMLElement} */ ($('sidebar')));
  $('menu-btn').addEventListener('click', function() {
    sidebarObj.open();
  });
  pageManager.addObserver(sidebarObj);
  pageManager.addObserver(new PageObserver());

  devicesPage = new DevicesPage();
  pageManager.register(devicesPage);
  adapterPage = new AdapterPage();
  pageManager.register(adapterPage);
  debugLogPage = new DebugLogPage(bluetoothInternalsHandler);
  pageManager.register(debugLogPage);

  // Set up hash-based navigation.
  window.addEventListener('hashchange', function() {
    // If a user navigates and the page doesn't exist, do nothing.
    const pageName = window.location.hash.substr(1);
    // Device page names are invalid selectors for querySelector(), as they
    // contain "/" and ":".
    // eslint-disable-next-line no-restricted-properties
    if (document.getElementById(pageName)) {
      pageManager.showPageByName(pageName);
    }
  });

  if (!window.location.hash) {
    pageManager.showPageByName(adapterPage.name);
    return;
  }

  // Only the root pages are available on page load.
  pageManager.showPageByName(window.location.hash.split('/')[0].substr(1));
}

function showRefreshPageDialog() {
  document.getElementById('refresh-page').showModal();
}

function showNeedLocationServicesOnDialog(bluetoothInternalsHandler) {
  const dialog = document.getElementById('need-location-services-on');
  const servicesLink =
      document.getElementById('need-location-services-on-services-link');
  servicesLink.onclick = () => {
    dialog.close();
    showRefreshPageDialog();
    bluetoothInternalsHandler.requestLocationServices();
  };
  dialog.showModal();
}

function showNeedLocationPermissionAndServicesOnDialog(
    bluetoothInternalsHandler) {
  const dialog =
      document.getElementById('need-location-permission-and-services-on');
  const servicesLink = document.getElementById(
      'need-location-permission-and-services-on-services-link');
  servicesLink.onclick = () => {
    dialog.close();
    showRefreshPageDialog();
    bluetoothInternalsHandler.requestLocationServices();
  };
  const permissionLink = document.getElementById(
      'need-location-permission-and-services-on-permission-link');
  permissionLink.onclick = () => {
    dialog.close();
    showRefreshPageDialog();
    bluetoothInternalsHandler.requestSystemPermissions();
  };
  dialog.showModal();
}

function showNeedNearbyDevicesPermissionDialog(bluetoothInternalsHandler) {
  const dialog = document.getElementById('need-nearby-devices-permission');
  const permissionLink =
      document.getElementById('need-nearby-devices-permission-permission-link');
  permissionLink.onclick = () => {
    dialog.close();
    showRefreshPageDialog();
    bluetoothInternalsHandler.requestSystemPermissions();
  };
  dialog.showModal();
}

function showNeedLocationPermissionDialog(bluetoothInternalsHandler) {
  const dialog = document.getElementById('need-location-permission');
  const permissionLink =
      document.getElementById('need-location-permission-permission-link');
  permissionLink.onclick = () => {
    dialog.close();
    showRefreshPageDialog();
    bluetoothInternalsHandler.requestSystemPermissions();
  };
  dialog.showModal();
}

function showCanNotRequestPermissionsDialog() {
  document.getElementById('can-not-request-permissions').showModal();
}

export function initializeViews(bluetoothInternalsHandler) {
  setupPages(bluetoothInternalsHandler);
  return getAdapterBroker(bluetoothInternalsHandler)
      .then(function(broker) {
        adapterBroker = broker;
      })
      .then(function() {
        return adapterBroker.getInfo();
      })
      .then(setupAdapterSystem)
      .then(function() {
        return adapterBroker.getDevices();
      })
      .then(setupDeviceSystem)
      .catch(function(error) {
        showSnackbar(error.message, SnackbarType.ERROR);
        console.error(error);
      });
}

/**
 * Check if the system has all the needed system permissions for using
 * bluetooth.
 * @param {BluetoothInternalsHandlerRemote} bluetoothInternalsHandler Mojo
 *     remote handler.
 * @param {Function} successCallback The callback to be called when the system
 *     has the permissions for using bluetooth.
 */
export async function checkSystemPermissions(
    bluetoothInternalsHandler, successCallback) {
  const {
    needLocationPermission,
    needNearbyDevicesPermission,
    needLocationServices,
    canRequestPermissions,
  } = await bluetoothInternalsHandler.checkSystemPermissions();
  const havePermission =
      !needNearbyDevicesPermission && !needLocationPermission;
  // In order to access Bluetooth, Android S+ requires us to have Nearby Devices
  // permission, and older versions of Android require Location permission and
  // Location Services to be turned on. Other platforms shouldn't have any of
  // these fields set to true.
  if (havePermission) {
    if (needLocationServices) {
      showNeedLocationServicesOnDialog(bluetoothInternalsHandler);
    } else {
      successCallback(bluetoothInternalsHandler);
    }
  } else if (canRequestPermissions) {
    if (needLocationServices) {
      // If Location Services are needed we can assume we are on an Android
      // version lower S and so Location, rather than Nearby Devices permission,
      // is also needed.
      showNeedLocationPermissionAndServicesOnDialog(bluetoothInternalsHandler);
    } else if (needNearbyDevicesPermission) {
      showNeedNearbyDevicesPermissionDialog(bluetoothInternalsHandler);
    } else {
      showNeedLocationPermissionDialog(bluetoothInternalsHandler);
    }
  } else {
    showCanNotRequestPermissionsDialog(bluetoothInternalsHandler);
  }
}