bluetooth: Add device details page with basic properties to internals page.

Adds DeviceDetailsPage to internals page so users can view all the properties
of the DeviceInfo object. No display of service properties is included in this
patch.

Splits action link in Devices table into two separate links: "Inspect"
and "Forget".
Adds functions to add and remove items from the sidebar.
Adds test infrastructure for Device proxy-based tests.
Adds test for DeviceDetailsPage.
Adds unregister function to PageManager.

GIFs: https://goo.gl/photos/zLiv86gyYmQhRn9w8

BUG=651282,663470
CQ_INCLUDE_TRYBOTS=master.tryserver.chromium.linux:closure_compilation

Review-Url: https://codereview.chromium.org/2576603002
Cr-Commit-Position: refs/heads/master@{#443465}
diff --git a/chrome/browser/browser_resources.grd b/chrome/browser/browser_resources.grd
index a1ce7f0..98651e7 100644
--- a/chrome/browser/browser_resources.grd
+++ b/chrome/browser/browser_resources.grd
@@ -103,6 +103,7 @@
       <include name="IDR_BLUETOOTH_INTERNALS_ADAPTER_BROKER_JS" file="resources\bluetooth_internals\adapter_broker.js" type="BINDATA" compress="gzip" />
       <include name="IDR_BLUETOOTH_INTERNALS_ADAPTER_PAGE_JS" file="resources\bluetooth_internals\adapter_page.js" type="BINDATA" compress="gzip" />
       <include name="IDR_BLUETOOTH_INTERNALS_DEVICE_COLLECTION_JS" file="resources\bluetooth_internals\device_collection.js" type="BINDATA" compress="gzip" />
+      <include name="IDR_BLUETOOTH_INTERNALS_DEVICE_DETAILS_PAGE_JS" file="resources\bluetooth_internals\device_details_page.js" type="BINDATA" compress="gzip" />
       <include name="IDR_BLUETOOTH_INTERNALS_DEVICE_TABLE_JS" file="resources\bluetooth_internals\device_table.js" type="BINDATA" compress="gzip" />
       <include name="IDR_BLUETOOTH_INTERNALS_DEVICES_PAGE_JS" file="resources\bluetooth_internals\devices_page.js" type="BINDATA" compress="gzip" />
       <include name="IDR_BLUETOOTH_INTERNALS_HTML" file="resources\bluetooth_internals\bluetooth_internals.html" flattenhtml="true" allowexternalscript="true" type="BINDATA" compress="gzip" />
diff --git a/chrome/browser/resources/bluetooth_internals/bluetooth_internals.css b/chrome/browser/resources/bluetooth_internals/bluetooth_internals.css
index bb76f10f..5fa4b2c 100644
--- a/chrome/browser/resources/bluetooth_internals/bluetooth_internals.css
+++ b/chrome/browser/resources/bluetooth_internals/bluetooth_internals.css
@@ -45,7 +45,7 @@
 }
 
 /* Page content */
-#page-container section {
+#page-container > section {
   padding: 24px 16px;
 }
 
@@ -96,7 +96,10 @@
   }
 
   .page-header > h1 {
-    margin: 13px 0 13px 24px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    width: 190px;
   }
 }
 
@@ -201,6 +204,11 @@
   width: 100%;
 }
 
+table a {
+  font-size: 10pt;
+  padding: 6px;
+}
+
 table th,
 table td {
   border: 1px solid #d9d9d9;
@@ -311,13 +319,6 @@
   visibility: visible;
 }
 
