Merge fx-team to m-c. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Tue, 11 Nov 2014 16:43:46 -0500
changeset 226489 c504dc2601d164d3c675b727e214cd003500d264
parent 226468 feba300a5eec8d9e406084473e3990049809aed9 (current diff)
parent 226488 6808270d3dae2235eacde38e95e6e01523608201 (diff)
child 226490 a926116946f8e108599e4d86196b0e7da7bc1644
push id53
push userdglastonbury@mozilla.com
push dateWed, 12 Nov 2014 02:04:58 +0000
reviewersmerge
milestone36.0a1
Merge fx-team to m-c. a=merge
browser/components/loop/content/shared/img/loading-icon.gif
browser/devtools/shared/test/browser_graphs-09.js
browser/devtools/timeline/widgets/overview.js
--- a/browser/branding/aurora/configure.sh
+++ b/browser/branding/aurora/configure.sh
@@ -1,6 +1,7 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 MOZ_APP_DISPLAYNAME=FirefoxDeveloperEdition
 MOZ_APP_REMOTINGNAME=firefox-dev
+MOZ_DEV_EDITION=1
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -412,16 +412,18 @@ loop.contacts = (function(_, mozL10n) {
         return comp;
       }
       // If names are equal, compare against unique ids to make sure we have
       // consistent ordering.
       return contact1._guid - contact2._guid;
     },
 
     render: function() {
+      let cx = React.addons.classSet;
+
       let viewForItem = item => {
         return ContactDetail({key: item._guid, contact: item, 
                               handleContactAction: this.handleContactAction})
       };
 
       let shownContacts = _.groupBy(this.contacts, function(contact) {
         return contact.blocked ? "blocked" : "available";
       });
@@ -439,26 +441,29 @@ loop.contacts = (function(_, mozL10n) {
             shownContacts.available = shownContacts.available.filter(filterFn);
           }
           if (shownContacts.blocked) {
             shownContacts.blocked = shownContacts.blocked.filter(filterFn);
           }
         }
       }
 
