blob: 5dc863649ff00297a5aaefd210fcc9247679a16d [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Maximum number of bluetooth devices shown in bluetooth subpage.
* @type {number}
*/
const MAX_NUMBER_DEVICE_SHOWN = 50;
/**
* @fileoverview
* 'settings-bluetooth-subpage' is the settings subpage for managing bluetooth
* properties and devices.
*/
Polymer({
is: 'settings-bluetooth-subpage',
behaviors: [
I18nBehavior,
CrScrollableBehavior,
DeepLinkingBehavior,
ListPropertyUpdateBehavior,
settings.RouteObserverBehavior,
],
properties: {
/** Reflects the bluetooth-page property. */
bluetoothToggleState: {
type: Boolean,
notify: true,
},
/** Reflects the bluetooth-page property. */
stateChangeInProgress: {
type: Boolean,
reflectToAttribute: true,
},
/**
* The bluetooth adapter state, cached by bluetooth-page.
* @type {!chrome.bluetooth.AdapterState|undefined}
*/
adapterState: Object,
/** Informs bluetooth-page whether to show the spinner in the header. */
showSpinner_: {
type: Boolean,
notify: true,
computed: 'computeShowSpinner_(adapterState.*, dialogShown_)',
},
/**
* The ordered list of bluetooth devices.
* @type {!Array<!chrome.bluetooth.Device>}
* @private
*/
deviceList_: {
type: Array,
value() {
return [];
},
},
/**
* The ordered list of paired or connecting bluetooth devices.
* @type {!Array<!chrome.bluetooth.Device>}
*/
pairedDeviceList_: {
type: Array,
value: /** @return {Array} */ function() {
return [];
},
},
/**
* The ordered list of unpaired bluetooth devices.
* @type {!Array<!chrome.bluetooth.Device>}
*/
unpairedDeviceList_: {
type: Array,
value: /** @return {Array} */ function() {
return [];
},
},
/**
* Whether or not the dialog is shown.
* @private
*/
dialogShown_: {
type: Boolean,
value: false,
},
/**
* Current Pairing device.
* @type {!chrome.bluetooth.Device|undefined}
* @private
*/
pairingDevice_: Object,
/**
* Interface for bluetooth calls. Set in bluetooth-page.
* @type {Bluetooth}
* @private
*/
bluetooth: {
type: Object,
value: chrome.bluetooth,
},
/**
* Interface for bluetoothPrivate calls. Set in bluetooth-page.
* @type {BluetoothPrivate}
* @private
*/
bluetoothPrivate: {
type: Object,
value: chrome.bluetoothPrivate,
},
/**
* Update frequency of the bluetooth list.
* @type {number}
*/
listUpdateFrequencyMs: {
type: Number,
value: 1000,
},
/**
* The time in milliseconds at which discovery was started attempt (when the
* page was opened with Bluetooth on, or when Bluetooth turned on while the
* page was active).
* @private {?number}
*/
discoveryStartTimestampMs_: {
type: Number,
value: null,
},
/**
* Used by FocusRowBehavior to track the last focused element on a row.
* @private {?Object}
*/
lastFocused_: Object,
/**
* Used by FocusRowBehavior to track if the list has been blurred.
* @private
*/
listBlurred_: Boolean,
/**
* Contains the settingId of any deep link that wasn't able to be shown,
* null otherwise.
* @private {?chromeos.settings.mojom.Setting}
*/
pendingSettingId_: {
type: Number,
value: null,
},
/**
* Used by DeepLinkingBehavior to focus this page's deep links.
* @type {!Set<!chromeos.settings.mojom.Setting>}
*/
supportedSettingIds: {
type: Object,
value: () => new Set([
chromeos.settings.mojom.Setting.kBluetoothOnOff,
chromeos.settings.mojom.Setting.kBluetoothConnectToDevice,
chromeos.settings.mojom.Setting.kBluetoothDisconnectFromDevice,
chromeos.settings.mojom.Setting.kBluetoothPairDevice,
chromeos.settings.mojom.Setting.kBluetoothUnpairDevice,
]),
},
},
observers: [
'adapterStateChanged_(adapterState.*)',
'deviceListChanged_(deviceList_.*)',
'listUpdateFrequencyMsChanged_(listUpdateFrequencyMs)',
],
/**
* Timer ID for bluetooth list update.
* @type {number|undefined}
* @private
*/
updateTimerId_: undefined,
/**
* Overridden from DeepLinkingBehavior.
* @param {!chromeos.settings.mojom.Setting} settingId
* @return {boolean}
*/
beforeDeepLinkAttempt(settingId) {
// If lastFocused_ is an internal element of a Focus Row (such as the menu
// button on a paired device), FocusRowBehavior prevents the Focus Row from
// being focused. We clear lastFocused_ so that we can focus the row (such
// as a paired/unpaired device).
if (settingId ===
chromeos.settings.mojom.Setting.kBluetoothConnectToDevice ||
settingId ===
chromeos.settings.mojom.Setting.kBluetoothDisconnectFromDevice ||
settingId === chromeos.settings.mojom.Setting.kBluetoothPairDevice ||
settingId === chromeos.settings.mojom.Setting.kBluetoothUnpairDevice) {
this.lastFocused_ = null;
}
// Should continue with deep link attempt.
return true;
},
/** @override */
detached() {
if (this.updateTimerId_ !== undefined) {
window.clearInterval(this.updateTimerId_);
this.updateTimerId_ = undefined;
this.deviceList_ = [];
}
},
/**
* settings.RouteObserverBehavior
* @param {!settings.Route} route
* @protected
*/
currentRouteChanged(route) {
// Any navigation resets the previous attempt to deep link.
this.pendingSettingId_ = null;
this.updateDiscovery_();
this.startOrStopRefreshingDeviceList_();
// Does not apply to this page.
if (route !== settings.routes.BLUETOOTH_DEVICES) {
return;
}
this.attemptDeepLink().then(result => {
if (!result.deepLinkShown && result.pendingSettingId) {
// Store any deep link settingId that wasn't shown so we can try again
// in refreshBluetoothList_.
this.pendingSettingId_ = result.pendingSettingId;
}
});
},
/** @private */
computeShowSpinner_() {
return !this.dialogShown_ && this.get('adapterState.discovering');
},
/** @private */
adapterStateChanged_() {
this.updateDiscovery_();
this.startOrStopRefreshingDeviceList_();
},
/** @private */
deviceListChanged_() {
this.updateList(
'pairedDeviceList_', item => item.address,
this.getUpdatedDeviceList_(
this.pairedDeviceList_,
this.deviceList_.filter(d => d.paired || d.connecting)));
this.updateList(
'unpairedDeviceList_', item => item.address,
this.getUpdatedDeviceList_(
this.unpairedDeviceList_,
this.deviceList_.filter(d => !(d.paired || d.connecting))));
this.updateScrollableContents();
},
/**
* Returns a copy of |oldDeviceList| but:
* - Using the corresponding device objects in |newDeviceList|
* - Removing devices not in |newDeviceList|
* - Adding device not in |oldDeviceList| but in |newDeviceList| to the
* end of the list.
*
* @param {!Array<!chrome.bluetooth.Device>} oldDeviceList
* @param {!Array<!chrome.bluetooth.Device>} newDeviceList
* @return {!Array<!chrome.bluetooth.Device>}
* @private
*/
getUpdatedDeviceList_(oldDeviceList, newDeviceList) {
const newDeviceMap = new Map(newDeviceList.map(d => [d.address, d]));
const updatedDeviceList = [];
// Add elements of |oldDeviceList| that are in |newDeviceList| to
// |updatedDeviceList|.
for (const oldDevice of oldDeviceList) {
const newDevice = newDeviceMap.get(oldDevice.address);
if (newDevice === undefined) {
continue;
}
updatedDeviceList.push(newDevice);
newDeviceMap.delete(newDevice.address);
}
// Add all elements of |newDeviceList| that are not in |oldDeviceList| to
// |updatedDeviceList|.
for (const newDevice of newDeviceMap.values()) {
updatedDeviceList.push(newDevice);
}
return updatedDeviceList;
},
/** @private */
updateDiscovery_() {
if (!this.adapterState || !this.adapterState.powered) {
return;
}
if (settings.Router.getInstance().getCurrentRoute() ===
settings.routes.BLUETOOTH_DEVICES) {
this.startDiscovery_();
} else {
this.stopDiscovery_();
}
},
/** @private */
startDiscovery_() {
if (!this.adapterState || this.adapterState.discovering) {
return;
}
this.bluetooth.startDiscovery(function() {
const lastError = chrome.runtime.lastError;
if (lastError) {
if (lastError.message === 'Starting discovery failed') {
return;
} // May happen if also started elsewhere, ignore.
console.error('startDiscovery Error: ' + lastError.message);
}
});
},
/** @private */
stopDiscovery_() {
if (!this.get('adapterState.discovering')) {
return;
}
this.bluetooth.stopDiscovery(function() {
const lastError = chrome.runtime.lastError;
if (lastError) {
if (lastError.message === 'Failed to stop discovery') {
return;
} // May happen if also stopped elsewhere, ignore.
console.error('stopDiscovery Error: ' + lastError.message);
}
});
},
/**
* @param {!CustomEvent<!{
* action: string, device:
* !chrome.bluetooth.Device
* }>} e
* @private
*/
onDeviceEvent_(e) {
const action = e.detail.action;
const device = e.detail.device;
if (action === 'connect') {
this.connectDevice_(device);
} else if (action === 'disconnect') {
this.disconnectDevice_(device);
} else if (action === 'remove') {
this.forgetDevice_(device);
} else {
console.error('Unexected action: ' + action);
}
},
/**
* @param {!Event} event
* @private
*/
onEnableTap_(event) {
if (this.isAdapterAvailable_() && !this.stateChangeInProgress) {
this.bluetoothToggleState = !this.bluetoothToggleState;
}
event.stopPropagation();
},
/**
* @param {boolean} enabled
* @param {string} onstr
* @param {string} offstr
* @return {string}
* @private
*/
getOnOffString_(enabled, onstr, offstr) {
// If these strings are changed to convey more information other than "On"
// and "Off" in the future, revisit the a11y implementation to ensure no
// meaningful information is skipped.
return enabled ? onstr : offstr;
},
/**
* @return {boolean}
* @private
*/
isAdapterAvailable_() {
return !!this.adapterState && this.adapterState.available;
},
/**
* @param {boolean} bluetoothToggleState
* @param {!Array<!chrome.bluetooth.Device>} deviceList
* @return {boolean}
* @private
*/
showDevices_(bluetoothToggleState, deviceList) {
return bluetoothToggleState && deviceList.length > 0;
},
/**
* @param {boolean} bluetoothToggleState
* @param {!Array<!chrome.bluetooth.Device>} deviceList
* @return {boolean}
* @private
*/
showNoDevices_(bluetoothToggleState, deviceList) {
return bluetoothToggleState && deviceList.length === 0;
},
/**
* @param {!chrome.bluetooth.Device} device
* @private
*/
connectDevice_(device) {
if (device.connecting || device.connected) {
return;
}
// If the device is not paired, show the pairing dialog before connecting.
// TODO(crbug.com/966170): Need to check if the device is pairable as well.
const isPaired = device.paired;
if (!isPaired) {
this.pairingDevice_ = device;
this.openDialog_();
}
if (isPaired !== undefined && device.transport !== undefined) {
this.recordDeviceSelectionDuration_(isPaired, device.transport);
}
const address = device.address;
this.bluetoothPrivate.connect(address, result => {
if (isPaired) {
const connectResult = chrome.runtime.lastError ? undefined : result;
chrome.bluetoothPrivate.recordReconnection(connectResult);
}
// If |pairingDevice_| has changed, ignore the connect result.
if (this.pairingDevice_ && address !== this.pairingDevice_.address) {
return;
}
// Let the dialog handle any errors, otherwise close the dialog.
const dialog = this.$.deviceDialog;
if (dialog.endConnectionAttempt(
device, !isPaired /* wasPairing */, chrome.runtime.lastError,
result)) {
this.openDialog_();
} else if (
result !== chrome.bluetoothPrivate.ConnectResultType.IN_PROGRESS) {
this.$.deviceDialog.close();
}
});
settings.recordSettingChange();
},
/**
* @param {!chrome.bluetooth.Device} device
* @private
*/
disconnectDevice_(device) {
this.bluetoothPrivate.disconnectAll(device.address, function() {
if (chrome.runtime.lastError) {
console.error(
'Error disconnecting: ' + device.address +
chrome.runtime.lastError.message);
}
});
settings.recordSettingChange();
},
/**
* @param {!chrome.bluetooth.Device} device
* @private
*/
forgetDevice_(device) {
this.bluetoothPrivate.forgetDevice(device.address, () => {
if (chrome.runtime.lastError) {
console.error(
'Error forgetting: ' + device.name + ': ' +
chrome.runtime.lastError.message);
}
});
settings.recordSettingChange();
},
/** @private */
openDialog_() {
if (this.dialogShown_) {
return;
}
// Call flush so that the dialog gets sized correctly before it is opened.
Polymer.dom.flush();
this.$.deviceDialog.open();
this.dialogShown_ = true;
},
/** @private */
onDialogClose_() {
this.dialogShown_ = false;
this.pairingDevice_ = undefined;
// The list is dynamic so focus the first item.
const device = this.$$('#unpairedContainer bluetooth-device-list-item');
if (device) {
device.focus();
}
},
/**
* Return the sorted devices based on the connected statuses, connected
* devices are first followed by non-connected devices.
*
* @param {!Array<!chrome.bluetooth.Device>} devices
* @return {!Array<!chrome.bluetooth.Device>}
* @private
*/
sortDevices_(devices) {
return devices.sort((a, b) => a.connected ? -1 : (b.connected ? 1 : 0));
},
/**
* Requests bluetooth device list from Chrome. Update deviceList_ once the
* results are returned from chrome.
* @private
*/
refreshBluetoothList_() {
const filter = {
filterType: chrome.bluetooth.FilterType.KNOWN,
limit: MAX_NUMBER_DEVICE_SHOWN
};
this.bluetooth.getDevices(filter, devices => {
this.deviceList_ = this.sortDevices_(devices);
// Check if we have yet to focus a deep-linked element.
if (!this.pendingSettingId_) {
return;
}
this.beforeDeepLinkAttempt(this.pendingSettingId_);
this.showDeepLink(this.pendingSettingId_).then(result => {
if (result.deepLinkShown) {
this.pendingSettingId_ = null;
}
});
});
},
/** @private */
startOrStopRefreshingDeviceList_() {
if (this.adapterState && this.adapterState.powered) {
if (this.updateTimerId_ !== undefined) {
return;
}
this.refreshBluetoothList_();
this.updateTimerId_ = window.setInterval(
this.refreshBluetoothList_.bind(this), this.listUpdateFrequencyMs);
this.discoveryStartTimestampMs_ = Date.now();
return;
}
window.clearInterval(this.updateTimerId_);
this.updateTimerId_ = undefined;
this.discoveryStartTimestampMs_ = null;
this.deviceList_ = [];
},
/**
* Restarts the timer when the frequency changes, which happens
* during tests.
*/
listUpdateFrequencyMsChanged_() {
if (this.updateTimerId_ === undefined) {
return;
}
window.clearInterval(this.updateTimerId_);
this.updateTimerId_ = undefined;
this.startOrStopRefreshingDeviceList_();
},
/**
* Record metrics for how long it took between when discovery started on the
* Settings page, and the user selected the device they wanted to connect to.
* @param {!boolean} wasPaired If the selected device was already
* paired.
* @param {!chrome.bluetooth.Transport} transport The transport type
* of the device.
* @private
*/
recordDeviceSelectionDuration_(wasPaired, transport) {
if (!this.discoveryStartTimestampMs_) {
// It's not necessarily an error that |discoveryStartTimestampMs_| isn't
// present; it's intentionally cleared after the first device selection
// (see further on in this method). Recording subsequent device selections
// after the first would provide inflated durations that don't truly
// reflect how long it took for the user to find the device they're
// looking for.
return;
}
chrome.bluetoothPrivate.recordDeviceSelection(
Date.now() - this.discoveryStartTimestampMs_, wasPaired, transport);
this.discoveryStartTimestampMs_ = null;
},
});