-/* Adapter Page */
-@media screen and (min-width: 601px) {
-  #adapter {
-    display: flex;
-  }
-}
-
 /* Object Fieldset */
 .object-fieldset .status {
   align-items: center;
@@ -332,4 +333,16 @@
 
 .object-fieldset .status:last-child {
   margin-bottom: 0;
+}
+
+/* Object Fieldset Container */
+@media screen and (min-width: 601px) {
+  .flex {
+    display: flex;
+  }
+}
+
+/* Device Details Page */
+.device-details-page section {
+  -webkit-margin-start: 1em;
 }
\ No newline at end of file
diff --git a/chrome/browser/resources/bluetooth_internals/bluetooth_internals.html b/chrome/browser/resources/bluetooth_internals/bluetooth_internals.html
index c4101e34..489d87a 100644
--- a/chrome/browser/resources/bluetooth_internals/bluetooth_internals.html
+++ b/chrome/browser/resources/bluetooth_internals/bluetooth_internals.html
@@ -25,6 +25,7 @@
   <script src="object_fieldset.js"></script>
   <script src="adapter_page.js"></script>
   <script src="device_collection.js"></script>
+  <script src="device_details_page.js"></script>
   <script src="device_table.js"></script>
   <script src="devices_page.js"></script>
   <script src="sidebar.js"></script>
@@ -37,7 +38,7 @@
       <button id="menu-btn" class="custom-appearance"></button>
       <h1 class="page-title"></h1>
     </header>
-    <section id="adapter" hidden>
+    <section id="adapter" class="flex" hidden>
       <div class="header-extras">
         <button id="adapter-refresh-btn">Refresh</button>
       </div>
@@ -78,6 +79,7 @@
         <th data-field="rssi.value">Latest RSSI</th>
         <th data-field="services.length">Services</th>
         <th data-field="is_gatt_connected">GATT Connection State</th>
+        <th></th>
       </tr>
     </thead>
     <tbody>
@@ -85,4 +87,15 @@
   </table>
 </template>
 
+<template id="device-details-template">
+  <div class="device-details-page">
+    <div class="header-extras">
+      <button class="disconnect">Disconnect</button>
+      <button class="forget">Forget</button>
+    </div>
+    <h3>Status</h3>
+    <section class="device-details flex"></section>
+  </div>
+</template>
+
 </html>
\ No newline at end of file
diff --git a/chrome/browser/resources/bluetooth_internals/bluetooth_internals.js b/chrome/browser/resources/bluetooth_internals/bluetooth_internals.js
index 9f1c578..e46d742c 100644
--- a/chrome/browser/resources/bluetooth_internals/bluetooth_internals.js
+++ b/chrome/browser/resources/bluetooth_internals/bluetooth_internals.js
@@ -8,17 +8,34 @@
  */
 
 // Expose for testing.
+/** @type {adapter_broker.AdapterBroker} */
 var adapterBroker = null;
+/** @type {device_collection.DeviceCollection} */
 var devices = null;
+/** @type {sidebar.Sidebar} */
 var sidebarObj = null;
 
 cr.define('bluetooth_internals', function() {
   /** @const */ var AdapterPage = adapter_page.AdapterPage;
+  /** @const */ var DeviceDetailsPage = device_details_page.DeviceDetailsPage;
   /** @const */ var DevicesPage = devices_page.DevicesPage;
   /** @const */ var PageManager = cr.ui.pageManager.PageManager;
   /** @const */ var Snackbar = snackbar.Snackbar;
   /** @const */ var SnackbarType = snackbar.SnackbarType;
 
+  devices = new device_collection.DeviceCollection([]);
+
+  /** @type {adapter_page.AdapterPage} */
+  var adapterPage = null;
+  /** @type {devices_page.DevicesPage} */
+  var devicesPage = null;
+
+  /** @type {interfaces.BluetoothAdapter.DiscoverySession.ptrClass} */
+  var discoverySession = null;
+
+  /** @type {boolean} */
+  var userRequestedScanStop = false;
+
   /**
    * Observer for page changes. Used to update page title header.
    * @extends {cr.ui.pageManager.PageManager.Observer}
@@ -42,79 +59,87 @@
     },
   };
 
-  /** @type {!Map<string, !interfaces.BluetoothDevice.DevicePtr>} */
-  var deviceAddressToProxy = new Map();
+  /**
+   * Removes DeviceDetailsPage with matching device |address|. The associated
+   * sidebar item is also removed.
+   * @param {string} address
+   */
+  function removeDeviceDetailsPage(address) {
+    var id = 'devices/' + address.toLowerCase();
+    sidebarObj.removeItem(id);
 
-  /** @type {!device_collection.DeviceCollection} */
-  devices = new device_collection.DeviceCollection([]);
+    var deviceDetailsPage = PageManager.registeredPages[id];
+    assert(deviceDetailsPage, 'Device Details page must exist');
 
-  /** @type {adapter_page.AdapterPage} */
-  var adapterPage = null;
-  /** @type {devices_page.DevicesPage} */
-  var devicesPage = null;
+    deviceDetailsPage.disconnect();
+    deviceDetailsPage.pageDiv.parentNode.removeChild(deviceDetailsPage.pageDiv);
 
-  /** @type {interfaces.BluetoothAdapter.DiscoverySession.ptrClass} */
-  var discoverySession = null;
+    // 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 */);
 
-  /** @type {boolean} */
-  var userRequestedScanStop = false;
+    PageManager.unregister(deviceDetailsPage);
+  }
 
-  function handleInspect(event) {
-    // TODO(crbug.com/663470): Move connection logic to DeviceDetailsView
-    // when it's added in chrome://bluetooth-internals.
-    var address = event.detail.address;
-    var proxy = deviceAddressToProxy.get(address);
+  /**
+   * 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 {!interfaces.BluetoothDevice.Device} deviceInfo
+   * @return {!device_details_page.DeviceDetailsPage}
+   */
+  function makeDeviceDetailsPage(deviceInfo) {
+    var deviceDetailsPageId = 'devices/' + deviceInfo.address.toLowerCase();
+    var deviceDetailsPage = PageManager.registeredPages[deviceDetailsPageId];
+    if (deviceDetailsPage) return deviceDetailsPage;
 
-    if (proxy) {
-      // Device is already connected, so disconnect.
-      proxy.disconnect();
-      deviceAddressToProxy.delete(address);
-      devices.updateConnectionStatus(
-          address, device_collection.ConnectionStatus.DISCONNECTED);
-      return;
-    }
+    var pageSection = document.createElement('section');
+    pageSection.hidden = true;
+    pageSection.id = deviceDetailsPageId;
+    $('page-container').appendChild(pageSection);
 
-    devices.updateConnectionStatus(
-        address, device_collection.ConnectionStatus.CONNECTING);
+    deviceDetailsPage = new DeviceDetailsPage(deviceDetailsPageId, deviceInfo);
+    deviceDetailsPage.pageDiv.addEventListener('connectionchanged',
+        function(event) {
+          devices.updateConnectionStatus(
+              event.detail.address, event.detail.status);
+        });
 
-    adapterBroker.connectToDevice(address).then(function(deviceProxy) {
-      var deviceInfo = devices.getByAddress(address);
-      if (!deviceInfo) {
-        // Device no longer in list, so drop the connection.
-        deviceProxy.disconnect();
-        return;
-      }
-
-      deviceAddressToProxy.set(address, deviceProxy);
-      devices.updateConnectionStatus(
-          address, device_collection.ConnectionStatus.CONNECTED);
-      Snackbar.show(deviceInfo.name_for_display + ': Connected',
-          SnackbarType.SUCCESS);
-
-      // Fetch services asynchronously.
-      return deviceProxy.getServices();
-    }).then(function(response) {
-      if (!response) return;
-
-      var deviceInfo = devices.getByAddress(address);
-      deviceInfo.services = response.services;
-      devices.addOrUpdate(deviceInfo);
-    }).catch(function(error) {
-      // If a connection error occurs while fetching the services, the proxy
-      // reference must be removed.
-      var proxy = deviceAddressToProxy.get(address);
-      if (proxy) {
-        proxy.disconnect();
-        deviceAddressToProxy.delete(address);
-      }
-
-      devices.updateConnectionStatus(
-          address, device_collection.ConnectionStatus.DISCONNECTED);
-
-      var deviceInfo = devices.getByAddress(address);
-      Snackbar.show(deviceInfo.name_for_display + ': ' + error.message,
-          SnackbarType.ERROR, 'Retry', function() { handleInspect(event); });
+    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.name_for_display,
+    });
+
+    deviceDetailsPage.connect();
+    return deviceDetailsPage;
+  }
+
+  /**
+   * Updates the DeviceDetailsPage with the matching device |address| and
+   * redraws it.
+   * @param {string} address
+   */
+  function updateDeviceDetailsPage(address) {
+    var detailPageId = 'devices/' + address.toLowerCase();
+    var page = PageManager.registeredPages[detailPageId];
+    if (page) page.redraw();
   }
 
   function updateStoppedDiscoverySession() {
@@ -150,18 +175,31 @@
     // 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', handleInspect);
+
+    devicesPage.pageDiv.addEventListener('inspectpressed', function(event) {
+      var detailsPage = makeDeviceDetailsPage(
+          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 && discoverySession.ptr.isBound()) {
@@ -215,7 +253,10 @@
 
     // Set up hash-based navigation.
     window.addEventListener('hashchange', function() {
-      PageManager.showPageByName(window.location.hash.substr(1));
+      // If a user navigates and the page doesn't exist, do nothing.
+      var pageName = window.location.hash.substr(1);
+      if ($(pageName))
+        PageManager.showPageByName(pageName);
     });
 
     if (!window.location.hash) {
@@ -223,7 +264,8 @@
       return;
     }
 
-    PageManager.showPageByName(window.location.hash.substr(1));
+    // Only the root pages are available on page load.
+    PageManager.showPageByName(window.location.hash.split('/')[0].substr(1));
   }
 
   function initializeViews() {
@@ -242,7 +284,7 @@
   }
 
   return {
-    initializeViews: initializeViews
+    initializeViews: initializeViews,
   };
 });
 
diff --git a/chrome/browser/resources/bluetooth_internals/device_collection.js b/chrome/browser/resources/bluetooth_internals/device_collection.js
index 6f68ec9..9fdc743 100644
--- a/chrome/browser/resources/bluetooth_internals/device_collection.js
+++ b/chrome/browser/resources/bluetooth_internals/device_collection.js
@@ -89,7 +89,7 @@
      * @param {string} address The address of the device.
      * @param {number} status The new connection status.
      */
-    updateConnectionStatus: function(address, status, opt_error) {
+    updateConnectionStatus: function(address, status) {
       var device = assert(this.getByAddress(address), 'Device does not exist');
       device.connectionStatus = status;
       this.updateIndex(this.indexOf(device));
diff --git a/chrome/browser/resources/bluetooth_internals/device_details_page.js b/chrome/browser/resources/bluetooth_internals/device_details_page.js
new file mode 100644
index 0000000..c50f657
--- /dev/null
+++ b/chrome/browser/resources/bluetooth_internals/device_details_page.js
@@ -0,0 +1,223 @@
+// 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.
+
+/**
+ * Javascript for DeviceDetailsPage which displays all of the details of a
+ * device. The page is generated and managed dynamically in bluetooth_internals.
+ * served from chrome://bluetooth-internals/.
+ */
+
+cr.define('device_details_page', function() {
+  /** @const */ var Page = cr.ui.pageManager.Page;
+  /** @const */ var Snackbar = snackbar.Snackbar;
+  /** @const */ var SnackbarType = snackbar.SnackbarType;
+
+  /**
+   * Property names that will be displayed in the ObjectFieldSet which contains
+   * the DeviceInfo object.
+   */
+  var PROPERTY_NAMES = {
+    name: 'Name',
+    address: 'Address',
+    is_gatt_connected: 'GATT Connected',
+    'rssi.value': 'Latest RSSI',
+    'services.length': 'Services',
+  };
+
+  /**
+   * Page that displays all of the details of a device. This page is generated
+   * and managed dynamically in bluetooth_internals. This page is the owner of
+   * the DevicePtr when a connection is created. Therefore, it manages the
+   * connection to the Bluetooth device and performs all Device interface
+   * related actions.
+   * @constructor
+   * @param {string} id
+   * @param {!interfaces.BluetoothDevice.DeviceInfo} deviceInfo
+   * @extends {cr.ui.pageManager.Page}
+   */
+  function DeviceDetailsPage(id, deviceInfo) {
+    Page.call(this, id, deviceInfo.name_for_display, id);
+
+    /** @type {interfaces.BluetoothDevice.DeviceInfo} */
+    this.deviceInfo = deviceInfo;
+
+    /** @type {interfaces.BluetoothDevice.Device.ptrClass} */
+    this.devicePtr = null;
+
+    /** @type {!object_fieldset.ObjectFieldSet} */
+    this.deviceFieldSet = new object_fieldset.ObjectFieldSet();
+    this.deviceFieldSet.setPropertyDisplayNames(PROPERTY_NAMES);
+
+    /** @private {!device_collection.ConnectionStatus} */
+    this.status_ = device_collection.ConnectionStatus.DISCONNECTED;
+
+    /** @private {?HTMLElement} */
+    this.connectBtn_ = null;
+
+    this.pageDiv.appendChild(
+        document.importNode($('device-details-template').content,
+                            true /* deep */));
+
+    this.pageDiv.querySelector('.device-details').appendChild(
+        this.deviceFieldSet);
+
+    this.pageDiv.querySelector('.forget').addEventListener(
+        'click', function() {
+          this.disconnect();
+          this.pageDiv.dispatchEvent(new CustomEvent('forgetpressed', {
+              detail: {
+                address: this.deviceInfo.address,
+              },
+          }));
+        }.bind(this));
+
+    this.connectBtn_ = this.pageDiv.querySelector('.disconnect');
+    this.connectBtn_.addEventListener('click', function() {
+      this.devicePtr !== null ? this.disconnect() : this.connect();
+    }.bind(this));
+
+    this.redraw();
+  }
+
+  DeviceDetailsPage.prototype = {
+    __proto__: Page.prototype,
+
+    /** Creates a connection to the Bluetooth device. */
+    connect: function() {
+      if (this.status_ !== device_collection.ConnectionStatus.DISCONNECTED)
+        return;
+
+      this.updateConnectionStatus_(
+          device_collection.ConnectionStatus.CONNECTING);
+
+      adapter_broker.getAdapterBroker().then(function(adapterBroker) {
+        return adapterBroker.connectToDevice(this.deviceInfo.address);
+      }.bind(this)).then(function(devicePtr) {
+        this.devicePtr = devicePtr;
+
+        this.updateConnectionStatus_(
+            device_collection.ConnectionStatus.CONNECTED);
+
+        // Fetch services asynchronously.
+        return this.devicePtr.getServices();
+      }.bind(this)).then(function(response) {
+        this.deviceInfo.services = response.services;
+        this.redraw();
+        this.fireDeviceInfoChanged_();
+      }.bind(this)).catch(function(error) {
+        // If a connection error occurs while fetching the services, the
+        // devicePtr reference must be removed.
+        if (this.devicePtr) {
+          this.devicePtr.disconnect();
+          this.devicePtr = null;
+        }
+
+        Snackbar.show(
+            this.deviceInfo.name_for_display + ': ' + error.message,
+            SnackbarType.DANGER, 'Retry', this.connect.bind(this));
+
+        this.updateConnectionStatus_(
+            device_collection.ConnectionStatus.DISCONNECTED);
+      }.bind(this));
+    },
+
+    /** Disconnects the page from the Bluetooth device. */
+    disconnect: function() {
+      if (!this.devicePtr) return;
+
+      this.devicePtr.disconnect();
+      this.devicePtr = null;
+      this.updateConnectionStatus_(
+          device_collection.ConnectionStatus.DISCONNECTED);
+    },
+
+    /** Redraws the contents of the page with the current |deviceInfo|. */
+    redraw: function() {
+      var isConnected = this.deviceInfo.is_gatt_connected;
+
+      // Update status if connection was dropped.
+      if (!isConnected) this.disconnect();
+      var connectedText = isConnected ? 'Connected' : 'Not Connected';
+
+      var rssi = this.deviceInfo.rssi || {};
+      var services = this.deviceInfo.services;
+
+      var rssiValue = 'Unknown';
+      if (rssi.value != null && rssi.value <= 0)
+        rssiValue = rssi.value;
+
+      var serviceCount = 'Unknown';
+      if (services != null && services.length >= 0)
+        serviceCount = services.length;
+
+      var deviceViewObj = {
+        name: this.deviceInfo.name_for_display,
+        address: this.deviceInfo.address,
+        is_gatt_connected: connectedText,
+        'rssi.value': rssiValue,
+        'services.length': serviceCount,
+      };
+
+      this.deviceFieldSet.setObject(deviceViewObj);
+    },
+
+    /**
+     * Sets the page's device info and forces a redraw.
+     * @param {!interfaces.BluetoothDevice.DeviceInfo}
+     */
+    setDeviceInfo: function(info) {
+      this.deviceInfo = info;
+      this.redraw();
+    },
+
+    /**
+     * Fires an 'infochanged' event with the current |deviceInfo|
+     * @private
+     */
+    fireDeviceInfoChanged_: function() {
+      this.pageDiv.dispatchEvent(new CustomEvent('infochanged', {
+        bubbles: true,
+        detail: {
+          info: this.deviceInfo,
+        },
+      }));
+    },
+
+    /**
+     * Updates the current connection status. Caches the latest status, updates
+     * the connection button message, and fires a 'connectionchanged' event when
+     * finished.
+     * @param {!device_collection.ConnectionStatus} status
+     * @private
+     */
+    updateConnectionStatus_: function(status) {
+      if (this.status === status)
+        return;
+
+      this.status_ = status;
+      if (status === device_collection.ConnectionStatus.DISCONNECTED) {
+        this.connectBtn_.textContent = 'Connect';
+        this.connectBtn_.disabled = false;
+      } else if (status === device_collection.ConnectionStatus.CONNECTING) {
+        this.connectBtn_.textContent = 'Connecting';
+        this.connectBtn_.disabled = true;
+      } else {
+        this.connectBtn_.textContent = 'Disconnect';
+        this.connectBtn_.disabled = false;
+      }
+
+      this.pageDiv.dispatchEvent(new CustomEvent('connectionchanged', {
+        bubbles: true,
+        detail: {
+          address: this.deviceInfo.address,
+          status: status,
+        }
+      }));
+    },
+  };
+
+  return {
+    DeviceDetailsPage: DeviceDetailsPage,
+  };
+});
diff --git a/chrome/browser/resources/bluetooth_internals/device_table.js b/chrome/browser/resources/bluetooth_internals/device_table.js
index be9e4b0e..b177864f 100644
--- a/chrome/browser/resources/bluetooth_internals/device_table.js
+++ b/chrome/browser/resources/bluetooth_internals/device_table.js
@@ -13,7 +13,7 @@
     RSSI: 2,
     SERVICES: 3,
     CONNECTION_STATE: 4,
-    INSPECT_LINK: 5,
+    LINKS: 5,
   };
 
   /**
@@ -43,6 +43,8 @@
       this.body_ = this.tBodies[0];
       /** @private */
       this.headers_ = this.tHead.rows[0].cells;
+      /** @private {!Map<!interfaces.BluetoothDevice.DeviceInfo, boolean>} */
+      this.inspectionMap_ = new Map();
     },
 
     /**
@@ -61,18 +63,45 @@
     },
 
     /**
-     * Updates table row on change event of the device collection.
+     * Updates the inspect status of the row matching the given |deviceInfo|.
+     * If |isInspecting| is true, the forget link is enabled otherwise it's
+     * disabled.
+     * @param {!interfaces.BluetoothDevice.DeviceInfo} deviceInfo
+     * @param {boolean} isInspecting
+     */
+    setInspecting: function(deviceInfo, isInspecting) {
+      this.inspectionMap_.set(deviceInfo, isInspecting);
+      this.updateRow_(deviceInfo, this.devices_.indexOf(deviceInfo));
+    },
+
+    /**
+     * Fires a forget pressed event for the row |index|.
+     * @param {number} index
      * @private
+     */
+    handleForgetClick_: function(index) {
+      var event = new CustomEvent('forgetpressed', {
+        bubbles: true,
+        detail: {
+          address: this.devices_.item(index).address,
+        }
+      });
+      this.dispatchEvent(event);
+    },
+
+    /**
+     * Updates table row on change event of the device collection.
      * @param {!Event} event
+     * @private
      */
     handleChange_: function(event) {
       this.updateRow_(this.devices_.item(event.index), event.index);
     },
 
     /**
-     * Fires a inspect pressed event for the row |index|.
-     * @private
+     * Fires an inspect pressed event for the row |index|.
      * @param {number} index
+     * @private
      */
     handleInspectClick_: function(index) {
       var event = new CustomEvent('inspectpressed', {
@@ -86,8 +115,8 @@
 
     /**
      * Updates table row on splice event of the device collection.
-     * @private
      * @param {!Event} event
+     * @private
      */
     handleSplice_: function(event) {
       event.removed.forEach(function() {
@@ -101,15 +130,17 @@
 
     /**
      * Inserts a new row at |index| and updates it with info from |device|.
-     * @private
      * @param {!interfaces.BluetoothDevice.DeviceInfo} device
      * @param {?number} index
+     * @private
      */
     insertRow_: function(device, index) {
       var row = this.body_.insertRow(index);
       row.id = device.address;
 
       for (var i = 0; i < this.headers_.length; i++) {
+        // Skip the LINKS column. It has no data-field attribute.
+        if (i === COLUMNS.LINKS) continue;
         row.insertCell();
       }
 
@@ -117,11 +148,19 @@
       var inspectCell = row.insertCell();
 
       var inspectLink = document.createElement('a', 'action-link');
+      inspectLink.textContent = 'Inspect';
       inspectCell.appendChild(inspectLink);
       inspectLink.addEventListener('click', function() {
         this.handleInspectClick_(row.sectionRowIndex);
       }.bind(this));
 
+      var forgetLink = document.createElement('a', 'action-link');
+      forgetLink.textContent = 'Forget';
+      inspectCell.appendChild(forgetLink);
+      forgetLink.addEventListener('click', function() {
+        this.handleForgetClick_(row.sectionRowIndex);
+      }.bind(this));
+
       this.updateRow_(device, row.sectionRowIndex);
     },
 
@@ -142,9 +181,9 @@
 
     /**
      * Updates the row at |index| with the info from |device|.
-     * @private
      * @param {!interfaces.BluetoothDevice.DeviceInfo} device
      * @param {number} index
+     * @private
      */
     updateRow_: function(device, index) {
       var row = this.body_.rows[index];
@@ -152,23 +191,18 @@
 
       row.classList.toggle('removed', device.removed);
 
-      var inspectLink = row.cells[COLUMNS.INSPECT_LINK].children[0];
-      inspectLink.disabled = false;
-      switch (device.connectionStatus) {
-        case device_collection.ConnectionStatus.DISCONNECTED:
-          inspectLink.textContent = 'Inspect';
-          break;
-        case device_collection.ConnectionStatus.CONNECTED:
-          inspectLink.textContent = 'Forget';
-          break;
-        case device_collection.ConnectionStatus.CONNECTING:
-          inspectLink.disabled = true;
-          break;
-        default: assert('case not handled');
-      }
+      var forgetLink = row.cells[COLUMNS.LINKS].children[1];
+
+      if (this.inspectionMap_.has(device))
+        forgetLink.disabled = !this.inspectionMap_.get(device);
+      else
+        forgetLink.disabled = true;
 
       // Update the properties based on the header field path.
       for (var i = 0; i < this.headers_.length; i++) {
+        // Skip the LINKS column. It has no data-field attribute.
+        if (i === COLUMNS.LINKS) continue;
+
         var header = this.headers_[i];
         var propName = header.dataset.field;
 
diff --git a/chrome/browser/resources/bluetooth_internals/devices_page.js b/chrome/browser/resources/bluetooth_internals/devices_page.js
index 1476f8a..39c72a7 100644
--- a/chrome/browser/resources/bluetooth_internals/devices_page.js
+++ b/chrome/browser/resources/bluetooth_internals/devices_page.js
@@ -49,6 +49,15 @@
       this.deviceTable.setDevices(devices);
     },
 
+    /**
+     * Updates the inspect status of the given |deviceInfo| in the device table.
+     * @param {!interfaces.BluetoothDevice.DeviceInfo} deviceInfo
+     * @param {boolean} isInspecting
+     */
+    setInspecting: function(deviceInfo, isInspecting) {
+      this.deviceTable.setInspecting(deviceInfo, isInspecting);
+    },
+
     setScanStatus: function(status) {
       switch (status) {
         case ScanStatus.OFF:
diff --git a/chrome/browser/resources/bluetooth_internals/sidebar.js b/chrome/browser/resources/bluetooth_internals/sidebar.js
index f060e713..9071614 100644
--- a/chrome/browser/resources/bluetooth_internals/sidebar.js
+++ b/chrome/browser/resources/bluetooth_internals/sidebar.js
@@ -7,6 +7,9 @@
  */
 
 cr.define('sidebar', function() {
+  /** @typedef {{pageName: string, text: string}} */
+  var SidebarItem;
+
   /** @const {!cr.ui.pageManager.PageManager}*/
   var PageManager = cr.ui.pageManager.PageManager;
 
@@ -40,6 +43,23 @@
     __proto__: PageManager.Observer.prototype,
 
     /**
+     * Adds a new list item to the sidebar using the given |item|.
+     * @param {!SidebarItem} item
+     */
+    addItem: function(item) {
+      var sidebarItem = document.createElement('li');
+      sidebarItem.dataset.pageName = item.pageName.toLowerCase();
+
+      var button = document.createElement('button');
+      button.classList.add('custom-appearance');
+      button.textContent = item.text;
+      button.addEventListener('click', this.onItemClick_.bind(this));
+      sidebarItem.appendChild(button);
+
+      this.sidebarList_.appendChild(sidebarItem);
+    },
+
+    /**
      * Closes the sidebar. Only applies to layouts with window width <= 600px.
      */
     close: function() {
@@ -58,6 +78,16 @@
     },
 
     /**
+     * Removes a sidebar item where |pageName| matches the item's pageName.
+     * @param {string} pageName
+     */
+    removeItem: function(pageName) {
+      pageName = pageName.toLowerCase();
+      var query = 'li[data-page-name="' + pageName + '"]';
+      this.sidebarList_.removeChild(this.sidebarList_.querySelector(query));
+    },
+
+    /**
      * Called when a page is navigated to.
      * @override
      * @param {string} path The path of the page being visited.
diff --git a/chrome/browser/ui/webui/bluetooth_internals/bluetooth_internals_ui.cc b/chrome/browser/ui/webui/bluetooth_internals/bluetooth_internals_ui.cc
index 13f7975..3a7b034 100644
--- a/chrome/browser/ui/webui/bluetooth_internals/bluetooth_internals_ui.cc
+++ b/chrome/browser/ui/webui/bluetooth_internals/bluetooth_internals_ui.cc
@@ -26,6 +26,8 @@
                                IDR_BLUETOOTH_INTERNALS_JS);
   html_source->AddResourcePath("device_collection.js",
                                IDR_BLUETOOTH_INTERNALS_DEVICE_COLLECTION_JS);
+  html_source->AddResourcePath("device_details_page.js",
+                               IDR_BLUETOOTH_INTERNALS_DEVICE_DETAILS_PAGE_JS);
   html_source->AddResourcePath("device_table.js",
                                IDR_BLUETOOTH_INTERNALS_DEVICE_TABLE_JS);
   html_source->AddResourcePath("devices_page.js",
diff --git a/chrome/test/data/webui/bluetooth_internals_browsertest.js b/chrome/test/data/webui/bluetooth_internals_browsertest.js
index d68f2e7..7931e26 100644
--- a/chrome/test/data/webui/bluetooth_internals_browsertest.js
+++ b/chrome/test/data/webui/bluetooth_internals_browsertest.js
@@ -53,12 +53,12 @@
         'mojo/public/js/bindings',
       ]).then(function([frameInterfaces, adapter, device, bindings]) {
         /**
-          * A test adapter factory proxy for the chrome://bluetooth-internals
-          * page.
-          *
-          * @constructor
-          * @extends {TestBrowserProxyBase}
-          */
+         * A test adapter factory proxy for the chrome://bluetooth-internals
+         * page.
+         *
+         * @constructor
+         * @extends {TestBrowserProxyBase}
+         */
         var TestAdapterFactoryProxy = function() {
           settings.TestBrowserProxy.call(this, [
             'getAdapter',
@@ -83,11 +83,10 @@
         };
 
         /**
-          * A test adapter proxy for the chrome://bluetooth-internals page.
-          *
-          * @constructor
-          * @extends {TestBrowserProxyBase}
-          */
+         * A test adapter proxy for the chrome://bluetooth-internals page.
+         * @constructor
+         * @extends {TestBrowserProxyBase}
+         */
         var TestAdapterProxy = function() {
           settings.TestBrowserProxy.call(this, [
             'getInfo',
@@ -95,13 +94,25 @@
             'setClient',
           ]);
 
+          this.deviceProxyMap = new Map();
           this.adapterInfo_ = null;
           this.devices_ = [];
+          this.connectResult_ = adapter.AdapterInfo.SUCCESS;
         };
 
         TestAdapterProxy.prototype = {
           __proto__: settings.TestBrowserProxy.prototype,
 
+          connectToDevice: function(address) {
+            assert(this.deviceProxyMap.has(address), 'Device does not exist');
+
+            return Promise.resolve({
+              result: this.connectResult_,
+              device: this.deviceProxyMap.get(
+                  address).binding.createInterfacePtrAndBind(),
+            });
+          },
+
           getInfo: function() {
             this.methodCalled('getInfo');
             return Promise.resolve({info: this.adapterInfo_});
@@ -120,11 +131,60 @@
             this.adapterInfo_ = adapterInfo;
           },
 
+          setTestConnectResult: function(connectResult) {
+            this.connectResult_ = connectResult;
+          },
+
           setTestDevices: function(devices) {
             this.devices_ = devices;
-          }
+            this.devices_.forEach(function(device) {
+              this.deviceProxyMap.set(
+                  device.address, new TestDeviceProxy(device));
+            }, this);
+          },
         };
 
+        /**
+         * A test Device proxy for the chrome://bluetooth-internals
+         * page. Proxies are generated by a TestAdapterProxy which provides
+         * the DeviceInfo.
+         * @constructor
+         * @extends {TestBrowserProxyBase}
+         * @param {!device.DeviceInfo} info
+         */
+        var TestDeviceProxy = function(info) {
+          settings.TestBrowserProxy.call(this, [
+            'getInfo',
+            'getServices',
+          ]);
+
+          this.binding = new bindings.Binding(device.Device, this);
+          this.info_ = info;
+          this.services_ = [];
+        }
+
+        TestDeviceProxy.prototype = {
+          __proto__: settings.TestBrowserProxy.prototype,
+
+          disconnect: function() {
+            this.binding.close();
+          },
+
+          getInfo: function() {
+            this.methodCalled('getInfo');
+            return Promise.resolve({info: this.info_});
+          },
+
+          getServices: function() {
+            this.methodCalled('getServices');
+            return Promise.resolve({services: this.services_});
+          },
+
+          setTestServices: function(services) {
+            this.services_ = services;
+          },
+        }
+
         frameInterfaces.addInterfaceOverrideForTesting(
             adapter.AdapterFactory.name, function(handle) {
               this.adapterFactory = new TestAdapterFactoryProxy();
@@ -137,6 +197,14 @@
               this.adapterFactory.adapter.setTestAdapter(
                   this.fakeAdapterInfo());
 
+              this.adapterFactory.adapter.deviceProxyMap.forEach(
+                  function(deviceProxy) {
+                    deviceProxy.setTestServices([
+                      this.fakeServiceInfo1(),
+                      this.fakeServiceInfo2(),
+                    ])
+                  }, this);
+
               this.setupResolver.resolve();
             }.bind(this));
 
@@ -200,6 +268,28 @@
       name_for_display: "CCC",
     };
   },
+
+  /**
+   * Returns a copy of fake service info object (variant 1).
+   * @return {!Object}
+   */
+  fakeServiceInfo1: function() {
+    return {
+      uuid: '00002a05-0000-1000-8000-00805f9b34fb',
+      is_primary: true,
+    }
+  },
+
+  /**
+   * Returns a copy of fake service info object (variant 2).
+   * @return {!Object}
+   */
+  fakeServiceInfo2: function() {
+    return {
+      uuid: '0000180d-0000-1000-8000-00805f9b34fb',
+      is_primary: true,
+    }
+  },
 };
 
 TEST_F('BluetoothInternalsTest', 'Startup_BluetoothInternals', function() {
@@ -209,11 +299,14 @@
   var adapterFieldSet = null;
   var deviceTable = null;
   var sidebarNode = null;
+  var pageNames = ['adapter', 'devices'];
 
   var fakeAdapterInfo = this.fakeAdapterInfo;
   var fakeDeviceInfo1 = this.fakeDeviceInfo1;
   var fakeDeviceInfo2 = this.fakeDeviceInfo2;
   var fakeDeviceInfo3 = this.fakeDeviceInfo3;
+  var fakeServiceInfo1 = this.fakeServiceInfo1;
+  var fakeServiceInfo2 = this.fakeServiceInfo2;
 
   // Before tests are run, make sure setup completes.
   var setupPromise = this.setupResolver.promise.then(function() {
@@ -241,13 +334,28 @@
       devices.splice(0, devices.length);
       adapterBroker.adapterClient_.deviceAdded(fakeDeviceInfo1());
       adapterBroker.adapterClient_.deviceAdded(fakeDeviceInfo2());
+
     });
 
     teardown(function() {
       adapterFactory.reset();
       sidebarObj.close();
       snackbar.Snackbar.dismiss(true);
+
+      adapterFactory.adapter.deviceProxyMap.forEach(function(deviceProxy) {
+        deviceProxy.reset();
+      });
+
       PageManager.registeredPages['adapter'].setAdapterInfo(fakeAdapterInfo());
+
+      for (var pageName in PageManager.registeredPages) {
+        var page = PageManager.registeredPages[pageName];
+
+        if (pageNames.indexOf(pageName) < 0) {
+          page.pageDiv.parentNode.removeChild(page.pageDiv);
+          PageManager.unregister(page);
+        }
+      }
     });
 
     /**
@@ -417,7 +525,7 @@
       var sidebarItems = Array.from(
           sidebarNode.querySelectorAll('.sidebar-content li'));
 
-      ['adapter', 'devices'].forEach(function(pageName) {
+      pageNames.forEach(function(pageName) {
         expectTrue(sidebarItems.some(function(item) {
           return item.dataset.pageName === pageName;
         }));
@@ -600,6 +708,97 @@
       adapterBroker.adapterClient_.discoveringChanged(adapterInfo.discovering);
       checkAdapterFieldSet(adapterInfo);
     });
+
+    /** Device Details Page Tests */
+
+    /**
+     * Checks DeviceDetailsPage status fieldset.
+     * @param {!HTMLElement} detailsPage
+     * @param {!Object} deviceInfo
+     */
+    function checkDeviceDetailsFieldSet(detailsPage, deviceInfo) {
+      [
+        'name',
+        'address',
+        'is_gatt_connected',
+        'rssi.value',
+        'services.length',
+      ].forEach(function(propName){
+        var valueCell = detailsPage.querySelector(
+            'fieldset [data-field="' + propName + '"]');
+
+        var parts = propName.split('.');
+        var value = deviceInfo;
+
+        while (value != null && parts.length > 0) {
+          var part = parts.shift();
+          value = value[part];
+        }
+
+        if (propName == 'is_gatt_connected')
+          value = value ? 'Connected' : 'Not Connected';
+
+        if (typeof(value) === 'boolean') {
+          expectEquals(value, valueCell.classList.contains('checked'));
+        } else if (typeof(value) === 'string') {
+          expectEquals(value, valueCell.textContent);
+        } else {
+          assert('boolean or string type expected but got ' + typeof(value));
+        }
+      });
+    }
+
+    test('DeviceDetailsPage_NewDelete', function() {
+      var device = devices.item(0);
+
+      var deviceInspectLink = $(device.address).querySelector(
+          '[is="action-link"]');
+
+      var deviceDetailsPageId = 'devices/' + device.address.toLowerCase();
+
+      deviceInspectLink.click();
+      expectEquals("#" + deviceDetailsPageId, window.location.hash);
+
+      var detailsPage = $(deviceDetailsPageId);
+      assertTrue(!!detailsPage);
+
+      return adapterFactory.adapter.deviceProxyMap.get(
+          device.address).whenCalled('getServices').then(function() {
+            // At this point, the device details page should be fully loaded.
+            checkDeviceDetailsFieldSet(detailsPage, device);
+
+            detailsPage.querySelector('.forget').click();
+            expectEquals('#devices', window.location.hash);
+            detailsPage = $(deviceDetailsPageId);
+            expectFalse(!!detailsPage);
+          });
+    });
+
+    test('DeviceDetailsPage_NewDelete_FromDevicesPage', function() {
+      var device = devices.item(0);
+      var deviceDetailsPageId = 'devices/' + device.address.toLowerCase();
+
+      var deviceLinks = $(device.address).querySelectorAll(
+          '[is="action-link"]');
+
+      // First link is 'Inspect'.
+      deviceLinks[0].click();
+      expectEquals("#" + deviceDetailsPageId, window.location.hash);
+
+      var detailsPage = $(deviceDetailsPageId);
+      assertTrue(!!detailsPage);
+
+      return adapterFactory.adapter.deviceProxyMap.get(
+          device.address).whenCalled('getServices').then(function() {
+            checkDeviceDetailsFieldSet(detailsPage, device);
+
+            // Second link is 'Forget'.
+            deviceLinks[1].click();
+            expectEquals('#devices', window.location.hash);
+            detailsPage = $(deviceDetailsPageId);
+            expectFalse(!!detailsPage);
+          });
+    });
   });
 
   // Run all registered tests.
diff --git a/ui/webui/resources/js/cr/ui/page_manager/page_manager.js b/ui/webui/resources/js/cr/ui/page_manager/page_manager.js
index 99599b1..4090722 100644
--- a/ui/webui/resources/js/cr/ui/page_manager/page_manager.js
+++ b/ui/webui/resources/js/cr/ui/page_manager/page_manager.js
@@ -83,6 +83,14 @@
     },
 
     /**
+     * Unregisters an existing page.
+     * @param {!cr.ui.pageManager.Page} page Page to unregister.
+     */
+    unregister: function(page) {
+      delete this.registeredPages[page.name.toLowerCase()];
+    },
+
+    /**
      * Registers a new Overlay page.
      * @param {!cr.ui.pageManager.Page} overlay Overlay to register.
      * @param {cr.ui.pageManager.Page} parentPage Associated parent page for