-      // TODO: bug 1076767 - add a spinner whilst importing contacts.
       return (
         React.DOM.div(null, 
           React.DOM.div({className: "content-area"}, 
             ButtonGroup(null, 
               Button({caption: this.state.importBusy
                                ? mozL10n.get("importing_contacts_progress_button")
                                : mozL10n.get("import_contacts_button"), 
                       disabled: this.state.importBusy, 
-                      onClick: this.handleImportButtonClick}), 
+                      onClick: this.handleImportButtonClick}, 
+                React.DOM.div({className: cx({"contact-import-spinner": true,
+                                    spinner: true,
+                                    busy: this.state.importBusy})})
+              ), 
               Button({caption: mozL10n.get("new_contact_button"), 
                       onClick: this.handleAddContactButtonClick})
             ), 
             showFilter ?
             React.DOM.input({className: "contact-filter", 
                    placeholder: mozL10n.get("contacts_search_placesholder"), 
                    valueLink: this.linkState("filter")})
             : null
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -412,16 +412,18 @@ loop.contacts = (function(_, mozL10n) {
         return comp;
       }
       // If names are equal, compare against unique ids to make sure we have
       // consistent ordering.
       return contact1._guid - contact2._guid;
     },
 
     render: function() {
+      let cx = React.addons.classSet;
+
       let viewForItem = item => {
         return <ContactDetail key={item._guid} contact={item}
                               handleContactAction={this.handleContactAction} />
       };
 
       let shownContacts = _.groupBy(this.contacts, function(contact) {
         return contact.blocked ? "blocked" : "available";
       });
@@ -439,26 +441,29 @@ loop.contacts = (function(_, mozL10n) {
             shownContacts.available = shownContacts.available.filter(filterFn);
           }
           if (shownContacts.blocked) {
             shownContacts.blocked = shownContacts.blocked.filter(filterFn);
           }
         }
       }
 
-      // TODO: bug 1076767 - add a spinner whilst importing contacts.
       return (
         <div>
           <div className="content-area">
             <ButtonGroup>
               <Button caption={this.state.importBusy
                                ? mozL10n.get("importing_contacts_progress_button")
                                : mozL10n.get("import_contacts_button")}
                       disabled={this.state.importBusy}
-                      onClick={this.handleImportButtonClick} />
+                      onClick={this.handleImportButtonClick}>
+                <div className={cx({"contact-import-spinner": true,
+                                    spinner: true,
+                                    busy: this.state.importBusy})} />
+              </Button>
               <Button caption={mozL10n.get("new_contact_button")}
                       onClick={this.handleAddContactButtonClick} />
             </ButtonGroup>
             {showFilter ?
             <input className="contact-filter"
                    placeholder={mozL10n.get("contacts_search_placesholder")}
                    valueLink={this.linkState("filter")} />
             : null }
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -13,17 +13,17 @@ loop.conversation = (function(mozL10n) {
 
   var sharedViews = loop.shared.views;
   var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
   var sharedActions = loop.shared.actions;
 
   var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
   var CallIdentifierView = loop.conversationViews.CallIdentifierView;
-  var DesktopRoomControllerView = loop.roomViews.DesktopRoomControllerView;
+  var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
 
   var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
     mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.AudioMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
@@ -579,20 +579,20 @@ loop.conversation = (function(mozL10n) {
         }
         case "outgoing": {
           return (OutgoingConversationView({
             store: this.props.conversationStore, 
             dispatcher: this.props.dispatcher}
           ));
         }
         case "room": {
-          return (DesktopRoomControllerView({
-            mozLoop: navigator.mozLoop, 
+          return (DesktopRoomConversationView({
             dispatcher: this.props.dispatcher, 
-            roomStore: this.props.roomStore}
+            roomStore: this.props.roomStore, 
+            dispatcher: this.props.dispatcher}
           ));
         }
         case "failed": {
           return (GenericFailureView({
             cancelCall: this.closeWindow}
           ));
         }
         default: {
@@ -638,17 +638,18 @@ loop.conversation = (function(mozL10n) {
     });
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
       dispatcher: dispatcher,
       sdkDriver: sdkDriver
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: navigator.mozLoop
+      mozLoop: navigator.mozLoop,
+      sdkDriver: sdkDriver
     });
     var roomStore = new loop.store.RoomStore({
       dispatcher: dispatcher,
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
 
     // XXX Old class creation for the incoming conversation view, whilst
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -13,17 +13,17 @@ loop.conversation = (function(mozL10n) {
 
   var sharedViews = loop.shared.views;
   var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
   var sharedActions = loop.shared.actions;
 
   var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
   var CallIdentifierView = loop.conversationViews.CallIdentifierView;
-  var DesktopRoomControllerView = loop.roomViews.DesktopRoomControllerView;
+  var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
 
   var IncomingCallView = React.createClass({
     mixins: [sharedMixins.DropdownMenuMixin, sharedMixins.AudioMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
@@ -579,20 +579,20 @@ loop.conversation = (function(mozL10n) {
         }
         case "outgoing": {
           return (<OutgoingConversationView
             store={this.props.conversationStore}
             dispatcher={this.props.dispatcher}
           />);
         }
         case "room": {
-          return (<DesktopRoomControllerView
-            mozLoop={navigator.mozLoop}
+          return (<DesktopRoomConversationView
             dispatcher={this.props.dispatcher}
             roomStore={this.props.roomStore}
+            dispatcher={this.props.dispatcher}
           />);
         }
         case "failed": {
           return (<GenericFailureView
             cancelCall={this.closeWindow}
           />);
         }
         default: {
@@ -638,17 +638,18 @@ loop.conversation = (function(mozL10n) {
     });
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
       dispatcher: dispatcher,
       sdkDriver: sdkDriver
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: navigator.mozLoop
+      mozLoop: navigator.mozLoop,
+      sdkDriver: sdkDriver
     });
     var roomStore = new loop.store.RoomStore({
       dispatcher: dispatcher,
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
 
     // XXX Old class creation for the incoming conversation view, whilst
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -394,28 +394,31 @@ loop.panel = (function(_, mozL10n) {
     },
 
     render: function() {
       // XXX setting elem value from a state (in the callUrl input)
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
-      var inputCSSClass = cx({
-        "pending": this.state.pending,
-        // Used in functional testing, signals that
-        // call url was received from loop server
-        "callUrl": !this.state.pending
-      });
       return (
         React.DOM.div({className: "generate-url"}, 
           React.DOM.header(null, __("share_link_header_text")), 
-          React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true", 
-                 onCopy: this.handleLinkExfiltration, 
-                 className: inputCSSClass}), 
+          React.DOM.div({className: "generate-url-stack"}, 
+            React.DOM.input({type: "url", value: this.state.callUrl, readOnly: "true", 
+                   onCopy: this.handleLinkExfiltration, 
+                   className: cx({"generate-url-input": true,
+                                  pending: this.state.pending,
+                                  // Used in functional testing, signals that
+                                  // call url was received from loop server
+                                  callUrl: !this.state.pending})}), 
+            React.DOM.div({className: cx({"generate-url-spinner": true,
+                                spinner: true,
+                                busy: this.state.pending})})
+          ), 
           ButtonGroup({additionalClass: "url-actions"}, 
             Button({additionalClass: "button-email", 
                     disabled: !this.state.callUrl, 
                     onClick: this.handleEmailButtonClick, 
                     caption: mozL10n.get("share_button")}), 
             Button({additionalClass: "button-copy", 
                     disabled: !this.state.callUrl, 
                     onClick: this.handleCopyButtonClick, 
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -394,28 +394,31 @@ loop.panel = (function(_, mozL10n) {
     },
 
     render: function() {
       // XXX setting elem value from a state (in the callUrl input)
       // makes it immutable ie read only but that is fine in our case.
       // readOnly attr will suppress a warning regarding this issue
       // from the react lib.
       var cx = React.addons.classSet;
-      var inputCSSClass = cx({
-        "pending": this.state.pending,
-        // Used in functional testing, signals that
-        // call url was received from loop server
-        "callUrl": !this.state.pending
-      });
       return (
         <div className="generate-url">
           <header>{__("share_link_header_text")}</header>
-          <input type="url" value={this.state.callUrl} readOnly="true"
-                 onCopy={this.handleLinkExfiltration}
-                 className={inputCSSClass} />
+          <div className="generate-url-stack">
+            <input type="url" value={this.state.callUrl} readOnly="true"
+                   onCopy={this.handleLinkExfiltration}
+                   className={cx({"generate-url-input": true,
+                                  pending: this.state.pending,
+                                  // Used in functional testing, signals that
+                                  // call url was received from loop server
+                                  callUrl: !this.state.pending})} />
+            <div className={cx({"generate-url-spinner": true,
+                                spinner: true,
+                                busy: this.state.pending})} />
+          </div>
           <ButtonGroup additionalClass="url-actions">
             <Button additionalClass="button-email"
                     disabled={!this.state.callUrl}
                     onClick={this.handleEmailButtonClick}
                     caption={mozL10n.get("share_button")} />
             <Button additionalClass="button-copy"
                     disabled={!this.state.callUrl}
                     onClick={this.handleCopyButtonClick}
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -6,16 +6,17 @@
 
 /* jshint newcap:false */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
+  var sharedActions = loop.shared.actions;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedViews = loop.shared.views;
 
   function noop() {}
 
   /**
    * ActiveRoomStore mixin.
    * @type {Object}
@@ -32,21 +33,30 @@ loop.roomViews = (function(mozL10n) {
                     this._onActiveRoomStateChanged);
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.roomStore);
     },
 
     _onActiveRoomStateChanged: function() {
-      this.setState(this.props.roomStore.getStoreState("activeRoom"));
+      // Only update the state if we're mounted, to avoid the problem where
+      // stopListening doesn't nuke the active listeners during a event
+      // processing.
+      if (this.isMounted()) {
+        this.setState(this.props.roomStore.getStoreState("activeRoom"));
+      }
     },
 
     getInitialState: function() {
-      return this.props.roomStore.getStoreState("activeRoom");
+      var storeState = this.props.roomStore.getStoreState("activeRoom");
+      return _.extend(storeState, {
+        // Used by the UI showcase.
+        roomState: this.props.roomState || storeState.roomState
+      });
     }
   };
 
   /**
    * Desktop room invitation view (overlay).
    */
   var DesktopRoomInvitationView = React.createClass({displayName: 'DesktopRoomInvitationView',
     mixins: [ActiveRoomStoreMixin],
@@ -67,140 +77,182 @@ loop.roomViews = (function(mozL10n) {
 
     handleCopyButtonClick: function(event) {
       event.preventDefault();
       // XXX
     },
 
     render: function() {
       return (
-        React.DOM.div({className: "room-conversation-wrapper"}, 
-          React.DOM.div({className: "room-invitation-overlay"}, 
-            React.DOM.form({onSubmit: this.handleFormSubmit}, 
-              React.DOM.input({type: "text", ref: "roomName", 
-                placeholder: mozL10n.get("rooms_name_this_room_label")})
+        React.DOM.div({className: "room-invitation-overlay"}, 
+          React.DOM.form({onSubmit: this.handleFormSubmit}, 
+            React.DOM.input({type: "text", ref: "roomName", 
+              placeholder: mozL10n.get("rooms_name_this_room_label")})
+          ), 
+          React.DOM.p(null, mozL10n.get("invite_header_text")), 
+          React.DOM.div({className: "btn-group call-action-group"}, 
+            React.DOM.button({className: "btn btn-info btn-email", 
+                    onClick: this.handleEmailButtonClick}, 
+              mozL10n.get("share_button2")
             ), 
-            React.DOM.p(null, mozL10n.get("invite_header_text")), 
-            React.DOM.div({className: "btn-group call-action-group"}, 
-              React.DOM.button({className: "btn btn-info btn-email", 
-                      onClick: this.handleEmailButtonClick}, 
-                mozL10n.get("share_button2")
-              ), 
-              React.DOM.button({className: "btn btn-info btn-copy", 
-                      onClick: this.handleCopyButtonClick}, 
-                mozL10n.get("copy_url_button2")
-              )
+            React.DOM.button({className: "btn btn-info btn-copy", 
+                    onClick: this.handleCopyButtonClick}, 
+              mozL10n.get("copy_url_button2")
             )
-          ), 
-          DesktopRoomConversationView({roomStore: this.props.roomStore})
+          )
         )
       );
     }
   });
 
   /**
    * Desktop room conversation view.
    */
   var DesktopRoomConversationView = React.createClass({displayName: 'DesktopRoomConversationView',
-    mixins: [ActiveRoomStoreMixin],
-
-    propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      video: React.PropTypes.object,
-      audio: React.PropTypes.object,
-      displayInvitation: React.PropTypes.bool
-    },
-
-    getDefaultProps: function() {
-      return {
-        video: {enabled: true, visible: true},
-        audio: {enabled: true, visible: true}
-      };
-    },
-
-    render: function() {
-      var localStreamClasses = React.addons.classSet({
-        local: true,
-        "local-stream": true,
-        "local-stream-audio": !this.props.video.enabled
-      });
-      return (
-        React.DOM.div({className: "room-conversation-wrapper"}, 
-          React.DOM.div({className: "video-layout-wrapper"}, 
-            React.DOM.div({className: "conversation room-conversation"}, 
-              React.DOM.div({className: "media nested"}, 
-                React.DOM.div({className: "video_wrapper remote_wrapper"}, 
-                  React.DOM.div({className: "video_inner remote"})
-                ), 
-                React.DOM.div({className: localStreamClasses})
-              ), 
-              sharedViews.ConversationToolbar({
-                video: this.props.video, 
-                audio: this.props.audio, 
-                publishStream: noop, 
-                hangup: noop})
-            )
-          )
-        )
-      );
-    }
-  });
-
-  /**
-   * Desktop room controller view.
-   */
-  var DesktopRoomControllerView = React.createClass({displayName: 'DesktopRoomControllerView',
     mixins: [ActiveRoomStoreMixin, loop.shared.mixins.DocumentTitleMixin],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
     },
 
+    _renderInvitationOverlay: function() {
+      if (this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS) {
+        return DesktopRoomInvitationView({
+          roomStore: this.props.roomStore, 
+          dispatcher: this.props.dispatcher}
+        );
+      }
+      return null;
+    },
+
+    componentDidMount: function() {
+      /**
+       * OT inserts inline styles into the markup. Using a listener for
+       * resize events helps us trigger a full width/height on the element
+       * so that they update to the correct dimensions.
+       * XXX: this should be factored as a mixin.
+       */
+      window.addEventListener('orientationchange', this.updateVideoContainer);
+      window.addEventListener('resize', this.updateVideoContainer);
+
+      // The SDK needs to know about the configuration and the elements to use
+      // for display. So the best way seems to pass the information here - ideally
+      // the sdk wouldn't need to know this, but we can't change that.
+      this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        publisherConfig: this._getPublisherConfig(),
+        getLocalElementFunc: this._getElement.bind(this, ".local"),
+        getRemoteElementFunc: this._getElement.bind(this, ".remote")
+      }));
+    },
+
+    _getPublisherConfig: function() {
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: !this.state.videoMuted,
+        style: {
+          audioLevelDisplayMode: "off",
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off",
+          videoDisabledDisplayMode: "off"
+        }
+      };
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
+    updateVideoContainer: function() {
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    },
+
+    /**
+     * Closes the window if the cancel button is pressed in the generic failure view.
+     */
     closeWindow: function() {
       window.close();
     },
 
-    _renderRoomView: function(roomState) {
-      switch (roomState) {
-        case ROOM_STATES.FAILED: {
-          return loop.conversation.GenericFailureView({
-            cancelCall: this.closeWindow}
-          );
-        }
-        case ROOM_STATES.INIT:
-        case ROOM_STATES.GATHER:
-        case ROOM_STATES.READY:
-        case ROOM_STATES.JOINED: {
-          return DesktopRoomInvitationView({
-            dispatcher: this.props.dispatcher, 
-            roomStore: this.props.roomStore}
-          );
-        }
-        // XXX needs bug 1074686/1074702
-        case ROOM_STATES.HAS_PARTICIPANTS: {
-          return DesktopRoomConversationView({
-            dispatcher: this.props.dispatcher, 
-            roomStore: this.props.roomStore}
-          );
-        }
-      }
+    /**
+     * Used to control publishing a stream - i.e. to mute a stream
+     *
+     * @param {String} type The type of stream, e.g. "audio" or "video".
+     * @param {Boolean} enabled True to enable the stream, false otherwise.
+     */
+    publishStream: function(type, enabled) {
+      this.props.dispatcher.dispatch(
+        new sharedActions.SetMute({
+          type: type,
+          enabled: enabled
+        }));
     },
 
     render: function() {
       if (this.state.roomName) {
         this.setTitle(this.state.roomName);
       }
-      return (
-        React.DOM.div({className: "room-conversation-wrapper"}, 
-          this._renderRoomView(this.state.roomState)
-        )
-      );
+
+      var localStreamClasses = React.addons.classSet({
+        local: true,
+        "local-stream": true,
+        "local-stream-audio": !this.state.videoMuted
+      });
+
+      switch(this.state.roomState) {
+        case ROOM_STATES.FAILED: {
+          return loop.conversation.GenericFailureView({
+            cancelCall: this.closeWindow}
+          );
+        }
+        default: {
+          return (
+            React.DOM.div({className: "room-conversation-wrapper"}, 
+              this._renderInvitationOverlay(), 
+              React.DOM.div({className: "video-layout-wrapper"}, 
+                React.DOM.div({className: "conversation room-conversation"}, 
+                  React.DOM.div({className: "media nested"}, 
+                    React.DOM.div({className: "video_wrapper remote_wrapper"}, 
+                      React.DOM.div({className: "video_inner remote"})
+                    ), 
+                    React.DOM.div({className: localStreamClasses})
+                  ), 
+                  sharedViews.ConversationToolbar({
+                    video: {enabled: !this.state.videoMuted, visible: true}, 
+                    audio: {enabled: !this.state.audioMuted, visible: true}, 
+                    publishStream: this.publishStream, 
+                    hangup: noop})
+                )
+              )
+            )
+          );
+        }
+      }
     }
   });
 
   return {
     ActiveRoomStoreMixin: ActiveRoomStoreMixin,
-    DesktopRoomControllerView: DesktopRoomControllerView,
     DesktopRoomConversationView: DesktopRoomConversationView,
     DesktopRoomInvitationView: DesktopRoomInvitationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -6,16 +6,17 @@
 
 /* jshint newcap:false */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
+  var sharedActions = loop.shared.actions;
   var ROOM_STATES = loop.store.ROOM_STATES;
   var sharedViews = loop.shared.views;
 
   function noop() {}
 
   /**
    * ActiveRoomStore mixin.
    * @type {Object}
@@ -32,21 +33,30 @@ loop.roomViews = (function(mozL10n) {
                     this._onActiveRoomStateChanged);
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.roomStore);
     },
 
     _onActiveRoomStateChanged: function() {
-      this.setState(this.props.roomStore.getStoreState("activeRoom"));
+      // Only update the state if we're mounted, to avoid the problem where
+      // stopListening doesn't nuke the active listeners during a event
+      // processing.
+      if (this.isMounted()) {
+        this.setState(this.props.roomStore.getStoreState("activeRoom"));
+      }
     },
 
     getInitialState: function() {
-      return this.props.roomStore.getStoreState("activeRoom");
+      var storeState = this.props.roomStore.getStoreState("activeRoom");
+      return _.extend(storeState, {
+        // Used by the UI showcase.
+        roomState: this.props.roomState || storeState.roomState
+      });
     }
   };
 
   /**
    * Desktop room invitation view (overlay).
    */
   var DesktopRoomInvitationView = React.createClass({
     mixins: [ActiveRoomStoreMixin],
@@ -67,140 +77,182 @@ loop.roomViews = (function(mozL10n) {
 
     handleCopyButtonClick: function(event) {
       event.preventDefault();
       // XXX
     },
 
     render: function() {
       return (
-        <div className="room-conversation-wrapper">
-          <div className="room-invitation-overlay">
-            <form onSubmit={this.handleFormSubmit}>
-              <input type="text" ref="roomName"
-                placeholder={mozL10n.get("rooms_name_this_room_label")} />
-            </form>
-            <p>{mozL10n.get("invite_header_text")}</p>
-            <div className="btn-group call-action-group">
-              <button className="btn btn-info btn-email"
-                      onClick={this.handleEmailButtonClick}>
-                {mozL10n.get("share_button2")}
-              </button>
-              <button className="btn btn-info btn-copy"
-                      onClick={this.handleCopyButtonClick}>
-                {mozL10n.get("copy_url_button2")}
-              </button>
-            </div>
+        <div className="room-invitation-overlay">
+          <form onSubmit={this.handleFormSubmit}>
+            <input type="text" ref="roomName"
+              placeholder={mozL10n.get("rooms_name_this_room_label")} />
+          </form>
+          <p>{mozL10n.get("invite_header_text")}</p>
+          <div className="btn-group call-action-group">
+            <button className="btn btn-info btn-email"
+                    onClick={this.handleEmailButtonClick}>
+              {mozL10n.get("share_button2")}
+            </button>
+            <button className="btn btn-info btn-copy"
+                    onClick={this.handleCopyButtonClick}>
+              {mozL10n.get("copy_url_button2")}
+            </button>
           </div>
-          <DesktopRoomConversationView roomStore={this.props.roomStore} />
         </div>
       );
     }
   });
 
   /**
    * Desktop room conversation view.
    */
   var DesktopRoomConversationView = React.createClass({
-    mixins: [ActiveRoomStoreMixin],
-
-    propTypes: {
-      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      video: React.PropTypes.object,
-      audio: React.PropTypes.object,
-      displayInvitation: React.PropTypes.bool
-    },
-
-    getDefaultProps: function() {
-      return {
-        video: {enabled: true, visible: true},
-        audio: {enabled: true, visible: true}
-      };
-    },
-
-    render: function() {
-      var localStreamClasses = React.addons.classSet({
-        local: true,
-        "local-stream": true,
-        "local-stream-audio": !this.props.video.enabled
-      });
-      return (
-        <div className="room-conversation-wrapper">
-          <div className="video-layout-wrapper">
-            <div className="conversation room-conversation">
-              <div className="media nested">
-                <div className="video_wrapper remote_wrapper">
-                  <div className="video_inner remote"></div>
-                </div>
-                <div className={localStreamClasses}></div>
-              </div>
-              <sharedViews.ConversationToolbar
-                video={this.props.video}
-                audio={this.props.audio}
-                publishStream={noop}
-                hangup={noop} />
-            </div>
-          </div>
-        </div>
-      );
-    }
-  });
-
-  /**
-   * Desktop room controller view.
-   */
-  var DesktopRoomControllerView = React.createClass({
     mixins: [ActiveRoomStoreMixin, loop.shared.mixins.DocumentTitleMixin],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
     },
 
+    _renderInvitationOverlay: function() {
+      if (this.state.roomState !== ROOM_STATES.HAS_PARTICIPANTS) {
+        return <DesktopRoomInvitationView
+          roomStore={this.props.roomStore}
+          dispatcher={this.props.dispatcher}
+        />;
+      }
+      return null;
+    },
+
+    componentDidMount: function() {
+      /**
+       * OT inserts inline styles into the markup. Using a listener for
+       * resize events helps us trigger a full width/height on the element
+       * so that they update to the correct dimensions.
+       * XXX: this should be factored as a mixin.
+       */
+      window.addEventListener('orientationchange', this.updateVideoContainer);
+      window.addEventListener('resize', this.updateVideoContainer);
+
+      // The SDK needs to know about the configuration and the elements to use
+      // for display. So the best way seems to pass the information here - ideally
+      // the sdk wouldn't need to know this, but we can't change that.
+      this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        publisherConfig: this._getPublisherConfig(),
+        getLocalElementFunc: this._getElement.bind(this, ".local"),
+        getRemoteElementFunc: this._getElement.bind(this, ".remote")
+      }));
+    },
+
+    _getPublisherConfig: function() {
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: !this.state.videoMuted,
+        style: {
+          audioLevelDisplayMode: "off",
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off",
+          videoDisabledDisplayMode: "off"
+        }
+      };
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
+    updateVideoContainer: function() {
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
+      if (localStreamParent) {
+        localStreamParent.style.width = "100%";
+      }
+      if (remoteStreamParent) {
+        remoteStreamParent.style.height = "100%";
+      }
+    },
+
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    },
+
+    /**
+     * Closes the window if the cancel button is pressed in the generic failure view.
+     */
     closeWindow: function() {
       window.close();
     },
 
-    _renderRoomView: function(roomState) {
-      switch (roomState) {
-        case ROOM_STATES.FAILED: {
-          return <loop.conversation.GenericFailureView
-            cancelCall={this.closeWindow}
-          />;
-        }
-        case ROOM_STATES.INIT:
-        case ROOM_STATES.GATHER:
-        case ROOM_STATES.READY:
-        case ROOM_STATES.JOINED: {
-          return <DesktopRoomInvitationView
-            dispatcher={this.props.dispatcher}
-            roomStore={this.props.roomStore}
-          />;
-        }
-        // XXX needs bug 1074686/1074702
-        case ROOM_STATES.HAS_PARTICIPANTS: {
-          return <DesktopRoomConversationView
-            dispatcher={this.props.dispatcher}
-            roomStore={this.props.roomStore}
-          />;
-        }
-      }
+    /**
+     * Used to control publishing a stream - i.e. to mute a stream
+     *
+     * @param {String} type The type of stream, e.g. "audio" or "video".
+     * @param {Boolean} enabled True to enable the stream, false otherwise.
+     */
+    publishStream: function(type, enabled) {
+      this.props.dispatcher.dispatch(
+        new sharedActions.SetMute({
+          type: type,
+          enabled: enabled
+        }));
     },
 
     render: function() {
       if (this.state.roomName) {
         this.setTitle(this.state.roomName);
       }
-      return (
-        <div className="room-conversation-wrapper">{
-          this._renderRoomView(this.state.roomState)
-        }</div>
-      );
+
+      var localStreamClasses = React.addons.classSet({
+        local: true,
+        "local-stream": true,
+        "local-stream-audio": !this.state.videoMuted
+      });
+
+      switch(this.state.roomState) {
+        case ROOM_STATES.FAILED: {
+          return <loop.conversation.GenericFailureView
+            cancelCall={this.closeWindow}
+          />;
+        }
+        default: {
+          return (
+            <div className="room-conversation-wrapper">
+              {this._renderInvitationOverlay()}
+              <div className="video-layout-wrapper">
+                <div className="conversation room-conversation">
+                  <div className="media nested">
+                    <div className="video_wrapper remote_wrapper">
+                      <div className="video_inner remote"></div>
+                    </div>
+                    <div className={localStreamClasses}></div>
+                  </div>
+                  <sharedViews.ConversationToolbar
+                    video={{enabled: !this.state.videoMuted, visible: true}}
+                    audio={{enabled: !this.state.audioMuted, visible: true}}
+                    publishStream={this.publishStream}
+                    hangup={noop} />
+                </div>
+              </div>
+            </div>
+          );
+        }
+      }
     }
   });
 
   return {
     ActiveRoomStoreMixin: ActiveRoomStoreMixin,
-    DesktopRoomControllerView: DesktopRoomControllerView,
     DesktopRoomConversationView: DesktopRoomConversationView,
     DesktopRoomInvitationView: DesktopRoomInvitationView
   };
 
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/shared/css/contacts.css
+++ b/browser/components/loop/content/shared/css/contacts.css
@@ -1,12 +1,22 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
+.contact-import-spinner {
+  display: none;
+}
+
+.contact-import-spinner.busy {
+  display: inline-block;
+  vertical-align: middle;
+  -moz-margin-start: 10px;
+}
+
 .content-area input.contact-filter {
   margin-top: 14px;
   border-radius: 10000px;
 }
 
 .contact-list {
   border-top: 1px solid #ccc;
   overflow-x: hidden;
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -41,27 +41,27 @@ body {
   border-top-left-radius: 2px;
   list-style: none;
 }
 
 .tab-view > li {
   flex: 1;
   text-align: center;
   color: #ccc;
-  border-right: 1px solid #ccc;
+  -moz-border-end: 1px solid #ccc;
   padding: 0 10px;
   height: 16px;
   cursor: pointer;
   background-repeat: no-repeat;
   background-size: 16px 16px;
   background-position: center;
 }
 
 .tab-view > li:last-child {
-  border-right-style: none;
+  -moz-border-end-style: none;
 }
 
 .tab-view > li[data-tab-name="call"],
 .tab-view > li[data-tab-name="rooms"] {
   background-image: url("../img/icons-16x16.svg#precall");
 }
 
 .tab-view > li[data-tab-name="call"]:hover,
@@ -302,16 +302,20 @@ body {
   background-color: #fbfbfb;
   color: #333;
   border: 1px solid #c1c1c1;
   border-radius: 2px;
   height: 26px;
   font-size: 12px;
 }
 
+.button > .button-caption {
+  vertical-align: middle;
+}
+
 .button:hover {
   background-color: #ebebeb;
 }
 
 .button:hover:active {
   background-color: #ccc;
   color: #111;
 }
@@ -367,34 +371,73 @@ body[dir=rtl] .dropdown-menu-item {
   white-space: nowrap;
 }
 
 .dropdown-menu-item:hover {
   border: 1px solid #ccc;
   background-color: #eee;
 }
 
+/* Spinner */
+
+@keyframes spinnerRotate {
+  to { transform: rotate(360deg); }
+}
+
+.spinner {
+  width: 16px;
+  height: 16px;
+  background-repeat: no-repeat;
+  background-size: 16px 16px;
+}
+
+.spinner.busy {
+  background-image: url(../img/spinner.png);
+  animation-name: spinnerRotate;
+  animation-duration: 1s;
+  animation-timing-function: linear;
+  animation-iteration-count: infinite;
+}
+
+@media (min-resolution: 2dppx) {
+  .spinner.busy {
+    background-image: url(../img/spinner@2x.png);
+  }
+}
+
 /* Share tab */
 
-.generate-url input {
+.generate-url-stack {
   margin: 14px 0;
+  position: relative;
+}
+
+.generate-url-input {
   outline: 0;
   border: 1px solid #ccc; /* Overriding background style for a text input (see
                              below) resets its borders to a weird beveled style;
                              defining a default 1px border solves the issue. */
   border-radius: 2px;
   height: 26px;
   padding: 0 10px;
   font-size: 1em;
 }
 
-.generate-url input.pending {
-  background-image: url(../img/loading-icon.gif);
-  background-repeat: no-repeat;
-  background-position: right;
+.generate-url-spinner {
+  position: absolute;
+  pointer-events: none;
+  z-index: 1;
+  top: 4px;
+  left: auto;
+  right: 4px;
+}
+
+body[dir=rtl] .generate-url-spinner {
+  left: 4px;
+  right: auto;
 }
 
 .generate-url .button {
   background-color: #0096dd;
   border-color: #0096dd;
   color: #fff;
 }
 
deleted file mode 100644
index 1c72ebb554be018511ae972c3f2361dff02dce02..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
new file mode 100644
index 0000000000000000000000000000000000000000..11134bcccac7b69d8364104c4701caeccd99a4c1
GIT binary patch
literal 724
zc$@*$0xSKAP)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV0007;Nkl<Zcmajb
zO^X~=6o>KuIp^N$u9xm<9mkm@GeZ*N5OpOe;>L)ef*>wk2rfmkb0G*q7UI&KA3^*8
zq8kxhC;?G)kzq7sWs4|b#-t~msp_hG?>SbxkVZm$;73t#?x`2Tf7-e7-D5GN*CXT^
zHEfxgL$bdF=?CZd69Ct+5}kVQDwY+aB5T|3o#^rxk&V6j+#7qZ30_0zbWwZMGx2)z
zrStqQfVv0T>K9a#$}bk-BQ4jTK=FVF4<sovRm)O>GbO*gx3+Os-*T?Gxj*!H6+r7?
zK#b`!t!+Lk)p|4%>Ib85Prk7ff7WCY%{`r{e92tlQ#l}2ev;ciKL+p?o%`^o3tCJ*
zptY?EB%;myNW#e#08^|~-%We9m7fN;c9voknoYIhJ1?`bydu>kiq8T<)A(@beMXx0
zu&SzXBag>#P<x_r`HA_4?-ISul;w}RK@kZlNSI%R^S}3SFQbk8hVN!yS&UA7={gk8
zF|vHj-gT5h;MD!n!+pj2M}&naJVzRAvp5ON9E7Z6XzbyB);GuBQgfWJm=8Ur>yBZE
zOiB=vM7)dtX=iUYj2@g{p3Um*(>srR&!pV}X$s4qD1+fehqTedy_`CCz5sA$JQ>c<
zUf8^r4YEI&O~0nF=vX+qa%ANEn@m{mVbyr!;VLfbO8^`@9{`xC{zjKROSpY>_!^g!
zXIUg!TSsl?4Ptk|EWfgo<b3v!4DvIQ4d|bl{wT9t2e9vm8A$O9ZSHPzIog(Ck;HpO
zY%fCfJPU{A6WM2hM4|z(ln%jifIfQubHlRvts@7P7i5_4v3O-dpdJ%-L=*{#{WSoX
z_Lhg!zYA77KDv1tEKl{XyUQ%)atWE<HjPuL?E^T(N`C|MPg9nE`M}Kp0000<MNUMn
GLSTZO!C2V<
new file mode 100644
index 0000000000000000000000000000000000000000..62ccb6c0712ac06da5a781e61d7c7bf807ded845
GIT binary patch
literal 1792
zc$@(M2mknqP)<h;3K|Lk000e1NJLTq001BW001Be1^@s6b9#F8000KZNkl<Zcmb``
zd#GJ!naA<(?|I*~_TJ~5ea^{Ao91Scwuwo#BBhGf3#m}7h=_^|6#7S@(3yc@3IhXW
zh8eY>)<3-<c%caPpQ2Ty?F<ae(0XaDD7IF5(Q2oWHnu0}IXSnz_uA`y9tmed4l#x_
z=?7k3HY}ds!*A^+F3`Tu{G#8E$2XVYy%BcM(awPBfP}}uA{IsYy%~>Jb^Q>q!UfRY
z|9FD)Nr@}RZQNF@em~Vzm#Sx;I*D8vB#R(LDHd&G(Tt3CBQaWQvNw;U%-4YDIS=i;
z>!+Mcr&o8{;{ViI?_-*pujuxT)KOTrkR>pSQ1ZZtNa1c#TwYqE8CVQgOoJt_%EkjG
z|4(4ZIb?4OXPfHx_BXD(WBQ7HX?i|UPl0kc27rQyU<-j+APlQT>WX?s-ks0Q`pH{t
zaN>q;_B()QIZL*b*{U;LzrCTpTk|{SG&Sc`9T7%=DG&jZ>|iSplM^x{b&O6WS4WY1
z2iI4R|8UAiw*fzA3kwe>=XCh~R^89pwms9Szui@LLs%eO5MmM>0pZ|n$lD+sBDA%Y
z0Fa5QXFhc~-ru)vBf9qi*RVxqVtT&UZ6g1>&F!4B>G_D1+(7QQ0t+GtciIM;#;uGO
zp<N2coP0$g@5MSbssu?OB2XYjs*R}ym590F>ap+ks`e<b4v=LAKndM$vzPDE^qiBE
z35dWYh;SH>q|w^Tbvt-K#D4)B00>}&7p9wrPc`b^-sah>u#UhKAOb>J^tREoqpQ;3
z<u3qt0%+l19%KIU-rjNUe>pAe&Qx6?Sp)(sO0gJiNQ33?mEquCpl}}JhV1IxHO$-o
z-mB>fB@3U-B1E$xTYjmX(W#@rGurX5k1@T_|7YK}`)Hb;S74Ke5$?$c>$1Vp*MWz4
z1J+i{Z3A1l$Ls#&lSKrIaItZM-ujCV0k`VlJx|WG-MO#Y+^)W=jshmNVbKhv)x`&a
zZ*dV$Esa0FQSbZ*(zF6>G8fu`^2+~@X0&oB7v}@1Gm}(R9zp_XbJK{?>g%<|ceqH_
z4nCV=`4erovnP}VC<0*}N}lVM@ye%l;GW;!m3QnvLU*P^AV9$B_fD+5dN1(bT!Q7r
z<{#Ip-Cv5jFJSWoXt+#w;|~vJ<<1^URS9r}0EloO4?HU#<q}ym_o-R?VpKC#AOHg(
z)lt+roTI9?E9VV_vq^+wrFq4x-UgRwes6De<5+X7csYbJfB-@@E%n}PDc3Sv1qvV_
za2MX5<V|8eSoF3%tehtWgD^RHHcRFvl>leL6)3`ZlXNn_9<;-wkvahaAiSlr@@y6{
zK;fVwHkm9io90dO)^{HGpQnCx{G&tVBUt5xW6kpV?%gxr%urrac#V=HLV%L+l;^dv
z)CGoIf}Mx1NkDb|4g3D>`KMp`$i^@ZS2<4XKeFxN`O9Ws(4nvV@y`0PJqO5j2S5-I
zQPxl4%YVKf_z9O_*TExuf$KQ8V_8vtLp$E5s`fJrD6H-TRrf<04<83wF2at3M?}Qo
zGH$9=cgFm+#Vq29D9ue4Qx#GmLJ+B{gQ@;ptc-339_AvHW^|3^s{c2_3-Fq5d@wk1
z{P-=J-TofsIslgdaK@u3E3bPDR=yAXk~d&}*WLq`t9M(@4gw<Z2!taGNiP9UWdMc$
zZ;Xc5TdIU91P-`zjb`S&B|gq*eKuG1BY>Ybc+Y+{Yb$S4O4lkSkRb>VVF85U#Yy=E
zfYS{CoNnQh)?c{6syZZ1nji@G7?0csYbAyoOL+S;GkX>outn~4I;pDnTdJ<Lx}Hi^
z?J{%F<{W^)BZBbqJK$-6?4@PW!ox9Kzgj76C*_csEMabS*Hg|Ooo+vx;fJGbZdAmg
zq*F3OW<BMqZ&h7VRk`J=AQQp>0z%+HScETI%Fh6tX<*{y@t1d4uYX7C&GayHflIic
zOxh@Tad|0m`i^KN6{X~6smRP_RwTK=fk2ZxBBGRGxc?up%7i}ngArSudi~^EtUGgS
z>P~fK7Dz5p1Tk5t04|~^q98T{r7{V1K><P#fJ2-PAB2}jfhD%c5NtKKu<)nV<)v?z
z@V`sl-ge8Ci!*;rCWDazDFjFWh%*e~5rDhG+fz}>kAYRrlEa;|&CJd%o?2Y|hP=Ir
zPVYLaYGDa8#iZ5{XF>zm@>@Y554jhoEzfxwKM54hLGQbNh4X1f;|1mXE>-o_ma=5F
zS+^7d7qkc`!UX~0M2v}eNz(Jc8t0*-KWe!UD<@C%klrCnS1RW#l+vtZl{UkNcv%iF
iuM+;Eg6Dt{7vL|S?0arv!8<_!0000<MNUMnLSTX;8E4@D
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -104,19 +104,22 @@ loop.shared.actions = (function() {
 
     /**
      * Used for hanging up the call at the end of a successful call.
      */
     HangupCall: Action.define("hangupCall", {
     }),
 
     /**
-     * Used to indicate the peer hung up the call.
+     * Used to indicate the remote peer was disconnected for some reason.
+     *
+     * peerHungup is true if the peer intentionally disconnected, false otherwise.
      */
-    PeerHungupCall: Action.define("peerHungupCall", {
+    RemotePeerDisconnected: Action.define("remotePeerDisconnected", {
+      peerHungup: Boolean
     }),
 
     /**
      * Used for notifying of connection progress state changes.
      * The connection refers to the overall connection flow as indicated
      * on the websocket.
      */
     ConnectionProgress: Action.define("connectionProgress", {
@@ -128,16 +131,28 @@ loop.shared.actions = (function() {
      * Used for notifying of connection failures.
      */
     ConnectionFailure: Action.define("connectionFailure", {
       // A string relating to the reason the connection failed.
       reason: String
     }),
 
     /**
+     * Used to notify that the sdk session is now connected to the servers.
+     */
+    ConnectedToSdkServers: Action.define("connectedToSdkServers", {
+    }),
+
+    /**
+     * Used to notify that a remote peer has connected to the room.
+     */
+    RemotePeerConnected: Action.define("remotePeerConnected", {
+    }),
+
+    /**
      * Used by the ongoing views to notify stores about the elements
      * required for the sdk.
      */
     SetupStreamElements: Action.define("setupStreamElements", {
       // The configuration for the publisher/subscribe options
       publisherConfig: Object,
       // The local stream element
       getLocalElementFunc: Function,
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -15,20 +15,22 @@ loop.store.ActiveRoomStore = (function()
     // The initial state of the room
     INIT: "room-init",
     // The store is gathering the room data
     GATHER: "room-gather",
     // The store has got the room data
     READY: "room-ready",
     // The room is known to be joined on the loop-server
     JOINED: "room-joined",
+    // The room is connected to the sdk server.
+    SESSION_CONNECTED: "room-session-connected",
+    // There are participants in the room.
+    HAS_PARTICIPANTS: "room-has-participants",
     // There was an issue with the room
-    FAILED: "room-failed",
-    // XXX to be implemented in bug 1074686/1074702
-    HAS_PARTICIPANTS: "room-has-participants"
+    FAILED: "room-failed"
   };
 
   /**
    * Store for things that are local to this instance (in this profile, on
    * this machine) of this roomRoom store, in addition to a mirror of some
    * remote-state.
    *
    * @extends {Backbone.Events}
@@ -46,41 +48,46 @@ loop.store.ActiveRoomStore = (function()
     }
     this._dispatcher = options.dispatcher;
 
     if (!options.mozLoop) {
       throw new Error("Missing option mozLoop");
     }
     this._mozLoop = options.mozLoop;
 
+    if (!options.sdkDriver) {
+      throw new Error("Missing option sdkDriver");
+    }
+    this._sdkDriver = options.sdkDriver;
+
+    // XXX Further actions are registered in setupWindowData and
+    // fetchServerData when we know what window type this is. At some stage,
+    // we might want to consider store mixins or some alternative which
+    // means the stores would only be created when we want them.
     this._dispatcher.register(this, [
-      "roomFailure",
       "setupWindowData",
-      "fetchServerData",
-      "updateRoomInfo",
-      "joinRoom",
-      "joinedRoom",
-      "windowUnload",
-      "leaveRoom"
+      "fetchServerData"
     ]);
 
     /**
      * Stored data reflecting the local state of a given room, used to drive
      * the room's views.
      *
      * @see https://wiki.mozilla.org/Loop/Architecture/Rooms#GET_.2Frooms.2F.7Btoken.7D
      *      for the main data. Additional properties below.
      *
      * @property {ROOM_STATES} roomState - the state of the room.
      * @property {Error=} error - if the room is an error state, this will be
      *                            set to an Error object reflecting the problem;
      *                            otherwise it will be unset.
      */
     this._storeState = {
-      roomState: ROOM_STATES.INIT
+      roomState: ROOM_STATES.INIT,
+      audioMuted: false,
+      videoMuted: false
     };
   }
 
   ActiveRoomStore.prototype = _.extend({
     /**
      * The time factor to adjust the expires time to ensure that we send a refresh
      * before the expiry. Currently set as 90%.
      */
@@ -109,29 +116,51 @@ loop.store.ActiveRoomStore = (function()
 
       this.setStoreState({
         error: actionData.error,
         roomState: ROOM_STATES.FAILED
       });
     },
 
     /**
+     * Registers the actions with the dispatcher that this store is interested
+     * in.
+     */
+    _registerActions: function() {
+      this._dispatcher.register(this, [
+        "roomFailure",
+        "updateRoomInfo",
+        "joinRoom",
+        "joinedRoom",
+        "connectedToSdkServers",
+        "connectionFailure",
+        "setMute",
+        "remotePeerDisconnected",
+        "remotePeerConnected",
+        "windowUnload",
+        "leaveRoom"
+      ]);
+    },
+
+    /**
      * Execute setupWindowData event action from the dispatcher. This gets
      * the room data from the mozLoop api, and dispatches an UpdateRoomInfo event.
      * It also dispatches JoinRoom as this action is only applicable to the desktop
      * client, and needs to auto-join.
      *
      * @param {sharedActions.SetupWindowData} actionData
      */
     setupWindowData: function(actionData) {
       if (actionData.type !== "room") {
         // Nothing for us to do here, leave it to other stores.
         return;
       }
 
+      this._registerActions();
+
       this.setStoreState({
         roomState: ROOM_STATES.GATHER
       });
 
       // Get the window data from the mozLoop api.
       this._mozLoop.rooms.get(actionData.roomToken,
         function(error, roomData) {
           if (error) {
@@ -164,16 +193,18 @@ loop.store.ActiveRoomStore = (function()
      * @param {sharedActions.FetchServerData} actionData
      */
     fetchServerData: function(actionData) {
       if (actionData.windowType !== "room") {
         // Nothing for us to do here, leave it to other stores.
         return;
       }
 
+      this._registerActions();
+
       this.setStoreState({
         roomToken: actionData.token,
         roomState: ROOM_STATES.READY
       });
     },
 
     /**
      * Handles the updateRoomInfo action. Updates the room data and
@@ -223,16 +254,67 @@ loop.store.ActiveRoomStore = (function()
       this.setStoreState({
         apiKey: actionData.apiKey,
         sessionToken: actionData.sessionToken,
         sessionId: actionData.sessionId,
         roomState: ROOM_STATES.JOINED
       });
 
       this._setRefreshTimeout(actionData.expires);
+      this._sdkDriver.connectSession(actionData);
+    },
+
+    /**
+     * Handles recording when the sdk has connected to the servers.
+     */
+    connectedToSdkServers: function() {
+      this.setStoreState({
+        roomState: ROOM_STATES.SESSION_CONNECTED
+      });
+    },
+
+    /**
+     * Handles disconnection of this local client from the sdk servers.
+     */
+    connectionFailure: function() {
+      // Treat all reasons as something failed. In theory, clientDisconnected
+      // could be a success case, but there's no way we should be intentionally
+      // sending that and still have the window open.
+      this._leaveRoom(ROOM_STATES.FAILED);
+    },
+
+    /**
+     * Records the mute state for the stream.
+     *
+     * @param {sharedActions.setMute} actionData The mute state for the stream type.
+     */
+    setMute: function(actionData) {
+      var muteState = {};
+      muteState[actionData.type + "Muted"] = !actionData.enabled;
+      this.setStoreState(muteState);
+    },
+
+    /**
+     * Handles recording when a remote peer has connected to the servers.
+     */
+    remotePeerConnected: function() {
+      this.setStoreState({
+        roomState: ROOM_STATES.HAS_PARTICIPANTS
+      });
+    },
+
+    /**
+     * Handles a remote peer disconnecting from the session.
+     */
+    remotePeerDisconnected: function() {
+      // As we only support two users at the moment, we just set this
+      // back to joined.
+      this.setStoreState({
+        roomState: ROOM_STATES.SESSION_CONNECTED
+      });
     },
 
     /**
      * Handles the window being unloaded. Ensures the room is left.
      */
     windowUnload: function() {
       this._leaveRoom();
     },
@@ -270,32 +352,37 @@ loop.store.ActiveRoomStore = (function()
 
           this._setRefreshTimeout(responseData.expires);
         }.bind(this));
     },
 
     /**
      * Handles leaving a room. Clears any membership timeouts, then
      * signals to the server the leave of the room.
+     *
+     * @param {ROOM_STATES} nextState Optional; the next state to switch to.
+     *                                Switches to READY if undefined.
      */
-    _leaveRoom: function() {
-      if (this._storeState.roomState !== ROOM_STATES.JOINED) {
-        return;
-      }
+    _leaveRoom: function(nextState) {
+      this._sdkDriver.disconnectSession();
 
       if (this._timeout) {
         clearTimeout(this._timeout);
         delete this._timeout;
       }
 
-      this._mozLoop.rooms.leave(this._storeState.roomToken,
-        this._storeState.sessionToken);
+      if (this._storeState.roomState === ROOM_STATES.JOINED ||
+          this._storeState.roomState === ROOM_STATES.SESSION_CONNECTED ||
+          this._storeState.roomState === ROOM_STATES.HAS_PARTICIPANTS) {
+        this._mozLoop.rooms.leave(this._storeState.roomToken,
+          this._storeState.sessionToken);
+      }
 
       this.setStoreState({
-        roomState: ROOM_STATES.READY
+        roomState: nextState ? nextState : ROOM_STATES.READY
       });
     }
 
   }, Backbone.Events);
 
   return ActiveRoomStore;
 
 })();
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -113,28 +113,22 @@ loop.store.ConversationStore = (function
       if (!options.sdkDriver) {
         throw new Error("Missing option sdkDriver");
       }
 
       this.client = options.client;
       this.dispatcher = options.dispatcher;
       this.sdkDriver = options.sdkDriver;
 
+      // XXX Further actions are registered in setupWindowData when
+      // we know what window type this is. At some stage, we might want to
+      // consider store mixins or some alternative which means the stores
+      // would only be created when we want them.
       this.dispatcher.register(this, [
-        "connectionFailure",
-        "connectionProgress",
-        "setupWindowData",
-        "connectCall",
-        "hangupCall",
-        "peerHungupCall",
-        "cancelCall",
-        "retryCall",
-        "mediaConnected",
-        "setMute",
-        "fetchEmailLink"
+        "setupWindowData"
       ]);
     },
 
     /**
      * Handles the connection failure action, setting the state to
      * terminated.
      *
      * @param {sharedActions.ConnectionFailure} actionData The action data.
@@ -191,16 +185,29 @@ loop.store.ConversationStore = (function
     setupWindowData: function(actionData) {
       var windowType = actionData.type;
       if (windowType !== "outgoing" &&
           windowType !== "incoming") {
         // Not for this store, don't do anything.
         return;
       }
 
+      this.dispatcher.register(this, [
+        "connectionFailure",
+        "connectionProgress",
+        "connectCall",
+        "hangupCall",
+        "remotePeerDisconnected",
+        "cancelCall",
+        "retryCall",
+        "mediaConnected",
+        "setMute",
+        "fetchEmailLink"
+      ]);
+
       this.set({
         contact: actionData.contact,
         outgoing: windowType === "outgoing",
         windowId: actionData.windowId,
         callType: actionData.callType,
         callState: CALL_STATES.GATHER,
         videoMuted: actionData.callType === CALL_TYPES.AUDIO_ONLY
       });
@@ -231,21 +238,33 @@ loop.store.ConversationStore = (function
         this._websocket.mediaFail();
       }
 
       this._endSession();
       this.set({callState: CALL_STATES.FINISHED});
     },
 
     /**
-     * The peer hungup the call.
+     * The remote peer disconnected from the session.
+     *
+     * @param {sharedActions.RemotePeerDisconnected} actionData
      */
-    peerHungupCall: function() {
+    remotePeerDisconnected: function(actionData) {
       this._endSession();
-      this.set({callState: CALL_STATES.FINISHED});
+
+      // If the peer hungup, we end normally, otherwise
+      // we treat this as a call failure.
+      if (actionData.peerHungup) {
+        this.set({callState: CALL_STATES.FINISHED});
+      } else {
+        this.set({
+          callState: CALL_STATES.TERMINATED,
+          callStateReason: "peerNetworkDisconnected"
+        });
+      }
     },
 
     /**
      * Cancels a call
      */
     cancelCall: function() {
       var callState = this.get("callState");
       if (this._websocket &&
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -74,16 +74,17 @@ loop.OTSdkDriver = (function() {
      * - apiKey: The OT API key
      * - sessionToken: The token for the OT session
      *
      * @param {Object} sessionData The session data for setting up the OT session.
      */
     connectSession: function(sessionData) {
       this.session = this.sdk.initSession(sessionData.sessionId);
 
+      this.session.on("connectionCreated", this._onConnectionCreated.bind(this));
       this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this));
       this.session.on("connectionDestroyed",
         this._onConnectionDestroyed.bind(this));
       this.session.on("sessionDisconnected",
         this._onSessionDisconnected.bind(this));
 
       // This starts the actual session connection.
       this.session.connect(sessionData.apiKey, sessionData.sessionToken,
@@ -125,39 +126,31 @@ loop.OTSdkDriver = (function() {
       if (error) {
         console.error("Failed to complete connection", error);
         this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
           reason: "couldNotConnect"
         }));
         return;
       }
 
+      this.dispatcher.dispatch(new sharedActions.ConnectedToSdkServers());
       this._sessionConnected = true;
       this._maybePublishLocalStream();
     },
 
     /**
      * Handles the connection event for a peer's connection being dropped.
      *
      * @param {SessionDisconnectEvent} event The event details
      * https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
      */
     _onConnectionDestroyed: function(event) {
-      var action;
-      if (event.reason === "clientDisconnected") {
-        action = new sharedActions.PeerHungupCall();
-      } else {
-        // Strictly speaking this isn't a failure on our part, but since our
-        // flow requires a full reconnection, then we just treat this as
-        // if a failure of our end had occurred.
-        action = new sharedActions.ConnectionFailure({
-          reason: "peerNetworkDisconnected"
-        });
-      }
-      this.dispatcher.dispatch(action);
+      this.dispatcher.dispatch(new sharedActions.RemotePeerDisconnected({
+        peerHungup: event.reason === "clientDisconnected"
+      }));
     },
 
     /**
      * Handles the session event for the connection for this client being
      * destroyed.
      *
      * @param {SessionDisconnectEvent} event The event details:
      * https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
@@ -166,16 +159,24 @@ loop.OTSdkDriver = (function() {
       // We only need to worry about the network disconnected reason here.
       if (event.reason === "networkDisconnected") {
         this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
           reason: "networkDisconnected"
         }));
       }
     },
 
+    _onConnectionCreated: function(event) {
+      if (this.session.connection.id === event.connection.id) {
+        return;
+      }
+
+      this.dispatcher.dispatch(new sharedActions.RemotePeerConnected());
+    },
+
     /**
      * Handles the event when the remote stream is created.
      *
      * @param {StreamEvent} event The event details:
      * https://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html
      */
     _onRemoteStreamCreated: function(event) {
       this.session.subscribe(event.stream,
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -723,17 +723,18 @@ loop.shared.views = (function(_, OT, l10
       var classObject = { button: true, disabled: this.props.disabled };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
       }
       return (
         React.DOM.button({onClick: this.props.onClick, 
                 disabled: this.props.disabled, 
                 className: cx(classObject)}, 
-          this.props.caption
+          React.DOM.span({className: "button-caption"}, this.props.caption), 
+          this.props.children
         )
       )
     }
   });
 
   var ButtonGroup = React.createClass({displayName: 'ButtonGroup',
     PropTypes: {
       additionalClass: React.PropTypes.string
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -723,17 +723,18 @@ loop.shared.views = (function(_, OT, l10
       var classObject = { button: true, disabled: this.props.disabled };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
       }
       return (
         <button onClick={this.props.onClick}
                 disabled={this.props.disabled}
                 className={cx(classObject)}>
-          {this.props.caption}
+          <span className="button-caption">{this.props.caption}</span>
+          {this.props.children}
         </button>
       )
     }
   });
 
   var ButtonGroup = React.createClass({
     PropTypes: {
       additionalClass: React.PropTypes.string
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -27,17 +27,18 @@ browser.jar:
   content/browser/loop/shared/css/conversation.css  (content/shared/css/conversation.css)
   content/browser/loop/shared/css/contacts.css      (content/shared/css/contacts.css)
 
   # Shared images
   content/browser/loop/shared/img/happy.png                     (content/shared/img/happy.png)
   content/browser/loop/shared/img/sad.png                       (content/shared/img/sad.png)
   content/browser/loop/shared/img/icon_32.png                   (content/shared/img/icon_32.png)
   content/browser/loop/shared/img/icon_64.png                   (content/shared/img/icon_64.png)
-  content/browser/loop/shared/img/loading-icon.gif              (content/shared/img/loading-icon.gif)
+  content/browser/loop/shared/img/spinner.png                   (content/shared/img/spinner.png)
+  content/browser/loop/shared/img/spinner@2x.png                (content/shared/img/spinner@2x.png)
   content/browser/loop/shared/img/audio-inverse-14x14.png       (content/shared/img/audio-inverse-14x14.png)
   content/browser/loop/shared/img/audio-inverse-14x14@2x.png    (content/shared/img/audio-inverse-14x14@2x.png)
   content/browser/loop/shared/img/facemute-14x14.png            (content/shared/img/facemute-14x14.png)
   content/browser/loop/shared/img/facemute-14x14@2x.png         (content/shared/img/facemute-14x14@2x.png)
   content/browser/loop/shared/img/hangup-inverse-14x14.png      (content/shared/img/hangup-inverse-14x14.png)
   content/browser/loop/shared/img/hangup-inverse-14x14@2x.png   (content/shared/img/hangup-inverse-14x14@2x.png)
   content/browser/loop/shared/img/mute-inverse-14x14.png        (content/shared/img/mute-inverse-14x14.png)
   content/browser/loop/shared/img/mute-inverse-14x14@2x.png     (content/shared/img/mute-inverse-14x14@2x.png)
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -91,16 +91,17 @@
     <script type="text/javascript" src="shared/js/models.js"></script>
     <script type="text/javascript" src="shared/js/mixins.js"></script>
     <script type="text/javascript" src="shared/js/views.js"></script>
     <script type="text/javascript" src="shared/js/feedbackApiClient.js"></script>
     <script type="text/javascript" src="shared/js/actions.js"></script>
     <script type="text/javascript" src="shared/js/validate.js"></script>
     <script type="text/javascript" src="shared/js/dispatcher.js"></script>
     <script type="text/javascript" src="shared/js/websocket.js"></script>
+    <script type="text/javascript" src="shared/js/otSdkDriver.js"></script>
     <script type="text/javascript" src="shared/js/activeRoomStore.js"></script>
     <script type="text/javascript" src="js/standaloneAppStore.js"></script>
     <script type="text/javascript" src="js/standaloneClient.js"></script>
     <script type="text/javascript" src="js/standaloneMozLoop.js"></script>
     <script type="text/javascript" src="js/standaloneRoomViews.js"></script>
     <script type="text/javascript" src="js/webapp.js"></script>
 
     <script>
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -982,26 +982,31 @@ loop.webapp = (function($, _, OT, mozL10
         url: document.location.origin
       });
 
     // New flux items.
     var dispatcher = new loop.Dispatcher();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
+    var sdkDriver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: OT
+    });
 
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: standaloneMozLoop
+      mozLoop: standaloneMozLoop,
+      sdkDriver: sdkDriver
     });
 
     window.addEventListener("unload", function() {
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(WebappRootView({
       client: client, 
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -982,26 +982,31 @@ loop.webapp = (function($, _, OT, mozL10
         url: document.location.origin
       });
 
     // New flux items.
     var dispatcher = new loop.Dispatcher();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
+    var sdkDriver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: OT
+    });
 
     var standaloneAppStore = new loop.store.StandaloneAppStore({
       conversation: conversation,
       dispatcher: dispatcher,
       helper: helper,
       sdk: OT
     });
     var activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: standaloneMozLoop
+      mozLoop: standaloneMozLoop,
+      sdkDriver: sdkDriver
     });
 
     window.addEventListener("unload", function() {
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.renderComponent(<WebappRootView
       client={client}
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -138,17 +138,18 @@ describe("loop.conversation", function()
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversation.AppControllerView({
           client: client,
           conversation: conversation,
           roomStore: roomStore,
           sdk: {},
           conversationStore: conversationStore,
-          conversationAppStore: conversationAppStore
+          conversationAppStore: conversationAppStore,
+          dispatcher: dispatcher
         }));
     }
 
     beforeEach(function() {
       oldTitle = document.title;
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {}
@@ -209,17 +210,17 @@ describe("loop.conversation", function()
     });
 
     it("should display the RoomView for rooms", function() {
       conversationAppStore.setStoreState({windowType: "room"});
 
       ccView = mountTestComponent();
 
       TestUtils.findRenderedComponentWithType(ccView,
-        loop.roomViews.DesktopRoomControllerView);
+        loop.roomViews.DesktopRoomConversationView);
     });
 
     it("should display the GenericFailureView for failures", function() {
       conversationAppStore.setStoreState({windowType: "failed"});
 
       ccView = mountTestComponent();
 
       TestUtils.findRenderedComponentWithType(ccView,
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -27,17 +27,18 @@ describe("loop.roomViews", function () {
     // XXX These stubs should be hoisted in a common file
     // Bug 1040968
     sandbox.stub(document.mozL10n, "get", function(x) {
       return x;
     });
 
     activeRoomStore = new loop.store.ActiveRoomStore({
       dispatcher: dispatcher,
-      mozLoop: {}
+      mozLoop: {},
+      sdkDriver: {}
     });
     roomStore = new loop.store.RoomStore({
       dispatcher: dispatcher,
       mozLoop: {},
       activeRoomStore: activeRoomStore
     });
   });
 
@@ -57,46 +58,117 @@ describe("loop.roomViews", function () {
       });
 
       var testView = TestUtils.renderIntoDocument(TestView({
         roomStore: activeRoomStore
       }));
 
       expect(testView.state).eql({
         roomState: ROOM_STATES.INIT,
+        audioMuted: false,
+        videoMuted: false,
         foo: "bar"
       });
     });
 
     it("should listen to store changes", function() {
       var TestView = React.createClass({
         mixins: [loop.roomViews.ActiveRoomStoreMixin],
         render: function() { return React.DOM.div(); }
       });
       var testView = TestUtils.renderIntoDocument(TestView({
         roomStore: activeRoomStore
       }));
 
       activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
 
-      expect(testView.state).eql({roomState: ROOM_STATES.READY});
+      expect(testView.state.roomState).eql(ROOM_STATES.READY);
     });
   });
 
-  describe("DesktopRoomControllerView", function() {
+  describe("DesktopRoomConversationView", function() {
     var view;
 
+    beforeEach(function() {
+      sandbox.stub(dispatcher, "dispatch");
+    });
+
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
-        new loop.roomViews.DesktopRoomControllerView({
-          mozLoop: {},
+        new loop.roomViews.DesktopRoomConversationView({
+          dispatcher: dispatcher,
           roomStore: roomStore
         }));
     }
 
+    it("should dispatch a setupStreamElements action when the view is created",
+      function() {
+        view = mountTestComponent();
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setupStreamElements"));
+    });
+
+    it("should dispatch a setMute action when the audio mute button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        view.setState({audioMuted: true});
+
+        var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
+
+        React.addons.TestUtils.Simulate.click(muteBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setMute"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("enabled", true));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("type", "audio"));
+      });
+
+    it("should dispatch a setMute action when the video mute button is pressed",
+      function() {
+        view = mountTestComponent();
+
+        view.setState({videoMuted: false});
+
+        var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
+
+        React.addons.TestUtils.Simulate.click(muteBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setMute"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("enabled", false));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("type", "video"));
+      });
+
+    it("should set the mute button as mute off", function() {
+      view = mountTestComponent();
+
+      view.setState({videoMuted: false});
+
+      var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
+
+      expect(muteBtn.classList.contains("muted")).eql(false);
+    });
+
+    it("should set the mute button as mute on", function() {
+      view = mountTestComponent();
+
+      view.setState({audioMuted: true});
+
+      var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
+
+      expect(muteBtn.classList.contains("muted")).eql(true);
+    });
+
     describe("#render", function() {
       it("should set document.title to store.serverData.roomName", function() {
         mountTestComponent();
 
         activeRoomStore.setStoreState({roomName: "fakeName"});
 
         expect(fakeWindow.document.title).to.equal("fakeName");
       });
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -2,17 +2,17 @@
 
 var expect = chai.expect;
 var sharedActions = loop.shared.actions;
 
 describe("loop.store.ActiveRoomStore", function () {
   "use strict";
 
   var ROOM_STATES = loop.store.ROOM_STATES;
-  var sandbox, dispatcher, store, fakeMozLoop;
+  var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     sandbox.useFakeTimers();
 
     dispatcher = new loop.Dispatcher();
     sandbox.stub(dispatcher, "dispatch");
 
@@ -20,18 +20,26 @@ describe("loop.store.ActiveRoomStore", f
       rooms: {
         get: sandbox.stub(),
         join: sandbox.stub(),
         refreshMembership: sandbox.stub(),
         leave: sandbox.stub()
       }
     };
 
-    store = new loop.store.ActiveRoomStore(
-      {mozLoop: fakeMozLoop, dispatcher: dispatcher});
+    fakeSdkDriver = {
+      connectSession: sandbox.stub(),
+      disconnectSession: sandbox.stub()
+    };
+
+    store = new loop.store.ActiveRoomStore({
+      dispatcher: dispatcher,
+      mozLoop: fakeMozLoop,
+      sdkDriver: fakeSdkDriver
+    });
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#constructor", function() {
     it("should throw an error if the dispatcher is missing", function() {
@@ -40,16 +48,22 @@ describe("loop.store.ActiveRoomStore", f
       }).to.Throw(/dispatcher/);
     });
 
     it("should throw an error if mozLoop is missing", function() {
       expect(function() {
         new loop.store.ActiveRoomStore({dispatcher: dispatcher});
       }).to.Throw(/mozLoop/);
     });
+
+    it("should throw an error if sdkDriver is missing", function() {
+      expect(function() {
+        new loop.store.ActiveRoomStore({dispatcher: dispatcher, mozLoop: {}});
+      }).to.Throw(/sdkDriver/);
+    });
   });
 
   describe("#roomFailure", function() {
     var fakeError;
 
     beforeEach(function() {
       sandbox.stub(console, "error");
 
@@ -276,16 +290,26 @@ describe("loop.store.ActiveRoomStore", f
       store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
       var state = store.getStoreState();
       expect(state.apiKey).eql(fakeJoinedData.apiKey);
       expect(state.sessionToken).eql(fakeJoinedData.sessionToken);
       expect(state.sessionId).eql(fakeJoinedData.sessionId);
     });
 
+    it("should start the session connection with the sdk", function() {
+      var actionData = new sharedActions.JoinedRoom(fakeJoinedData);
+
+      store.joinedRoom(actionData);
+
+      sinon.assert.calledOnce(fakeSdkDriver.connectSession);
+      sinon.assert.calledWithExactly(fakeSdkDriver.connectSession,
+        actionData);
+    });
+
     it("should call mozLoop.rooms.refreshMembership before the expiresTime",
       function() {
         store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
 
         sandbox.clock.tick(fakeJoinedData.expires * 1000);
 
         sinon.assert.calledOnce(fakeMozLoop.rooms.refreshMembership);
         sinon.assert.calledWith(fakeMozLoop.rooms.refreshMembership,
@@ -325,25 +349,118 @@ describe("loop.store.ActiveRoomStore", f
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWith(dispatcher.dispatch,
           new sharedActions.RoomFailure({
             error: fakeError
           }));
     });
   });
 
+  describe("#connectedToSdkServers", function() {
+    it("should set the state to `SESSION_CONNECTED`", function() {
+      store.connectedToSdkServers(new sharedActions.ConnectedToSdkServers());
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.SESSION_CONNECTED);
+    });
+  });
+
+  describe("#connectionFailure", function() {
+    beforeEach(function() {
+      store.setStoreState({
+        roomState: ROOM_STATES.JOINED,
+        roomToken: "fakeToken",
+        sessionToken: "1627384950"
+      });
+    });
+
+    it("should disconnect from the servers via the sdk", function() {
+      store.connectionFailure();
+
+      sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
+    });
+
+    it("should clear any existing timeout", function() {
+      sandbox.stub(window, "clearTimeout");
+      store._timeout = {};
+
+      store.connectionFailure();
+
+      sinon.assert.calledOnce(clearTimeout);
+    });
+
+    it("should call mozLoop.rooms.leave", function() {
+      store.connectionFailure();
+
+      sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
+      sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
+        "fakeToken", "1627384950");
+    });
+
+    it("should set the state to `FAILED`", function() {
+      store.connectionFailure();
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.FAILED);
+    });
+  });
+
+  describe("#setMute", function() {
+    it("should save the mute state for the audio stream", function() {
+      store.setStoreState({audioMuted: false});
+
+      store.setMute(new sharedActions.SetMute({
+        type: "audio",
+        enabled: true
+      }));
+
+      expect(store.getStoreState().audioMuted).eql(false);
+    });
+
+    it("should save the mute state for the video stream", function() {
+      store.setStoreState({videoMuted: true});
+
+      store.setMute(new sharedActions.SetMute({
+        type: "video",
+        enabled: false
+      }));
+
+      expect(store.getStoreState().videoMuted).eql(true);
+    });
+  });
+
+  describe("#remotePeerConnected", function() {
+    it("should set the state to `HAS_PARTICIPANTS`", function() {
+      store.remotePeerConnected();
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.HAS_PARTICIPANTS);
+    });
+  });
+
+  describe("#remotePeerDisconnected", function() {
+    it("should set the state to `SESSION_CONNECTED`", function() {
+      store.remotePeerDisconnected();
+
+      expect(store.getStoreState().roomState).eql(ROOM_STATES.SESSION_CONNECTED);
+    });
+  });
+
   describe("#windowUnload", function() {
     beforeEach(function() {
       store.setStoreState({
         roomState: ROOM_STATES.JOINED,
         roomToken: "fakeToken",
         sessionToken: "1627384950"
       });
     });
 
+    it("should disconnect from the servers via the sdk", function() {
+      store.windowUnload();
+
+      sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
+    });
+
     it("should clear any existing timeout", function() {
       sandbox.stub(window, "clearTimeout");
       store._timeout = {};
 
       store.windowUnload();
 
       sinon.assert.calledOnce(clearTimeout);
     });
@@ -367,16 +484,22 @@ describe("loop.store.ActiveRoomStore", f
     beforeEach(function() {
       store.setStoreState({
         roomState: ROOM_STATES.JOINED,
         roomToken: "fakeToken",
         sessionToken: "1627384950"
       });
     });
 
+    it("should disconnect from the servers via the sdk", function() {
+      store.leaveRoom();
+
+      sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
+    });
+
     it("should clear any existing timeout", function() {
       sandbox.stub(window, "clearTimeout");
       store._timeout = {};
 
       store.leaveRoom();
 
       sinon.assert.calledOnce(clearTimeout);
     });
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -127,97 +127,97 @@ describe("loop.store.ConversationStore",
 
   describe("#connectionFailure", function() {
     beforeEach(function() {
       store._websocket = fakeWebsocket;
       store.set({windowId: "42"});
     });
 
     it("should disconnect the session", function() {
-      dispatcher.dispatch(
+      store.connectionFailure(
         new sharedActions.ConnectionFailure({reason: "fake"}));
 
       sinon.assert.calledOnce(sdkDriver.disconnectSession);
     });
 
     it("should ensure the websocket is closed", function() {
-      dispatcher.dispatch(
+      store.connectionFailure(
         new sharedActions.ConnectionFailure({reason: "fake"}));
 
       sinon.assert.calledOnce(wsCloseSpy);
     });
 
     it("should set the state to 'terminated'", function() {
       store.set({callState: CALL_STATES.ALERTING});
 
-      dispatcher.dispatch(
+      store.connectionFailure(
         new sharedActions.ConnectionFailure({reason: "fake"}));
 
       expect(store.get("callState")).eql(CALL_STATES.TERMINATED);
       expect(store.get("callStateReason")).eql("fake");
     });
 
     it("should release mozLoop callsData", function() {
-      dispatcher.dispatch(
+      store.connectionFailure(
         new sharedActions.ConnectionFailure({reason: "fake"}));
 
       sinon.assert.calledOnce(navigator.mozLoop.calls.clearCallInProgress);
       sinon.assert.calledWithExactly(
         navigator.mozLoop.calls.clearCallInProgress, "42");
     });
   });
 
   describe("#connectionProgress", function() {
     describe("progress: init", function() {
       it("should change the state from 'gather' to 'connecting'", function() {
         store.set({callState: CALL_STATES.GATHER});
 
-        dispatcher.dispatch(
+        store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.INIT}));
 
         expect(store.get("callState")).eql(CALL_STATES.CONNECTING);
       });
     });
 
     describe("progress: alerting", function() {
       it("should change the state from 'gather' to 'alerting'", function() {
         store.set({callState: CALL_STATES.GATHER});
 
-        dispatcher.dispatch(
+        store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
 
         expect(store.get("callState")).eql(CALL_STATES.ALERTING);
       });
 
       it("should change the state from 'init' to 'alerting'", function() {
         store.set({callState: CALL_STATES.INIT});
 
-        dispatcher.dispatch(
+        store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
 
         expect(store.get("callState")).eql(CALL_STATES.ALERTING);
       });
     });
 
     describe("progress: connecting", function() {
       beforeEach(function() {
         store.set({callState: CALL_STATES.ALERTING});
       });
 
       it("should change the state to 'ongoing'", function() {
-        dispatcher.dispatch(
+        store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
 
         expect(store.get("callState")).eql(CALL_STATES.ONGOING);
       });
 
       it("should connect the session", function() {
         store.set(fakeSessionData);
 
-        dispatcher.dispatch(
+        store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
 
         sinon.assert.calledOnce(sdkDriver.connectSession);
         sinon.assert.calledWithExactly(sdkDriver.connectSession, {
           apiKey: "fakeKey",
           sessionId: "321456",
           sessionToken: "341256"
         });
@@ -381,54 +381,54 @@ describe("loop.store.ConversationStore",
             sinon.match.hasOwn("reason", "setup"));
         });
       });
     });
   });
 
   describe("#connectCall", function() {
     it("should save the call session data", function() {
-      dispatcher.dispatch(
+      store.connectCall(
         new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
       expect(store.get("apiKey")).eql("fakeKey");
       expect(store.get("callId")).eql("142536");
       expect(store.get("sessionId")).eql("321456");
       expect(store.get("sessionToken")).eql("341256");
       expect(store.get("websocketToken")).eql("543216");
       expect(store.get("progressURL")).eql("fakeURL");
     });
 
     it("should initialize the websocket", function() {
       sandbox.stub(loop, "CallConnectionWebSocket").returns({
         promiseConnect: function() { return connectPromise; },
         on: sinon.spy()
       });
 
-      dispatcher.dispatch(
+      store.connectCall(
         new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
       sinon.assert.calledOnce(loop.CallConnectionWebSocket);
       sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
         url: "fakeURL",
         callId: "142536",
         websocketToken: "543216"
       });
     });
 
     it("should connect the websocket to the server", function() {
-      dispatcher.dispatch(
+      store.connectCall(
         new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
       sinon.assert.calledOnce(store._websocket.promiseConnect);
     });
 
     describe("WebSocket connection result", function() {
       beforeEach(function() {
-        dispatcher.dispatch(
+        store.connectCall(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
         sandbox.stub(dispatcher, "dispatch");
       });
 
       it("should dispatch a connection progress action on success", function(done) {
         resolveConnectPromise(WS_STATES.INIT);
 
@@ -475,168 +475,194 @@ describe("loop.store.ConversationStore",
         mediaFail: wsMediaFailSpy,
         close: wsCloseSpy
       };
       store.set({callState: CALL_STATES.ONGOING});
       store.set({windowId: "42"});
     });
 
     it("should disconnect the session", function() {
-      dispatcher.dispatch(new sharedActions.HangupCall());
+      store.hangupCall(new sharedActions.HangupCall());
 
       sinon.assert.calledOnce(sdkDriver.disconnectSession);
     });
 
     it("should send a media-fail message to the websocket if it is open", function() {
-      dispatcher.dispatch(new sharedActions.HangupCall());
+      store.hangupCall(new sharedActions.HangupCall());
 
       sinon.assert.calledOnce(wsMediaFailSpy);
     });
 
     it("should ensure the websocket is closed", function() {
-      dispatcher.dispatch(new sharedActions.HangupCall());
+      store.hangupCall(new sharedActions.HangupCall());
 
       sinon.assert.calledOnce(wsCloseSpy);
     });
 
     it("should set the callState to finished", function() {
-      dispatcher.dispatch(new sharedActions.HangupCall());
+      store.hangupCall(new sharedActions.HangupCall());
 
       expect(store.get("callState")).eql(CALL_STATES.FINISHED);
     });
 
     it("should release mozLoop callsData", function() {
-      dispatcher.dispatch(new sharedActions.HangupCall());
+      store.hangupCall(new sharedActions.HangupCall());
 
       sinon.assert.calledOnce(navigator.mozLoop.calls.clearCallInProgress);
       sinon.assert.calledWithExactly(
         navigator.mozLoop.calls.clearCallInProgress, "42");
     });
   });
 
-  describe("#peerHungupCall", function() {
+  describe("#remotePeerDisconnected", function() {
     var wsMediaFailSpy, wsCloseSpy;
     beforeEach(function() {
       wsMediaFailSpy = sinon.spy();
       wsCloseSpy = sinon.spy();
 
       store._websocket = {
         mediaFail: wsMediaFailSpy,
         close: wsCloseSpy
       };
       store.set({callState: CALL_STATES.ONGOING});
       store.set({windowId: "42"});
     });
 
     it("should disconnect the session", function() {
-      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+      store.remotePeerDisconnected(new sharedActions.RemotePeerDisconnected({
+        peerHungup: true
+      }));
 
       sinon.assert.calledOnce(sdkDriver.disconnectSession);
     });
 
     it("should ensure the websocket is closed", function() {
-      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+      store.remotePeerDisconnected(new sharedActions.RemotePeerDisconnected({
+        peerHungup: true
+      }));
 
       sinon.assert.calledOnce(wsCloseSpy);
     });
 
-    it("should set the callState to finished", function() {
-      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+    it("should release mozLoop callsData", function() {
+      store.remotePeerDisconnected(new sharedActions.RemotePeerDisconnected({
+        peerHungup: true
+      }));
+
+      sinon.assert.calledOnce(navigator.mozLoop.calls.clearCallInProgress);
+      sinon.assert.calledWithExactly(
+        navigator.mozLoop.calls.clearCallInProgress, "42");
+    });
+
+    it("should set the callState to finished if the peer hungup", function() {
+      store.remotePeerDisconnected(new sharedActions.RemotePeerDisconnected({
+        peerHungup: true
+      }));
 
       expect(store.get("callState")).eql(CALL_STATES.FINISHED);
     });
 
-    it("should release mozLoop callsData", function() {
-      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+    it("should set the callState to terminated if the peer was disconnected" +
+      "for an unintentional reason", function() {
+        store.remotePeerDisconnected(new sharedActions.RemotePeerDisconnected({
+          peerHungup: false
+        }));
 
-      sinon.assert.calledOnce(navigator.mozLoop.calls.clearCallInProgress);
-      sinon.assert.calledWithExactly(
-        navigator.mozLoop.calls.clearCallInProgress, "42");
+        expect(store.get("callState")).eql(CALL_STATES.TERMINATED);
+      });
+
+    it("should set the reason to peerNetworkDisconnected if the peer was" +
+      "disconnected for an unintentional reason", function() {
+        store.remotePeerDisconnected(new sharedActions.RemotePeerDisconnected({
+          peerHungup: false
+        }));
+
+        expect(store.get("callStateReason")).eql("peerNetworkDisconnected");
     });
   });
 
   describe("#cancelCall", function() {
     beforeEach(function() {
       store._websocket = fakeWebsocket;
 
       store.set({callState: CALL_STATES.CONNECTING});
       store.set({windowId: "42"});
     });
 
     it("should disconnect the session", function() {
-      dispatcher.dispatch(new sharedActions.CancelCall());
+      store.cancelCall(new sharedActions.CancelCall());
 
       sinon.assert.calledOnce(sdkDriver.disconnectSession);
     });
 
     it("should send a cancel message to the websocket if it is open", function() {
-      dispatcher.dispatch(new sharedActions.CancelCall());
+      store.cancelCall(new sharedActions.CancelCall());
 
       sinon.assert.calledOnce(wsCancelSpy);
     });
 
     it("should ensure the websocket is closed", function() {
-      dispatcher.dispatch(new sharedActions.CancelCall());
+      store.cancelCall(new sharedActions.CancelCall());
 
       sinon.assert.calledOnce(wsCloseSpy);
     });
 
     it("should set the state to close if the call is connecting", function() {
-      dispatcher.dispatch(new sharedActions.CancelCall());
+      store.cancelCall(new sharedActions.CancelCall());
 
       expect(store.get("callState")).eql(CALL_STATES.CLOSE);
     });
 
     it("should set the state to close if the call has terminated already", function() {
       store.set({callState: CALL_STATES.TERMINATED});
 
-      dispatcher.dispatch(new sharedActions.CancelCall());
+      store.cancelCall(new sharedActions.CancelCall());
 
       expect(store.get("callState")).eql(CALL_STATES.CLOSE);
     });
 
     it("should release mozLoop callsData", function() {
-      dispatcher.dispatch(new sharedActions.CancelCall());
+      store.cancelCall(new sharedActions.CancelCall());
 
       sinon.assert.calledOnce(navigator.mozLoop.calls.clearCallInProgress);
       sinon.assert.calledWithExactly(
         navigator.mozLoop.calls.clearCallInProgress, "42");
     });
   });
 
   describe("#retryCall", function() {
     it("should set the state to gather", function() {
       store.set({callState: CALL_STATES.TERMINATED});
 
-      dispatcher.dispatch(new sharedActions.RetryCall());
+      store.retryCall(new sharedActions.RetryCall());
 
       expect(store.get("callState")).eql(CALL_STATES.GATHER);
     });
 
     it("should request the outgoing call data", function() {
       store.set({
         callState: CALL_STATES.TERMINATED,
         outgoing: true,
         callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO,
         contact: contact
       });
 
-      dispatcher.dispatch(new sharedActions.RetryCall());
+      store.retryCall(new sharedActions.RetryCall());
 
       sinon.assert.calledOnce(client.setupOutgoingCall);
       sinon.assert.calledWith(client.setupOutgoingCall,
         ["fakeEmail"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
     });
   });
 
   describe("#mediaConnected", function() {
     it("should send mediaUp via the websocket", function() {
       store._websocket = fakeWebsocket;
 
-      dispatcher.dispatch(new sharedActions.MediaConnected());
+      store.mediaConnected(new sharedActions.MediaConnected());
 
       sinon.assert.calledOnce(wsMediaUpSpy);
     });
   });
 
   describe("#setMute", function() {
     it("should save the mute state for the audio stream", function() {
       store.set({"audioMuted": false});
@@ -658,49 +684,49 @@ describe("loop.store.ConversationStore",
       }));
 
       expect(store.get("videoMuted")).eql(true);
     });
   });
 
   describe("#fetchEmailLink", function() {
     it("should request a new call url to the server", function() {
-      dispatcher.dispatch(new sharedActions.FetchEmailLink());
+      store.fetchEmailLink(new sharedActions.FetchEmailLink());
 
       sinon.assert.calledOnce(client.requestCallUrl);
       sinon.assert.calledWith(client.requestCallUrl, "");
     });
 
     it("should update the emailLink attribute when the new call url is received",
       function() {
         client.requestCallUrl = function(callId, cb) {
           cb(null, {callUrl: "http://fake.invalid/"});
         };
-        dispatcher.dispatch(new sharedActions.FetchEmailLink());
+        store.fetchEmailLink(new sharedActions.FetchEmailLink());
 
         expect(store.get("emailLink")).eql("http://fake.invalid/");
       });
 
     it("should trigger an error:emailLink event in case of failure",
       function() {
         var trigger = sandbox.stub(store, "trigger");
         client.requestCallUrl = function(callId, cb) {
           cb("error");
         };
-        dispatcher.dispatch(new sharedActions.FetchEmailLink());
+        store.fetchEmailLink(new sharedActions.FetchEmailLink());
 
         sinon.assert.calledOnce(trigger);
         sinon.assert.calledWithExactly(trigger, "error:emailLink");
       });
   });
 
   describe("Events", function() {
     describe("Websocket progress", function() {
       beforeEach(function() {
-        dispatcher.dispatch(
+        store.connectCall(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
         sandbox.stub(dispatcher, "dispatch");
       });
 
       it("should dispatch a connection failure action on 'terminate'", function() {
         store._websocket.trigger("progress", {
           state: WS_STATES.TERMINATED,
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -226,36 +226,40 @@ describe("loop.OTSdkDriver", function ()
         getRemoteElementFunc: function() {return fakeRemoteElement;},
         publisherConfig: publisherConfig
       }));
 
       sandbox.stub(dispatcher, "dispatch");
     });
 
     describe("connectionDestroyed", function() {
-      it("should dispatch a peerHungupCall action if the client disconnected", function() {
-        session.trigger("connectionDestroyed", {
-          reason: "clientDisconnected"
+      it("should dispatch a remotePeerDisconnected action if the client" +
+        "disconnected", function() {
+          session.trigger("connectionDestroyed", {
+            reason: "clientDisconnected"
+          });
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("name", "remotePeerDisconnected"));
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("peerHungup", true));
         });
 
-        sinon.assert.calledOnce(dispatcher.dispatch);
-        sinon.assert.calledWithMatch(dispatcher.dispatch,
-          sinon.match.hasOwn("name", "peerHungupCall"));
-      });
+      it("should dispatch a remotePeerDisconnected action if the connection" +
+        "failed", function() {
+          session.trigger("connectionDestroyed", {
+            reason: "networkDisconnected"
+          });
 
-      it("should dispatch a connectionFailure action if the connection failed", function() {
-        session.trigger("connectionDestroyed", {
-          reason: "networkDisconnected"
-        });
-
-        sinon.assert.calledOnce(dispatcher.dispatch);
-        sinon.assert.calledWithMatch(dispatcher.dispatch,
-          sinon.match.hasOwn("name", "connectionFailure"));
-        sinon.assert.calledWithMatch(dispatcher.dispatch,
-          sinon.match.hasOwn("reason", "peerNetworkDisconnected"));
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("name", "remotePeerDisconnected"));
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("peerHungup", false));
       });
     });
 
     describe("sessionDisconnected", function() {
       it("should dispatch a connectionFailure action if the session was disconnected",
         function() {
           session.trigger("sessionDisconnected", {
             reason: "networkDisconnected"
@@ -291,10 +295,38 @@ describe("loop.OTSdkDriver", function ()
 
         session.trigger("streamCreated", {stream: fakeStream});
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "mediaConnected"));
       });
     });
+
+    describe("connectionCreated", function() {
+      beforeEach(function() {
+        session.connection = {
+          id: "localUser"
+        };
+      });
+
+      it("should dispatch a RemotePeerConnected action if this is for a remote user",
+        function() {
+          session.trigger("connectionCreated", {
+            connection: {id: "remoteUser"}
+          });
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.RemotePeerConnected());
+        });
+
+      it("should not dispatch an action if this is for a local user",
+        function() {
+          session.trigger("connectionCreated", {
+            connection: {id: "localUser"}
+          });
+
+          sinon.assert.notCalled(dispatcher.dispatch);
+        });
+    });
   });
 });
--- a/browser/components/loop/test/shared/roomStore_test.js
+++ b/browser/components/loop/test/shared/roomStore_test.js
@@ -365,17 +365,18 @@ describe("loop.store.RoomStore", functio
     });
 
     describe("ActiveRoomStore substore", function() {
       var store, activeRoomStore;
 
       beforeEach(function() {
         activeRoomStore = new loop.store.ActiveRoomStore({
           dispatcher: dispatcher,
-          mozLoop: fakeMozLoop
+          mozLoop: fakeMozLoop,
+          sdkDriver: {}
         });
         store = new loop.store.RoomStore({
           dispatcher: dispatcher,
           mozLoop: fakeMozLoop,
           activeRoomStore: activeRoomStore
         });
       });
 
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -36,16 +36,17 @@
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/activeRoomStore.js"></script>
+  <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../standalone/content/js/multiplexGum.js"></script>
   <script src="../../standalone/content/js/standaloneAppStore.js"></script>
   <script src="../../standalone/content/js/standaloneClient.js"></script>
   <script src="../../standalone/content/js/standaloneMozLoop.js"></script>
   <script src="../../standalone/content/js/standaloneRoomViews.js"></script>
   <script src="../../standalone/content/js/webapp.js"></script>
   <!-- Test scripts -->
   <script src="standalone_client_test.js"></script>
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -607,17 +607,18 @@ describe("loop.webapp", function() {
         sdk: sdk
       });
       client = new loop.StandaloneClient({
         baseServerUrl: "fakeUrl"
       });
       dispatcher = new loop.Dispatcher();
       activeRoomStore = new loop.store.ActiveRoomStore({
         dispatcher: dispatcher,
-        mozLoop: {}
+        mozLoop: {},
+        sdkDriver: {}
       });
       standaloneAppStore = new loop.store.StandaloneAppStore({
         dispatcher: dispatcher,
         sdk: sdk,
         helper: helper,
         conversation: conversationModel
       });
       // Stub this to stop the StartConversationView kicking in the request and
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -17,33 +17,35 @@
   // 1. Desktop components
   // 1.1 Panel
   var PanelView = loop.panel.PanelView;
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
   var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
   var CallFailedView = loop.conversationViews.CallFailedView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
-  var DesktopRoomInvitationView = loop.roomViews.DesktopRoomInvitationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView  = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView   = loop.webapp.UnsupportedDeviceView;
   var CallUrlExpiredView      = loop.webapp.CallUrlExpiredView;
   var PendingConversationView = loop.webapp.PendingConversationView;
   var StartConversationView   = loop.webapp.StartConversationView;
   var FailedConversationView  = loop.webapp.FailedConversationView;
   var EndedConversationView   = loop.webapp.EndedConversationView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
   var FeedbackView = loop.shared.views.FeedbackView;
 
+  // Room constants
+  var ROOM_STATES = loop.store.ROOM_STATES;
+
   // Local helpers
   function returnTrue() {
     return true;
   }
 
   function returnFalse() {
     return false;
   }
@@ -56,17 +58,18 @@
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
   var dispatcher = new loop.Dispatcher();
   var activeRoomStore = new loop.store.ActiveRoomStore({
     dispatcher: dispatcher,
-    mozLoop: navigator.mozLoop
+    mozLoop: navigator.mozLoop,
+    sdkDriver: {}
   });
   var roomStore = new loop.store.RoomStore({
     dispatcher: dispatcher,
     mozLoop: navigator.mozLoop
   });
 
   // Local mocks
 
@@ -528,30 +531,34 @@
           Section({name: "UnsupportedDeviceView"}, 
             Example({summary: "Standalone Unsupported Device"}, 
               React.DOM.div({className: "standalone"}, 
                 UnsupportedDeviceView(null)
               )
             )
           ), 
 
-          Section({name: "DesktopRoomInvitationView"}, 
-            Example({summary: "Desktop room invitation", dashed: "true", 
+          Section({name: "DesktopRoomConversationView"}, 
+            Example({summary: "Desktop room conversation (invitation)", dashed: "true", 
                      style: {width: "260px", height: "265px"}}, 
               React.DOM.div({className: "fx-embedded"}, 
-                DesktopRoomInvitationView({roomStore: roomStore})
+                DesktopRoomConversationView({
+                  roomStore: roomStore, 
+                  dispatcher: dispatcher, 
+                  roomState: ROOM_STATES.INIT})
               )
-            )
-          ), 
+            ), 
 
-          Section({name: "DesktopRoomConversationView"}, 
             Example({summary: "Desktop room conversation", dashed: "true", 
                      style: {width: "260px", height: "265px"}}, 
               React.DOM.div({className: "fx-embedded"}, 
-                DesktopRoomConversationView({roomStore: roomStore})
+                DesktopRoomConversationView({
+                  roomStore: roomStore, 
+                  dispatcher: dispatcher, 
+                  roomState: ROOM_STATES.HAS_PARTICIPANTS})
               )
             )
           ), 
 
           Section({name: "SVG icons preview"}, 
             Example({summary: "16x16"}, 
               SVGIcons(null)
             )
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -17,33 +17,35 @@
   // 1. Desktop components
   // 1.1 Panel
   var PanelView = loop.panel.PanelView;
   // 1.2. Conversation Window
   var IncomingCallView = loop.conversation.IncomingCallView;
   var DesktopPendingConversationView = loop.conversationViews.PendingConversationView;
   var CallFailedView = loop.conversationViews.CallFailedView;
   var DesktopRoomConversationView = loop.roomViews.DesktopRoomConversationView;
-  var DesktopRoomInvitationView = loop.roomViews.DesktopRoomInvitationView;
 
   // 2. Standalone webapp
   var HomeView = loop.webapp.HomeView;
   var UnsupportedBrowserView  = loop.webapp.UnsupportedBrowserView;
   var UnsupportedDeviceView   = loop.webapp.UnsupportedDeviceView;
   var CallUrlExpiredView      = loop.webapp.CallUrlExpiredView;
   var PendingConversationView = loop.webapp.PendingConversationView;
   var StartConversationView   = loop.webapp.StartConversationView;
   var FailedConversationView  = loop.webapp.FailedConversationView;
   var EndedConversationView   = loop.webapp.EndedConversationView;
 
   // 3. Shared components
   var ConversationToolbar = loop.shared.views.ConversationToolbar;
   var ConversationView = loop.shared.views.ConversationView;
   var FeedbackView = loop.shared.views.FeedbackView;
 
+  // Room constants
+  var ROOM_STATES = loop.store.ROOM_STATES;
+
   // Local helpers
   function returnTrue() {
     return true;
   }
 
   function returnFalse() {
     return false;
   }
@@ -56,17 +58,18 @@
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
   var dispatcher = new loop.Dispatcher();
   var activeRoomStore = new loop.store.ActiveRoomStore({
     dispatcher: dispatcher,
-    mozLoop: navigator.mozLoop
+    mozLoop: navigator.mozLoop,
+    sdkDriver: {}
   });
   var roomStore = new loop.store.RoomStore({
     dispatcher: dispatcher,
     mozLoop: navigator.mozLoop
   });
 
   // Local mocks
 
@@ -528,30 +531,34 @@
           <Section name="UnsupportedDeviceView">
             <Example summary="Standalone Unsupported Device">
               <div className="standalone">
                 <UnsupportedDeviceView />
               </div>
             </Example>
           </Section>
 
-          <Section name="DesktopRoomInvitationView">
-            <Example summary="Desktop room invitation" dashed="true"
+          <Section name="DesktopRoomConversationView">
+            <Example summary="Desktop room conversation (invitation)" dashed="true"
                      style={{width: "260px", height: "265px"}}>
               <div className="fx-embedded">
-                <DesktopRoomInvitationView roomStore={roomStore} />
+                <DesktopRoomConversationView
+                  roomStore={roomStore}
+                  dispatcher={dispatcher}
+                  roomState={ROOM_STATES.INIT} />
               </div>
             </Example>
-          </Section>
 
-          <Section name="DesktopRoomConversationView">
             <Example summary="Desktop room conversation" dashed="true"
                      style={{width: "260px", height: "265px"}}>
               <div className="fx-embedded">
-                <DesktopRoomConversationView roomStore={roomStore} />
+                <DesktopRoomConversationView
+                  roomStore={roomStore}
+                  dispatcher={dispatcher}
+                  roomState={ROOM_STATES.HAS_PARTICIPANTS} />
               </div>
             </Example>
           </Section>
 
           <Section name="SVG icons preview">
             <Example summary="16x16">
               <SVGIcons />
             </Example>
--- a/browser/components/sessionstore/SessionStore.jsm
+++ b/browser/components/sessionstore/SessionStore.jsm
@@ -630,31 +630,18 @@ let SessionStoreInternal = {
           let tabData = browser.__SS_data;
 
           // wall-paper fix for bug 439675: make sure that the URL to be loaded
           // is always visible in the address bar
           let activePageData = tabData.entries[tabData.index - 1] || null;
           let uri = activePageData ? activePageData.url || null : null;
           browser.userTypedValue = uri;
 
-          // If the page has a title, set it.
-          if (activePageData) {
-            if (activePageData.title) {
-              tab.label = activePageData.title;
-              tab.crop = "end";
-            } else if (activePageData.url != "about:blank") {
-              tab.label = activePageData.url;
-              tab.crop = "center";
-            }
-          }
-
-          // Restore the tab icon.
-          if ("image" in tabData) {
-            win.gBrowser.setIcon(tab, tabData.image);
-          }
+          // Update tab label and icon again after the tab history was updated.
+          this.updateTabLabelAndIcon(tab, tabData);
 
           let event = win.document.createEvent("Events");
           event.initEvent("SSTabRestoring", true, false);
           tab.dispatchEvent(event);
         }
         break;
       case "SessionStore:restoreTabContentStarted":
         if (this.isCurrentEpoch(browser, aMessage.data.epoch)) {
@@ -1855,16 +1842,36 @@ let SessionStoreInternal = {
   },
 
   persistTabAttribute: function ssi_persistTabAttribute(aName) {
     if (TabAttributes.persist(aName)) {
       this.saveStateDelayed();
     }
   },
 
+  updateTabLabelAndIcon(tab, tabData) {
+    let activePageData = tabData.entries[tabData.index - 1] || null;
+
+    // If the page has a title, set it.
+    if (activePageData) {
+      if (activePageData.title) {
+        tab.label = activePageData.title;
+        tab.crop = "end";
+      } else if (activePageData.url != "about:blank") {
+        tab.label = activePageData.url;
+        tab.crop = "center";
+      }
+    }
+
+    // Restore the tab icon.
+    if ("image" in tabData) {
+      tab.ownerDocument.defaultView.gBrowser.setIcon(tab, tabData.image);
+    }
+  },
+
   /**
    * Restores the session state stored in LastSession. This will attempt
    * to merge data into the current session. If a window was opened at startup
    * with pinned tab(s), then the remaining data from the previous session for
    * that window will be opened into that winddow. Otherwise new windows will
    * be opened.
    */
   restoreLastSession: function ssi_restoreLastSession() {
@@ -2527,19 +2534,27 @@ let SessionStoreInternal = {
     // If provided, set the selected tab.
     if (aSelectTab > 0 && aSelectTab <= aTabs.length) {
       tabbrowser.selectedTab = aTabs[aSelectTab - 1];
 
       // Update the window state in case we shut down without being notified.
       this._windows[aWindow.__SSi].selected = aSelectTab;
     }
 
+    // If we restore the selected tab, make sure it goes first.
+    let selectedIndex = aTabs.indexOf(tabbrowser.selectedTab);
+    if (selectedIndex > -1) {
+      this.restoreTab(tabbrowser.selectedTab, aTabData[selectedIndex]);
+    }
+
     // Restore all tabs.
     for (let t = 0; t < aTabs.length; t++) {
-      this.restoreTab(aTabs[t], aTabData[t]);
+      if (t != selectedIndex) {
+        this.restoreTab(aTabs[t], aTabData[t]);
+      }
     }
   },
 
   // Restores the given tab state for a given tab.
   restoreTab(tab, tabData, options = {}) {
     let restoreImmediately = options.restoreImmediately;
     let loadArguments = options.loadArguments;
     let browser = tab.linkedBrowser;
@@ -2635,16 +2650,20 @@ let SessionStoreInternal = {
       formdata: tabData.formdata || null,
       disallow: tabData.disallow || null,
       pageStyle: tabData.pageStyle || null
     });
 
     browser.messageManager.sendAsyncMessage("SessionStore:restoreHistory",
                                             {tabData: tabData, epoch: epoch});
 
+    // Update tab label and icon to show something
+    // while we wait for the messages to be processed.
+    this.updateTabLabelAndIcon(tab, tabData);
+
     // Restore tab attributes.
     if ("attributes" in tabData) {
       TabAttributes.set(tab, tabData.attributes);
     }
 
     // This could cause us to ignore MAX_CONCURRENT_TAB_RESTORES a bit, but
     // it ensures each window will have its selected tab loaded.
     if (restoreImmediately || tabbrowser.selectedBrowser == browser || loadArguments) {
--- a/browser/devtools/framework/toolbox.js
+++ b/browser/devtools/framework/toolbox.js
@@ -756,16 +756,20 @@ Toolbox.prototype = {
       }
     });
 
     // Tilt is handled separately because it is disabled in E10S mode. Because
     // we have removed tilt from toolboxButtons we have to deal with it here.
     let tiltEnabled = !this.target.isMultiProcess &&
                       Services.prefs.getBoolPref("devtools.command-button-tilt.enabled");
     let tiltButton = this.doc.getElementById("command-button-tilt");
+    // Remote toolboxes don't add the button to the DOM at all
+    if (!tiltButton) {
+      return;
+    }
 
     if (tiltEnabled) {
       tiltButton.removeAttribute("hidden");
     } else {
       tiltButton.setAttribute("hidden", "true");
     }
   },
 
--- a/browser/devtools/shared/doorhanger.js
+++ b/browser/devtools/shared/doorhanger.js
@@ -71,16 +71,24 @@ let panelAttrs = {
  */
 exports.showDoorhanger = Task.async(function *({ window, type, anchor }) {
   let { predicate, success, url, action } = TYPES[type];
   // Abort if predicate fails
   if (!predicate()) {
     return;
   }
 
+  // Call success function to set preferences/cleanup immediately,
+  // so if triggered multiple times, only happens once (Windows/Linux)
+  success();
+
+  // Wait 200ms to prevent flickering where the popup is displayed
+  // before the underlying window (Windows 7, 64bit)
+  yield wait(200);
+
   let document = window.document;
 
   let panel = document.createElementNS(XULNS, "panel");
   let frame = document.createElementNS(XULNS, "iframe");
   let parentEl = document.querySelector("window");
 
   frame.setAttribute("src", url);
   let close = () => parentEl.removeChild(panel);
@@ -103,19 +111,16 @@ exports.showDoorhanger = Task.async(func
   if (goBtn) {
     goBtn.addEventListener("click", () => {
       if (action) {
         action();
       }
       close();
     });
   }
-
-  // Call success function to set preferences, etc.
-  success();
 });
 
 function setDoorhangerStyle (panel, frame) {
   Object.keys(panelAttrs).forEach(prop => panel.setAttribute(prop, panelAttrs[prop]));
   panel.style.margin = "20px";
   panel.style.borderRadius = "5px";
   panel.style.border = "none";
   panel.style.MozAppearance = "none";
@@ -142,8 +147,14 @@ function onFrameLoad (frame) {
   }
 
   return promise;
 }
 
 function getGBrowser () {
   return getMostRecentBrowserWindow().gBrowser;
 }
+
+function wait (n) {
+  let { resolve, promise } = Promise.defer();
+  setTimeout(resolve, n);
+  return promise;
+}
--- a/browser/devtools/shared/telemetry.js
+++ b/browser/devtools/shared/telemetry.js
@@ -140,16 +140,21 @@ Telemetry.prototype = {
       userHistogram: "DEVTOOLS_JSPROFILER_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_JSPROFILER_TIME_ACTIVE_SECONDS"
     },
     netmonitor: {
       histogram: "DEVTOOLS_NETMONITOR_OPENED_BOOLEAN",
       userHistogram: "DEVTOOLS_NETMONITOR_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_NETMONITOR_TIME_ACTIVE_SECONDS"
     },
+    storage: {
+       histogram: "DEVTOOLS_STORAGE_OPENED_BOOLEAN",
+       userHistogram: "DEVTOOLS_STORAGE_OPENED_PER_USER_FLAG",
+       timerHistogram: "DEVTOOLS_STORAGE_TIME_ACTIVE_SECONDS"
+    },
     tilt: {
       histogram: "DEVTOOLS_TILT_OPENED_BOOLEAN",
       userHistogram: "DEVTOOLS_TILT_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_TILT_TIME_ACTIVE_SECONDS"
     },
     paintflashing: {
       histogram: "DEVTOOLS_PAINTFLASHING_OPENED_BOOLEAN",
       userHistogram: "DEVTOOLS_PAINTFLASHING_OPENED_PER_USER_FLAG",
--- a/browser/devtools/shared/test/browser.ini
+++ b/browser/devtools/shared/test/browser.ini
@@ -55,16 +55,17 @@ skip-if = e10s # Bug 1086492 - Disable t
 [browser_telemetry_toolbox.js]
 [browser_telemetry_toolboxtabs_canvasdebugger.js]
 [browser_telemetry_toolboxtabs_inspector.js]
 [browser_telemetry_toolboxtabs_jsdebugger.js]
 [browser_telemetry_toolboxtabs_jsprofiler.js]
 [browser_telemetry_toolboxtabs_netmonitor.js]
 [browser_telemetry_toolboxtabs_options.js]
 [browser_telemetry_toolboxtabs_shadereditor.js]
+[browser_telemetry_toolboxtabs_storage.js]
 [browser_telemetry_toolboxtabs_styleeditor.js]
 [browser_telemetry_toolboxtabs_webaudioeditor.js]
 [browser_telemetry_toolboxtabs_webconsole.js]
 [browser_templater_basic.js]
 [browser_toolbar_basic.js]
 [browser_toolbar_tooltip.js]
 [browser_toolbar_webconsole_errors_count.js]
 skip-if = buildapp == 'mulet'
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_storage.js
@@ -0,0 +1,119 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_storage.js</p>";
+
+// Because we need to gather stats for the period of time that a tool has been
+// opened we make use of setTimeout() to create tool active times.
+const TOOL_DELAY = 200;
+
+let {Promise: promise} = Cu.import("resource://gre/modules/devtools/deprecated-sync-thenables.js", {});
+let {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
+
+let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
+let Telemetry = require("devtools/shared/telemetry");
+
+let STORAGE_PREF = "devtools.storage.enabled";
+Services.prefs.setBoolPref(STORAGE_PREF, true);
+
+function init() {
+  Telemetry.prototype.telemetryInfo = {};
+  Telemetry.prototype._oldlog = Telemetry.prototype.log;
+  Telemetry.prototype.log = function(histogramId, value) {
+    if (!this.telemetryInfo) {
+      // Can be removed when Bug 992911 lands (see Bug 1011652 Comment 10)
+      return;
+    }
+    if (histogramId) {
+      if (!this.telemetryInfo[histogramId]) {
+        this.telemetryInfo[histogramId] = [];
+      }
+
+      this.telemetryInfo[histogramId].push(value);
+    }
+  }
+
+  openToolboxTabTwice("storage", false);
+}
+
+function openToolboxTabTwice(id, secondPass) {
+  let target = TargetFactory.forTab(gBrowser.selectedTab);
+
+  gDevTools.showToolbox(target, id).then(function(toolbox) {
+    info("Toolbox tab " + id + " opened");
+
+    toolbox.once("destroyed", function() {
+      if (secondPass) {
+        checkResults();
+      } else {
+        openToolboxTabTwice(id, true);
+      }
+    });
+    // We use a timeout to check the tools active time
+    setTimeout(function() {
+      gDevTools.closeToolbox(target);
+    }, TOOL_DELAY);
+  }).then(null, reportError);
+}
+
+function checkResults() {
+  let result = Telemetry.prototype.telemetryInfo;
+
+  for (let [histId, value] of Iterator(result)) {
+    if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+      ok(value.length === 1 && value[0] === true,
+         "Per user value " + histId + " has a single value of true");
+    } else if (histId.endsWith("OPENED_BOOLEAN")) {
+      ok(value.length > 1, histId + " has more than one entry");
+
+      let okay = value.every(function(element) {
+        return element === true;
+      });
+
+      ok(okay, "All " + histId + " entries are === true");
+    } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) {
+      ok(value.length > 1, histId + " has more than one entry");
+
+      let okay = value.every(function(element) {
+        return element > 0;
+      });
+
+      ok(okay, "All " + histId + " entries have time > 0");
+    }
+  }
+
+  finishUp();
+}
+
+function reportError(error) {
+  let stack = "    " + error.stack.replace(/\n?.*?@/g, "\n    JS frame :: ");
+
+  ok(false, "ERROR: " + error + " at " + error.fileName + ":" +
+            error.lineNumber + "\n\nStack trace:" + stack);
+  finishUp();
+}
+
+function finishUp() {
+  gBrowser.removeCurrentTab();
+
+  Telemetry.prototype.log = Telemetry.prototype._oldlog;
+  delete Telemetry.prototype._oldlog;
+  delete Telemetry.prototype.telemetryInfo;
+
+  Services.prefs.clearUserPref(STORAGE_PREF);
+
+  TargetFactory = Services = promise = require = null;
+
+  finish();
+}
+
+function test() {
+  waitForExplicitFinish();
+  gBrowser.selectedTab = gBrowser.addTab();
+  gBrowser.selectedBrowser.addEventListener("load", function() {
+    gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true);
+    waitForFocus(init, content);
+  }, true);
+
+  content.location = TEST_URI;
+}
--- a/browser/devtools/storage/panel.js
+++ b/browser/devtools/storage/panel.js
@@ -54,17 +54,17 @@ StoragePanel.prototype = {
       this.UI = new StorageUI(this._front, this._target, this._panelWin);
       this.isReady = true;
       this.emit("ready");
       return this;
     }, console.error);
   },
 
   /**
-   * Destroy the style editor.
+   * Destroy the storage inspector.
    */
   destroy: function() {
     if (!this._destroyed) {
       this.UI.destroy();
       // Destroy front to ensure packet handler is removed from client
       this._front.destroy();
       this._destroyed = true;
 
--- a/browser/devtools/storage/ui.js
+++ b/browser/devtools/storage/ui.js
@@ -16,16 +16,18 @@ XPCOMUtils.defineLazyGetter(this, "Table
   () => require("devtools/shared/widgets/TableWidget").TableWidget);
 XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
   "resource://gre/modules/devtools/event-emitter.js");
 XPCOMUtils.defineLazyModuleGetter(this, "ViewHelpers",
   "resource:///modules/devtools/ViewHelpers.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "VariablesView",
   "resource:///modules/devtools/VariablesView.jsm");
 
+let Telemetry = require("devtools/shared/telemetry");
+
 /**
  * Localization convenience methods.
  */
 let L10N = new ViewHelpers.L10N(STORAGE_STRINGS);
 
 const GENERIC_VARIABLES_VIEW_SETTINGS = {
   lazyEmpty: true,
   lazyEmptyDelay: 10, // ms
@@ -80,28 +82,32 @@ this.StorageUI = function StorageUI(fron
   this.front.listStores().then(storageTypes => {
     this.populateStorageTree(storageTypes);
   });
   this.onUpdate = this.onUpdate.bind(this);
   this.front.on("stores-update", this.onUpdate);
 
   this.handleKeypress = this.handleKeypress.bind(this);
   this._panelDoc.addEventListener("keypress", this.handleKeypress);
+
+  this._telemetry = new Telemetry();
+  this._telemetry.toolOpened("storage");
 }
 
 exports.StorageUI = StorageUI;
 
 StorageUI.prototype = {
 
   storageTypes: null,
   shouldResetColumns: true,
 
   destroy: function() {
     this.front.off("stores-update", this.onUpdate);
     this._panelDoc.removeEventListener("keypress", this.handleKeypress);
+    this._telemetry.toolClosed("storage");
   },
 
   /**
    * Empties and hides the object viewer sidebar
    */
   hideSidebar: function() {
     this.view.empty();
     this.sidebar.hidden = true;
--- a/browser/devtools/webide/content/webide.js
+++ b/browser/devtools/webide/content/webide.js
@@ -280,17 +280,24 @@ let UI = {
     this._busyPromise = promise;
     this._busyOperationDescription = operationDescription;
     this.setupBusyTimeout();
     this.busy();
     promise.then(() => {
       this.cancelBusyTimeout();
       this.unbusy();
     }, (e) => {
-      let message = operationDescription + (e ? (": " + e) : "");
+      let message;
+      if (e.error && e.message) {
+        // Some errors come from fronts that are not based on protocol.js.
+        // Errors are not translated to strings.
+        message = operationDescription + " (" + e.error + "): " + e.message;
+      } else {
+        message = operationDescription + (e ? (": " + e) : "");
+      }
       this.cancelBusyTimeout();
       let operationCanceled = e && e.canceled;
       if (!operationCanceled) {
         UI.reportError("error_operationFail", message);
         console.error(e);
       }
       this.unbusy();
     });
@@ -812,17 +819,18 @@ let UI = {
         playCmd.removeAttribute("disabled");
       } else if (AppManager.selectedProject.type == "tab") {
         playCmd.removeAttribute("disabled");
         stopCmd.setAttribute("disabled", "true");
       } else if (AppManager.selectedProject.type == "mainProcess") {
         playCmd.setAttribute("disabled", "true");
         stopCmd.setAttribute("disabled", "true");
       } else {
-        if (AppManager.selectedProject.errorsCount == 0) {
+        if (AppManager.selectedProject.errorsCount == 0 &&
+            AppManager.runtimeCanHandleApps()) {
           playCmd.removeAttribute("disabled");
         } else {
           playCmd.setAttribute("disabled", "true");
         }
       }
     }
 
     // Remove command
--- a/browser/devtools/webide/modules/app-manager.js
+++ b/browser/devtools/webide/modules/app-manager.js
@@ -101,28 +101,38 @@ let AppManager = exports.AppManager = {
       if (this._appsFront) {
         this._appsFront.off("install-progress", this.onInstallProgress);
         this._appsFront.unwatchApps();
         this._appsFront = null;
       }
       this._listTabsResponse = null;
     } else {
       this.connection.client.listTabs((response) => {
-        let front = new AppActorFront(this.connection.client,
-                                      response);
-        front.on("install-progress", this.onInstallProgress);
-        front.watchApps(() => this.checkIfProjectIsRunning())
-             .then(() => front.fetchIcons())
-             .then(() => {
-               this._appsFront = front;
-               this.checkIfProjectIsRunning();
-               this.update("runtime-apps-found");
-             });
-        this._listTabsResponse = response;
-        this.update("list-tabs-response");
+        if (response.webappsActor) {
+          let front = new AppActorFront(this.connection.client,
+                                        response);
+          front.on("install-progress", this.onInstallProgress);
+          front.watchApps(() => this.checkIfProjectIsRunning())
+          .then(() => {
+            // This can't be done earlier as many operations
+            // in the apps actor require watchApps to be called
+            // first.
+            this._appsFront = front;
+            this._listTabsResponse = response;
+            this.update("list-tabs-response");
+            return front.fetchIcons();
+          })
+          .then(() => {
+            this.checkIfProjectIsRunning();
+            this.update("runtime-apps-found");
+          });
+        } else {
+          this._listTabsResponse = response;
+          this.update("list-tabs-response");
+        }
       });
     }
 
     this.update("connection");
   },
 
   get apps() {
     if (this._appsFront) {
@@ -411,29 +421,38 @@ let AppManager = exports.AppManager = {
     let app = this._getProjectFront(this.selectedProject);
     if (!app.running) {
       return app.launch();
     } else {
       return app.reload();
     }
   },
 
+  runtimeCanHandleApps: function() {
+    return !!this._appsFront;
+  },
+
   installAndRunProject: function() {
     let project = this.selectedProject;
 
     if (!project || (project.type != "packaged" && project.type != "hosted")) {
       console.error("Can't install project. Unknown type of project.");
       return promise.reject("Can't install");
     }
 
     if (!this._listTabsResponse) {
       this.reportError("error_cantInstallNotFullyConnected");
       return promise.reject("Can't install");
     }
 
+    if (!this._appsFront) {
+      console.error("Runtime doesn't have a webappsActor");
+      return promise.reject("Can't install");
+    }
+
     return Task.spawn(function* () {
       let self = AppManager;
 
       yield self.validateProject(project);
 
       if (project.errorsCount > 0) {
         self.reportError("error_cantInstallValidationErrors");
         return;
--- a/browser/devtools/webide/test/test_autoconnect_runtime.html
+++ b/browser/devtools/webide/test/test_autoconnect_runtime.html
@@ -24,17 +24,17 @@
           DebuggerServer.init(function () { return true; });
           DebuggerServer.addBrowserActors();
 
           let win = yield openWebIDE();
 
           let fakeRuntime = {
             type: "USB",
             connect: function(connection) {
-              ok(connection, win.AppManager.connection, "connection is valid");
+              is(connection, win.AppManager.connection, "connection is valid");
               connection.host = null; // force connectPipe
               connection.connect();
               return promise.resolve();
             },
 
             get id() {
               return "fakeRuntime";
             },
--- a/browser/devtools/webide/test/test_runtime.html
+++ b/browser/devtools/webide/test/test_runtime.html
@@ -46,17 +46,17 @@
             DebuggerServer.init(function () { return true; });
             DebuggerServer.addBrowserActors();
           }
 
           win = yield openWebIDE();
 
           win.AppManager.runtimeList.usb.push({
             connect: function(connection) {
-              ok(connection, win.AppManager.connection, "connection is valid");
+              is(connection, win.AppManager.connection, "connection is valid");
               connection.host = null; // force connectPipe
               connection.connect();
               return promise.resolve();
             },
 
             get name() {
               return "fakeRuntime";
             }
@@ -79,16 +79,18 @@
 
           items[0].click();
 
           ok(win.document.querySelector("window").className, "busy", "UI is busy");
           yield win.UI._busyPromise;
 
           is(Object.keys(DebuggerServer._connections).length, 1, "Connected");
 
+          yield waitForUpdate(win, "list-tabs-response");
+
           ok(isPlayActive(), "play button is enabled 1");
           ok(!isStopActive(), "stop button is disabled 1");
           let oldProject = win.AppManager.selectedProject;
           win.AppManager.selectedProject = null;
 
           yield nextTick();
 
           ok(!isPlayActive(), "play button is disabled 2");
--- a/browser/extensions/pdfjs/test/browser.ini
+++ b/browser/extensions/pdfjs/test/browser.ini
@@ -4,8 +4,10 @@ support-files = file_pdfjs_test.pdf
 
 [browser_pdfjs_main.js]
 skip-if = debug # bug 1058695
 [browser_pdfjs_navigation.js]
 skip-if = debug # bug 1058695
 [browser_pdfjs_savedialog.js]
 [browser_pdfjs_views.js]
 skip-if = debug # bug 1058695
+[browser_pdfjs_zoom.js]
+skip-if = debug # bug 1058695
new file mode 100644
--- /dev/null
+++ b/browser/extensions/pdfjs/test/browser_pdfjs_zoom.js
@@ -0,0 +1,173 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const RELATIVE_DIR = "browser/extensions/pdfjs/test/";
+const TESTROOT = "http://example.com/browser/" + RELATIVE_DIR;
+
+const TESTS = [
+  {
+    action: {
+      selector: "button#zoomIn",
+      event: "click"
+    },
+    expectedZoom: 1, // 1 - zoom in
+    message: "Zoomed in using the '+' (zoom in) button"
+  },
+
+  {
+    action: {
+      selector: "button#zoomOut",
+      event: "click"
+    },
+    expectedZoom: -1, // -1 - zoom out
+    message: "Zoomed out using the '-' (zoom out) button"
+  },
+
+  {
+    action: {
+      keyboard: true,
+      event: "+"
+    },
+    expectedZoom: 1, // 1 - zoom in
+    message: "Zoomed in using the CTRL++ keys"
+  },
+
+  {
+    action: {
+      keyboard: true,
+      event: "-"
+    },
+    expectedZoom: -1, // -1 - zoom out
+    message: "Zoomed out using the CTRL+- keys"
+  },
+
+  {
+    action: {
+      selector: "select#scaleSelect",
+      index: 5,
+      event: "change"
+    },
+    expectedZoom: -1, // -1 - zoom out
+    message: "Zoomed using the zoom picker"
+  }
+];
+
+let initialWidth; // the initial width of the PDF document
+var previousWidth; // the width of the PDF document at previous step/test
+
+function test() {
+  var tab;
+  let handlerService = Cc["@mozilla.org/uriloader/handler-service;1"]
+                       .getService(Ci.nsIHandlerService);
+  let mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+  let handlerInfo = mimeService.getFromTypeAndExtension('application/pdf', 'pdf');
+
+  // Make sure pdf.js is the default handler.
+  is(handlerInfo.alwaysAskBeforeHandling, false,
+     'pdf handler defaults to always-ask is false');
+  is(handlerInfo.preferredAction, Ci.nsIHandlerInfo.handleInternally,
+    'pdf handler defaults to internal');
+
+  info('Pref action: ' + handlerInfo.preferredAction);
+
+  waitForExplicitFinish();
+  registerCleanupFunction(function() {
+    gBrowser.removeTab(tab);
+  });
+
+  tab = gBrowser.selectedTab = gBrowser.addTab(TESTROOT + "file_pdfjs_test.pdf");
+  var newTabBrowser = gBrowser.getBrowserForTab(tab);
+
+  newTabBrowser.addEventListener("load", function eventHandler() {
+    newTabBrowser.removeEventListener("load", eventHandler, true);
+
+    var document = newTabBrowser.contentDocument,
+        window = newTabBrowser.contentWindow;
+
+    // Runs tests after all 'load' event handlers have fired off
+    window.addEventListener("documentload", function() {
+      initialWidth = parseInt(document.querySelector("div#pageContainer1").style.width);
+      previousWidth = initialWidth;
+      runTests(document, window, finish);
+    }, false, true);
+  }, true);
+}
+
+function runTests(document, window, callback) {
+  // check that PDF is opened with internal viewer
+  ok(document.querySelector('div#viewer'), "document content has viewer UI");
+  ok('PDFJS' in window.wrappedJSObject, "window content has PDFJS object");
+
+  // Start the zooming tests after the document is loaded
+  waitForDocumentLoad(document).then(function () {
+    zoomPDF(document, window, TESTS.shift(), finish);
+  });
+}
+
+function waitForDocumentLoad(document) {
+  var deferred = Promise.defer();
+  var interval = setInterval(function () {
+    if (document.querySelector("div#pageContainer1") != null){
+      clearInterval(interval);
+      deferred.resolve();
+    }
+  }, 500);
+
+  return deferred.promise;
+}
+
+function zoomPDF(document, window, test, endCallback) {
+  var renderedPage;
+
+  document.addEventListener("pagerendered", function onPageRendered(e) {
+    if(e.detail.pageNumber !== 1) {
+      return;
+    }
+
+    document.removeEventListener("pagerendered", onPageRendered, true);
+
+    var pageZoomScale = document.querySelector('select#scaleSelect');
+
+    // The zoom value displayed in the zoom select
+    var zoomValue = pageZoomScale.options[pageZoomScale.selectedIndex].innerHTML;
+
+    let pageContainer = document.querySelector('div#pageContainer1');
+    let actualWidth  = parseInt(pageContainer.style.width);
+
+    // the actual zoom of the PDF document
+    let computedZoomValue = parseInt(((actualWidth/initialWidth).toFixed(2))*100) + "%";
+    is(computedZoomValue, zoomValue, "Content has correct zoom");
+
+    // Check that document zooms in the expected way (in/out)
+    let zoom = (actualWidth - previousWidth) * test.expectedZoom;
+    ok(zoom > 0, test.message);
+
+    // Go to next test (if there is any) or finish
+    var nextTest = TESTS.shift();
+    if (nextTest) {
+      previousWidth = actualWidth;
+      zoomPDF(document, window, nextTest, endCallback);
+    }
+    else
+      endCallback();
+  }, true);
+
+  // We zoom using an UI element
+  if (test.action.selector) {
+    // Get the element and trigger the action for changing the zoom
+    var el = document.querySelector(test.action.selector);
+    ok(el, "Element '" + test.action.selector + "' has been found");
+
+    if (test.action.index){
+      el.selectedIndex = test.action.index;
+    }
+
+    // Dispatch the event for changing the zoom
+    el.dispatchEvent(new Event(test.action.event));
+  }
+  // We zoom using keyboard
+  else {
+    // Simulate key press
+    EventUtils.synthesizeKey(test.action.event, { ctrlKey: true });
+  }
+}
--- a/configure.in
+++ b/configure.in
@@ -8984,17 +8984,17 @@ if test "$ACCESSIBILITY" -a "$MOZ_ENABLE
     ATK_MAJOR_VERSION=`echo ${ATK_FULL_VERSION} | $AWK -F\. '{ print $1 }'`
     ATK_MINOR_VERSION=`echo ${ATK_FULL_VERSION} | $AWK -F\. '{ print $2 }'`
     ATK_REV_VERSION=`echo ${ATK_FULL_VERSION} | $AWK -F\. '{ print $3 }'`
     AC_DEFINE_UNQUOTED(ATK_MAJOR_VERSION, $ATK_MAJOR_VERSION)
     AC_DEFINE_UNQUOTED(ATK_MINOR_VERSION, $ATK_MINOR_VERSION)
     AC_DEFINE_UNQUOTED(ATK_REV_VERSION, $ATK_REV_VERSION)
 fi
 
-if test -z "$RELEASE_BUILD" -a -z "$NIGHTLY_BUILD"; then
+if test -n "$MOZ_DEV_EDITION"; then
     AC_DEFINE(MOZ_DEV_EDITION)
 fi
 
 if test "$MOZ_DEBUG"; then
     A11Y_LOG=1
 fi
 case "$MOZ_UPDATE_CHANNEL" in
 aurora|beta|release|esr)
--- a/dom/promise/Promise.cpp
+++ b/dom/promise/Promise.cpp
@@ -355,31 +355,37 @@ Promise::MaybeReject(JSContext* aCx,
   MaybeRejectInternal(aCx, aValue);
 }
 
 void
 Promise::MaybeReject(const nsRefPtr<MediaStreamError>& aArg) {
   MaybeSomething(aArg, &Promise::MaybeReject);
 }
 
-void
+bool
 Promise::PerformMicroTaskCheckpoint()
 {
   CycleCollectedJSRuntime* runtime = CycleCollectedJSRuntime::Get();
   nsTArray<nsRefPtr<nsIRunnable>>& microtaskQueue =
     runtime->GetPromiseMicroTaskQueue();
 
-  while (!microtaskQueue.IsEmpty()) {
+  if (microtaskQueue.IsEmpty()) {
+    return false;
+  }
+
+  do {
     nsRefPtr<nsIRunnable> runnable = microtaskQueue.ElementAt(0);
     MOZ_ASSERT(runnable);
 
     // This function can re-enter, so we remove the element before calling.
     microtaskQueue.RemoveElementAt(0);
     runnable->Run();
-  }
+  } while (!microtaskQueue.IsEmpty());
+
+  return true;
 }
 
 /* static */ bool
 Promise::JSCallback(JSContext* aCx, unsigned aArgc, JS::Value* aVp)
 {
   JS::CallArgs args = CallArgsFromVp(aArgc, aVp);
 
   JS::Rooted<JS::Value> v(aCx,
--- a/dom/promise/Promise.h
+++ b/dom/promise/Promise.h
@@ -116,17 +116,18 @@ public:
   // require use to include DOMError.h either here or in all those translation
   // units.
   template<typename T>
   void MaybeRejectBrokenly(const T& aArg); // Not implemented by default; see
                                            // specializations in the .cpp for
                                            // the T values we support.
 
   // Called by DOM to let us execute our callbacks.  May be called recursively.
-  static void PerformMicroTaskCheckpoint();
+  // Returns true if at least one microtask was processed.
+  static bool PerformMicroTaskCheckpoint();
 
   // WebIDL
 
   nsIGlobalObject* GetParentObject() const
   {
     return mGlobal;
   }
 
--- a/dom/workers/WorkerPrivate.cpp
+++ b/dom/workers/WorkerPrivate.cpp
@@ -4238,17 +4238,17 @@ WorkerPrivate::DoRunLoop(JSContext* aCx)
     // Start the periodic GC timer if it is not already running.
     SetGCTimerMode(PeriodicTimer);
 
     // Process a single runnable from the main queue.
     MOZ_ALWAYS_TRUE(NS_ProcessNextEvent(mThread, false));
 
     // Only perform the Promise microtask checkpoint on the outermost event
     // loop.  Don't run it, for example, during sync XHR or importScripts.
-    Promise::PerformMicroTaskCheckpoint();
+    (void)Promise::PerformMicroTaskCheckpoint();
 
     if (NS_HasPendingEvents(mThread)) {
       // Now *might* be a good time to GC. Let the JS engine make the decision.
       if (workerCompartment) {
         JS_MaybeGC(aCx);
       }
     }
     else {
--- a/js/xpconnect/src/nsXPConnect.cpp
+++ b/js/xpconnect/src/nsXPConnect.cpp
@@ -1003,33 +1003,50 @@ nsXPConnect::JSToVariant(JSContext* ctx,
     nsRefPtr<XPCVariant> variant = XPCVariant::newVariant(ctx, value);
     variant.forget(_retval);
     if (!(*_retval))
         return NS_ERROR_FAILURE;
 
     return NS_OK;
 }
 
+namespace {
+
+class DummyRunnable : public nsRunnable {
+public:
+    NS_IMETHOD Run() { return NS_OK; }
+};
+
+} // anonymous namespace
+
 NS_IMETHODIMP
 nsXPConnect::OnProcessNextEvent(nsIThreadInternal *aThread, bool aMayWait,
                                 uint32_t aRecursionDepth)
 {
+    MOZ_ASSERT(NS_IsMainThread());
+
     // If ProcessNextEvent was called during a Promise "then" callback, we
     // must process any pending microtasks before blocking in the event loop,
     // otherwise we may deadlock until an event enters the queue later.
     if (aMayWait) {
-        Promise::PerformMicroTaskCheckpoint();
+        if (Promise::PerformMicroTaskCheckpoint()) {
+            // If any microtask was processed, we post a dummy event in order to
+            // force the ProcessNextEvent call not to block.  This is required
+            // to support nested event loops implemented using a pattern like
+            // "while (condition) thread.processNextEvent(true)", in case the
+            // condition is triggered here by a Promise "then" callback.
+            NS_DispatchToMainThread(new DummyRunnable());
+        }
     }
 
     // Record this event.
     mEventDepth++;
 
     // Push a null JSContext so that we don't see any script during
     // event processing.
-    MOZ_ASSERT(NS_IsMainThread());
     bool ok = PushJSContextNoScriptContext(nullptr);
     NS_ENSURE_TRUE(ok, NS_ERROR_FAILURE);
     return NS_OK;
 }
 
 NS_IMETHODIMP
 nsXPConnect::AfterProcessNextEvent(nsIThreadInternal *aThread,
                                    uint32_t aRecursionDepth,
--- a/mobile/android/base/preferences/GeckoPreferences.java
+++ b/mobile/android/base/preferences/GeckoPreferences.java
@@ -898,19 +898,16 @@ OnSharedPreferenceChangeListener
        Intent intent = new Intent(ACTION_STUMBLER_UPLOAD_PREF)
                 .putExtra("pref", PREFS_GEO_REPORTING)
                 .putExtra("branch", GeckoSharedPrefs.APP_PREFS_NAME)
                 .putExtra("enabled", value)
                 .putExtra("moz_mozilla_api_key", AppConstants.MOZ_STUMBLER_API_KEY);
        if (GeckoAppShell.getGeckoInterface() != null) {
            intent.putExtra("user_agent", GeckoAppShell.getGeckoInterface().getDefaultUAString());
        }
-       if (!AppConstants.MOZILLA_OFFICIAL) {
-           intent.putExtra("is_debug", true);
-       }
        broadcastAction(context, intent);
     }
 
     /**
      * Broadcast the current value of the
      * <code>PREFS_GEO_REPORTING</code> pref.
      */
     public static void broadcastStumblerPref(final Context context) {
--- a/mobile/android/base/tests/robocop.ini
+++ b/mobile/android/base/tests/robocop.ini
@@ -141,8 +141,9 @@ skip-if = android_version == "10"
 [testNativeCrypto]
 [testSessionHistory]
 
 # testSelectionHandler disabled on Android 2.3 by trailing skip-if, due to bug 980074
 [testSelectionHandler]
 skip-if = android_version == "10"
 
 [testStumblerSetting]
+skip-if = android_version == "10"
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/AppGlobals.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/AppGlobals.java
@@ -2,17 +2,17 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.mozstumbler.service;
 
 import java.util.concurrent.ConcurrentLinkedQueue;
 
 public class AppGlobals {
-    public static final String LOG_PREFIX = "Stumbler:";
+    public static final String LOG_PREFIX = "Stumbler_";
 
     /* All intent actions start with this string. Only locally broadcasted. */
     public static final String ACTION_NAMESPACE = "org.mozilla.mozstumbler.intent.action";
 
     /* Handle this for logging reporter info. */
     public static final String ACTION_GUI_LOG_MESSAGE = AppGlobals.ACTION_NAMESPACE + ".LOG_MESSAGE";
     public static final String ACTION_GUI_LOG_MESSAGE_EXTRA = ACTION_GUI_LOG_MESSAGE + ".MESSAGE";
 
@@ -52,12 +52,20 @@ public class AppGlobals {
         if (guiLogMessageBuffer != null) {
             if (isBold) {
                 msg = "<b>" + msg + "</b>";
             }
             guiLogMessageBuffer.add("<font color='" + color +"'>" + msg + "</font>");
         }
     }
 
+    public static String makeLogTag(String name) {
+        final int maxLen = 23 - LOG_PREFIX.length();
+        if (name.length() > maxLen) {
+            name = name.substring(name.length() - maxLen, name.length());
+        }
+        return LOG_PREFIX + name;
+    }
+
     public static final String ACTION_TEST_SETTING_ENABLED = "stumbler-test-setting-enabled";
     public static final String ACTION_TEST_SETTING_DISABLED = "stumbler-test-setting-disabled";
 }
 
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/Prefs.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/Prefs.java
@@ -9,17 +9,17 @@ import android.annotation.TargetApi;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.location.Location;
 import android.os.Build.VERSION;
 import android.text.TextUtils;
 import android.util.Log;
 
 public  final class Prefs {
-    private static final String LOG_TAG = Prefs.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(Prefs.class.getSimpleName());
     private static final String NICKNAME_PREF = "nickname";
     private static final String USER_AGENT_PREF = "user-agent";
     private static final String VALUES_VERSION_PREF = "values_version";
     private static final String WIFI_ONLY = "wifi_only";
     private static final String LAT_PREF = "lat_pref";
     private static final String LON_PREF = "lon_pref";
     private static final String GEOFENCE_HERE = "geofence_here";
     private static final String GEOFENCE_SWITCH = "geofence_switch";
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/PassiveServiceReceiver.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/PassiveServiceReceiver.java
@@ -24,37 +24,41 @@ import org.mozilla.mozstumbler.service.s
  *    The StumblerService is where the enabled state is checked, and if not enabled, the
  *    service stops immediately.
  *
  * 3) Upload notification: onReceive intents are used to tell the StumblerService to check for upload.
  *    In the Fennec host app use, startup and pause are used as indicators to the StumblerService that now
  *    is a good time to try upload, as it is likely that the network is in use.
  */
 public class PassiveServiceReceiver extends BroadcastReceiver {
-    static final String LOG_TAG = AppGlobals.LOG_PREFIX + PassiveServiceReceiver.class.getSimpleName();
+    // This allows global debugging logs to be enabled by doing
+    // |adb shell setprop log.tag.PassiveStumbler DEBUG|
+    static final String LOG_TAG = "PassiveStumbler";
 
     @Override
     public void onReceive(Context context, Intent intent) {
         if (intent == null) {
             return;
         }
 
+        // This value is cached, so if |setprop| is performed (as described on the LOG_TAG above),
+        // then the start/stop intent must be resent by toggling the setting or stopping/starting Fennec.
+        // This does not guard against dumping PII (PII in stumbler is location, wifi BSSID, cell tower details).
+        AppGlobals.isDebug = Log.isLoggable(LOG_TAG, Log.DEBUG);
+
         final String action = intent.getAction();
         final boolean isIntentFromHostApp = (action != null) && action.contains(".STUMBLER_PREF");
         if (!isIntentFromHostApp) {
             Log.d(LOG_TAG, "Stumbler: received intent external to host app");
             Intent startServiceIntent = new Intent(context, StumblerService.class);
             startServiceIntent.putExtra(StumblerService.ACTION_NOT_FROM_HOST_APP, true);
             context.startService(startServiceIntent);
             return;
         }
 
-        if (intent.hasExtra("is_debug")) {
-            AppGlobals.isDebug = intent.getBooleanExtra("is_debug", false);
-        }
         StumblerService.sFirefoxStumblingEnabled.set(intent.getBooleanExtra("enabled", false));
 
         if (!StumblerService.sFirefoxStumblingEnabled.get()) {
             // This calls the service's onDestroy(), and the service's onHandleIntent(...) is not called
             context.stopService(new Intent(context, StumblerService.class));
             // For testing service messages were received
             context.sendBroadcast(new Intent(AppGlobals.ACTION_TEST_SETTING_DISABLED));
             return;
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/Reporter.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/Reporter.java
@@ -25,17 +25,17 @@ import org.mozilla.mozstumbler.service.s
 import org.mozilla.mozstumbler.service.stumblerthread.datahandling.DataStorageManager;
 import org.mozilla.mozstumbler.service.stumblerthread.datahandling.StumblerBundle;
 import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellInfo;
 import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellScanner;
 import org.mozilla.mozstumbler.service.stumblerthread.scanners.GPSScanner;
 import org.mozilla.mozstumbler.service.stumblerthread.scanners.WifiScanner;
 
 public final class Reporter extends BroadcastReceiver {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + Reporter.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(Reporter.class.getSimpleName());
     public static final String ACTION_FLUSH_TO_BUNDLE = AppGlobals.ACTION_NAMESPACE + ".FLUSH";
     private boolean mIsStarted;
 
     /* The maximum number of Wi-Fi access points in a single observation. */
     private static final int MAX_WIFIS_PER_LOCATION = 200;
 
     /* The maximum number of cells in a single observation */
     private static final int MAX_CELLS_PER_LOCATION  = 50;
@@ -190,17 +190,18 @@ public final class Reporter extends Broa
             cellCount = mlsObj.getInt(DataStorageContract.ReportsColumns.CELL_COUNT);
 
         } catch (JSONException e) {
             Log.w(LOG_TAG, "Failed to convert bundle to JSON: " + e);
             return;
         }
 
         if (AppGlobals.isDebug) {
-            Log.d(LOG_TAG, "Received bundle: " + mlsObj.toString());
+            // PII: do not log the bundle without obfuscating it
+            Log.d(LOG_TAG, "Received bundle");
         }
 
         if (wifiCount + cellCount < 1) {
             return;
         }
 
         try {
             DataStorageManager.getInstance().insert(mlsObj.toString(), wifiCount, cellCount);
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/StumblerService.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/StumblerService.java
@@ -22,17 +22,17 @@ import org.mozilla.mozstumbler.service.u
 import org.mozilla.mozstumbler.service.utils.NetworkUtils;
 import org.mozilla.mozstumbler.service.utils.PersistentIntentService;
 
 // In stand-alone service mode (a.k.a passive scanning mode), this is created from PassiveServiceReceiver (by calling startService).
 // The StumblerService is a sticky unbound service in this usage.
 //
 public class StumblerService extends PersistentIntentService
         implements DataStorageManager.StorageIsEmptyTracker {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + StumblerService.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(StumblerService.class.getSimpleName());
     public static final String ACTION_BASE = AppGlobals.ACTION_NAMESPACE;
     public static final String ACTION_START_PASSIVE = ACTION_BASE + ".START_PASSIVE";
     public static final String ACTION_EXTRA_MOZ_API_KEY = ACTION_BASE + ".MOZKEY";
     public static final String ACTION_EXTRA_USER_AGENT = ACTION_BASE + ".USER_AGENT";
     public static final String ACTION_NOT_FROM_HOST_APP = ACTION_BASE + ".NOT_FROM_HOST";
     public static final AtomicBoolean sFirefoxStumblingEnabled = new AtomicBoolean();
     protected final ScanManager mScanManager = new ScanManager();
     protected final Reporter mReporter = new Reporter();
@@ -139,16 +139,18 @@ public class StumblerService extends Per
         setIntentRedelivery(true);
     }
 
     // Called from the main thread
     @Override
     public void onDestroy() {
         super.onDestroy();
 
+        UploadAlarmReceiver.cancelAlarm(this, !mScanManager.isPassiveMode());
+
         if (!mScanManager.isScanning()) {
             return;
         }
 
         // Used to move these disk I/O ops off the calling thread. The current operations here are synchronized,
         // however instead of creating another thread (if onDestroy grew to have concurrency complications)
         // we could be messaging the stumbler thread to perform a shutdown function.
         new AsyncTask<Void, Void, Void>() {
@@ -193,17 +195,21 @@ public class StumblerService extends Per
 
         final boolean isScanEnabledInPrefs = Prefs.getInstance().getFirefoxScanEnabled();
 
         if (!isScanEnabledInPrefs && intent.getBooleanExtra(ACTION_NOT_FROM_HOST_APP, false)) {
             stopSelf();
             return;
         }
 
-        if (!DataStorageManager.getInstance().isDirEmpty()) {
+        boolean hasFilesWaiting = !DataStorageManager.getInstance().isDirEmpty();
+        if (AppGlobals.isDebug) {
+            Log.d(LOG_TAG, "Files waiting:" + hasFilesWaiting);
+        }
+        if (hasFilesWaiting) {
             // non-empty on startup, schedule an upload
             // This is the only upload trigger in Firefox mode
             // Firefox triggers this ~4 seconds after startup (after Gecko is loaded), add a small delay to avoid
             // clustering with other operations that are triggered at this time.
             final long lastAttemptedTime = Prefs.getInstance().getLastAttemptedUploadTime();
             final long timeNow = System.currentTimeMillis();
 
             if (timeNow - lastAttemptedTime < PASSIVE_UPLOAD_FREQ_GUARD_MSEC) {
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/BSSIDBlockList.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/BSSIDBlockList.java
@@ -6,17 +6,17 @@ package org.mozilla.mozstumbler.service.
 
 import android.net.wifi.ScanResult;
 import android.util.Log;
 import org.mozilla.mozstumbler.service.AppGlobals;
 import java.util.Locale;
 import java.util.regex.Pattern;
 
 public final class BSSIDBlockList {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + BSSIDBlockList.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(BSSIDBlockList.class.getSimpleName());
     private static final String NULL_BSSID = "000000000000";
     private static final String WILDCARD_BSSID = "ffffffffffff";
     private static final Pattern BSSID_PATTERN = Pattern.compile("([0-9a-f]{12})");
     private static String[] sOuiList = new String[]{};
 
     private BSSIDBlockList() {
     }
 
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageManager.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageManager.java
@@ -33,17 +33,17 @@ import java.util.TimerTask;
  *
  * If the network is reasonably active, and reporting is slow enough, there is no disk I/O, it all happens
  * in-memory.
  *
  * Also of note: the in-memory buffers (both mCurrentReports and mCurrentReportsSendBuffer) are saved
  * when the service is destroyed.
  */
 public class DataStorageManager {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + DataStorageManager.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(DataStorageManager.class.getSimpleName());
 
     // The max number of reports stored in the mCurrentReports. Each report is a GPS location plus wifi and cell scan.
     // After this size is reached, data is persisted to disk, mCurrentReports is cleared.
     private static final int MAX_REPORTS_IN_MEMORY = 50;
 
     // Used to cap the amount of data stored. When this limit is hit, no more data is saved to disk
     // until the data is uploaded, or and data exceeds DEFAULT_MAX_WEEKS_DATA_ON_DISK.
     private static final long DEFAULT_MAX_BYTES_STORED_ON_DISK = 1024 * 250; // 250 KiB max by default
@@ -196,29 +196,17 @@ public class DataStorageManager {
         public final ReportFileList fileList;
     }
 
     public interface StorageIsEmptyTracker {
         public void notifyStorageStateEmpty(boolean isEmpty);
     }
 
     private String getStorageDir(Context c) {
-        File dir = null;
-        if (AppGlobals.isDebug) {
-            // in debug, put files in public location
-            dir = c.getExternalFilesDir(null);
-            if (dir != null) {
-                dir = new File(dir.getAbsolutePath() + "/mozstumbler");
-            }
-        }
-
-        if (dir == null) {
-            dir = c.getFilesDir();
-        }
-
+        File dir = c.getFilesDir();
         if (!dir.exists()) {
             boolean ok = dir.mkdirs();
             if (!ok) {
                 Log.d(LOG_TAG, "getStorageDir: error in mkdirs()");
             }
         }
 
         return dir.getPath();
@@ -409,19 +397,16 @@ public class DataStorageManager {
         if (reports != null) {
             for(String s: reports) {
                 sb.append(sep).append(s);
                 sep = separator;
             }
         }
 
         final String result = sb.append(kSuffix).toString();
-        if (AppGlobals.isDebug) {
-            Log.d(LOG_TAG, result);
-        }
         return result;
     }
 
     public synchronized void saveCurrentReportsToDisk() throws IOException {
         saveCurrentReportsSendBufferToDisk();
         if (mCurrentReports.reports.size() < 1) {
             return;
         }
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/GPSScanner.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/GPSScanner.java
@@ -29,17 +29,17 @@ public class GPSScanner implements Locat
     public static final String ACTION_ARG_TIME = AppGlobals.ACTION_ARG_TIME;
     public static final String SUBJECT_NEW_STATUS = "new_status";
     public static final String SUBJECT_LOCATION_LOST = "location_lost";
     public static final String SUBJECT_NEW_LOCATION = "new_location";
     public static final String NEW_STATUS_ARG_FIXES = "fixes";
     public static final String NEW_STATUS_ARG_SATS = "sats";
     public static final String NEW_LOCATION_ARG_LOCATION = "location";
 
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + GPSScanner.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(GPSScanner.class.getSimpleName());
     private static final int MIN_SAT_USED_IN_FIX = 3;
     private static final long ACTIVE_MODE_GPS_MIN_UPDATE_TIME_MS = 1000;
     private static final float ACTIVE_MODE_GPS_MIN_UPDATE_DISTANCE_M = 10;
     private static final long PASSIVE_GPS_MIN_UPDATE_FREQ_MS = 3000;
     private static final float PASSIVE_GPS_MOVEMENT_MIN_DELTA_M = 30;
 
     private final LocationBlockList mBlockList = new LocationBlockList();
     private final Context mContext;
@@ -186,25 +186,20 @@ public class GPSScanner implements Locat
         Date date = new Date(location.getTime());
         SimpleDateFormat formatter = new SimpleDateFormat("HH:mm:ss");
         String time = formatter.format(date);
         logMsg += String.format("%s Coord: %.4f,%.4f, Acc: %.0f, Speed: %.0f, Alt: %.0f, Bearing: %.1f", time, location.getLatitude(),
                 location.getLongitude(), location.getAccuracy(), location.getSpeed(), location.getAltitude(), location.getBearing());
         sendToLogActivity(logMsg);
 
         if (mBlockList.contains(location)) {
-            Log.w(LOG_TAG, "Blocked location: " + location);
             reportLocationLost();
             return;
         }
 
-        if (AppGlobals.isDebug) {
-            Log.d(LOG_TAG, "New location: " + location);
-        }
-
         mLocation = location;
 
         if (!mAutoGeofencing) {
             reportNewLocationReceived(location);
         }
         mLocationCount++;
 
         if (mIsPassiveMode) {
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/LocationBlockList.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/LocationBlockList.java
@@ -5,17 +5,17 @@
 package org.mozilla.mozstumbler.service.stumblerthread.scanners;
 
 import android.location.Location;
 import android.util.Log;
 import org.mozilla.mozstumbler.service.AppGlobals;
 import org.mozilla.mozstumbler.service.Prefs;
 
 public final class LocationBlockList {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + LocationBlockList.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(LocationBlockList.class.getSimpleName());
     private static final double MAX_ALTITUDE = 8848;      // Mount Everest's altitude in meters
     private static final double MIN_ALTITUDE = -418;      // Dead Sea's altitude in meters
     private static final float MAX_SPEED = 340.29f;   // Mach 1 in meters/second
     private static final float MIN_ACCURACY = 500;       // meter radius
     private static final long MIN_TIMESTAMP = 946684801; // 2000-01-01 00:00:01
     private static final double GEOFENCE_RADIUS = 0.01;      // .01 degrees is approximately 1km
     private static final long MILLISECONDS_PER_DAY = 86400000;
 
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/ScanManager.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/ScanManager.java
@@ -18,17 +18,17 @@ import org.mozilla.mozstumbler.service.s
 import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellScanner;
 import org.mozilla.mozstumbler.service.AppGlobals.ActiveOrPassiveStumbling;
 
 import java.util.Date;
 import java.util.Timer;
 import java.util.TimerTask;
 
 public class ScanManager {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + ScanManager.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(ScanManager.class.getSimpleName());
     private Timer mPassiveModeFlushTimer;
     private Context mContext;
     private boolean mIsScanning;
     private GPSScanner mGPSScanner;
     private WifiScanner mWifiScanner;
     private CellScanner mCellScanner;
     private ActiveOrPassiveStumbling mStumblingMode = ActiveOrPassiveStumbling.ACTIVE_STUMBLING;
 
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/WifiScanner.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/WifiScanner.java
@@ -35,17 +35,17 @@ public class WifiScanner extends Broadca
     public static final String ACTION_WIFIS_SCANNED = ACTION_BASE + "WIFIS_SCANNED";
     public static final String ACTION_WIFIS_SCANNED_ARG_RESULTS = "scan_results";
     public static final String ACTION_WIFIS_SCANNED_ARG_TIME = AppGlobals.ACTION_ARG_TIME;
 
     public static final int STATUS_IDLE = 0;
     public static final int STATUS_ACTIVE = 1;
     public static final int STATUS_WIFI_DISABLED = -1;
 
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + WifiScanner.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(WifiScanner.class.getSimpleName());
     private static final long WIFI_MIN_UPDATE_TIME = 5000; // milliseconds
 
     private boolean mStarted;
     private final Context mContext;
     private WifiLock mWifiLock;
     private Timer mWifiScanTimer;
     private final Set<String> mAPs = Collections.synchronizedSet(new HashSet<String>());
     private final AtomicInteger mVisibleAPs = new AtomicInteger();
@@ -189,21 +189,19 @@ public class WifiScanner extends Broadca
         mWifiScanTimer.cancel();
         mWifiScanTimer = null;
 
         mVisibleAPs.set(0);
     }
 
     public static boolean shouldLog(ScanResult scanResult) {
         if (BSSIDBlockList.contains(scanResult)) {
-            Log.w(LOG_TAG, "Blocked BSSID: " + scanResult);
             return false;
         }
         if (SSIDBlockList.contains(scanResult)) {
-            Log.w(LOG_TAG, "Blocked SSID: " + scanResult);
             return false;
         }
         return true;
     }
 
     private WifiManager getWifiManager() {
         return (WifiManager) mContext.getSystemService(Context.WIFI_SERVICE);
     }
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellInfo.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellInfo.java
@@ -14,17 +14,17 @@ import android.telephony.cdma.CdmaCellLo
 import android.telephony.gsm.GsmCellLocation;
 import android.util.Log;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.mozstumbler.service.AppGlobals;
 
 public class CellInfo implements Parcelable {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + CellInfo.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(CellInfo.class.getSimpleName());
 
     public static final String RADIO_GSM = "gsm";
     public static final String RADIO_CDMA = "cdma";
     public static final String RADIO_WCDMA = "wcdma";
 
     public static final String CELL_RADIO_GSM = "gsm";
     public static final String CELL_RADIO_UMTS = "umts";
     public static final String CELL_RADIO_CDMA = "cdma";
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScanner.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScanner.java
@@ -21,17 +21,17 @@ import org.mozilla.mozstumbler.service.A
 
 
 public class CellScanner {
     public static final String ACTION_BASE = AppGlobals.ACTION_NAMESPACE + ".CellScanner.";
     public static final String ACTION_CELLS_SCANNED = ACTION_BASE + "CELLS_SCANNED";
     public static final String ACTION_CELLS_SCANNED_ARG_CELLS = "cells";
     public static final String ACTION_CELLS_SCANNED_ARG_TIME = AppGlobals.ACTION_ARG_TIME;
 
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + CellScanner.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(CellScanner.class.getSimpleName());
     private static final long CELL_MIN_UPDATE_TIME = 1000; // milliseconds
 
     private final Context mContext;
     private static CellScannerImpl sImpl;
     private Timer mCellScanTimer;
     private final Set<String> mCells = new HashSet<String>();
     private int mCurrentCellInfoCount;
 
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScannerNoWCDMA.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScannerNoWCDMA.java
@@ -26,17 +26,17 @@ import org.mozilla.mozstumbler.service.A
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 
 /* Fennec does not yet support the api level for WCDMA import */
 public class CellScannerNoWCDMA implements CellScanner.CellScannerImpl {
 
-    protected static String LOG_TAG = AppGlobals.LOG_PREFIX + CellScannerNoWCDMA.class.getSimpleName();
+    protected static String LOG_TAG = AppGlobals.makeLogTag(CellScannerNoWCDMA.class.getSimpleName());
     protected GetAllCellInfoScannerImpl mGetAllInfoCellScanner;
     protected TelephonyManager mTelephonyManager;
     protected boolean mIsStarted;
     protected int mPhoneType;
     protected final Context mContext;
     protected volatile int mSignalStrength;
     protected volatile int mCdmaDbm;
 
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/AsyncUploader.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/AsyncUploader.java
@@ -19,17 +19,17 @@ import org.mozilla.mozstumbler.service.u
 /* Only one at a time may be uploading. If executed while another upload is in progress
 * it will return immediately, and SyncResult is null.
 *
 * Threading:
 * Uploads on a separate thread. ONLY DataStorageManager is thread-safe, do not call
 * preferences, do not call any code that isn't thread-safe. You will cause suffering.
 * An exception is made for AppGlobals.isDebug, a false reading is of no consequence. */
 public class AsyncUploader extends AsyncTask<Void, Void, SyncSummary> {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + AsyncUploader.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(AsyncUploader.class.getSimpleName());
     private final UploadSettings mSettings;
     private final Object mListenerLock = new Object();
     private AsyncUploaderListener mListener;
     private static final AtomicBoolean sIsUploading = new AtomicBoolean();
     private String mNickname;
 
     public interface AsyncUploaderListener {
         public void onUploadComplete(SyncSummary result);
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/UploadAlarmReceiver.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/UploadAlarmReceiver.java
@@ -25,17 +25,17 @@ import org.mozilla.mozstumbler.service.u
 // 2) Changing the pref in Fennec to stumble or not.
 // 3) Boot intent (and SD card app available intent).
 //
 // Threading:
 // - scheduled from the stumbler thread
 // - triggered from the main thread
 // - actual work is done the upload thread (AsyncUploader)
 public class UploadAlarmReceiver extends BroadcastReceiver {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + UploadAlarmReceiver.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(UploadAlarmReceiver.class.getSimpleName());
     private static final String EXTRA_IS_REPEATING = "is_repeating";
     private static boolean sIsAlreadyScheduled;
 
     public UploadAlarmReceiver() {}
 
     public static class UploadAlarmService extends IntentService {
 
         public UploadAlarmService(String name) {
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/AbstractCommunicator.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/AbstractCommunicator.java
@@ -15,17 +15,17 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.HttpURLConnection;
 import java.net.MalformedURLException;
 import java.net.URL;
 
 public abstract class AbstractCommunicator {
 
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + AbstractCommunicator.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(AbstractCommunicator.class.getSimpleName());
     private static final String NICKNAME_HEADER = "X-Nickname";
     private static final String USER_AGENT_HEADER = "User-Agent";
     private HttpURLConnection mHttpURLConnection;
     private final String mUserAgent;
     private static int sBytesSentTotal = 0;
     private static String sMozApiKey;
 
     public abstract String getUrlString();
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/NetworkUtils.java
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/NetworkUtils.java
@@ -6,17 +6,17 @@ package org.mozilla.mozstumbler.service.
 
 import android.content.Context;
 import android.net.ConnectivityManager;
 import android.net.NetworkInfo;
 import android.util.Log;
 import org.mozilla.mozstumbler.service.AppGlobals;
 
 public final class NetworkUtils {
-    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + NetworkUtils.class.getSimpleName();
+    private static final String LOG_TAG = AppGlobals.makeLogTag(NetworkUtils.class.getSimpleName());
 
     ConnectivityManager mConnectivityManager;
     static NetworkUtils sInstance;
 
     /* Created at startup by app, or service, using a context. */
     static public void createGlobalInstance(Context context) {
         sInstance = new NetworkUtils();
         sInstance.mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -5851,16 +5851,21 @@
     "kind": "boolean",
     "description": "How many times has the devtool's JS Profiler been opened?"
   },
   "DEVTOOLS_NETMONITOR_OPENED_BOOLEAN": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "How many times has the devtool's Network Monitor been opened?"
   },
+  "DEVTOOLS_STORAGE_OPENED_BOOLEAN": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "How many times has the Storage Inspector been opened?"
+  },
   "DEVTOOLS_PAINTFLASHING_OPENED_BOOLEAN": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "How many times has the devtool's Paint Flashing been opened via the toolbox button?"
   },
   "DEVTOOLS_TILT_OPENED_BOOLEAN": {
     "expires_in_version": "never",
     "kind": "boolean",
@@ -5971,16 +5976,21 @@
     "kind": "flag",
     "description": "How many users have opened the devtool's JS Profiler?"
   },
   "DEVTOOLS_NETMONITOR_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
     "description": "How many users have opened the devtool's Network Monitor?"
   },
+  "DEVTOOLS_STORAGE_OPENED_PER_USER_FLAG": {
+    "expires_in_version": "never",
+    "kind": "flag",
+    "description": "How many users have opened the devtool's Storage Inspector?"
+  },
   "DEVTOOLS_PAINTFLASHING_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
     "description": "How many users have opened the devtool's Paint Flashing been opened via the toolbox button?"
   },
   "DEVTOOLS_TILT_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
@@ -6125,16 +6135,23 @@
   },
   "DEVTOOLS_NETMONITOR_TIME_ACTIVE_SECONDS": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "10000000",
     "n_buckets": 100,
     "description": "How long has the network monitor been active (seconds)"
   },
+  "DEVTOOLS_STORAGE_TIME_ACTIVE_SECONDS": {
+    "expires_in_version": "never",
+    "kind": "exponential",
+    "high": "10000000",
+    "n_buckets": 100,
+    "description": "How long has the storage inspector been active (seconds)"
+  },
   "DEVTOOLS_PAINTFLASHING_TIME_ACTIVE_SECONDS": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "10000000",
     "n_buckets": 100,
     "description": "How long has paint flashing been active (seconds)"
   },
   "DEVTOOLS_TILT_TIME_ACTIVE_SECONDS": {