Merge fx-team to m-c. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Mon, 22 Sep 2014 14:57:14 -0400
changeset 206492 d8688cafc7529ede0593202ed85cda9c2929a4c7
parent 206466 af668f4b96c80eb5ad277ad9316c54916eaf2879 (current diff)
parent 206491 fac90451603fbf142fdafec8cebce4da986a1461 (diff)
child 206544 f4037194394efcad03d5076860387bcf32ff98a0
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersmerge
milestone35.0a1
Merge fx-team to m-c. a=merge
browser/devtools/webaudioeditor/webaudioeditor-controller.js
browser/devtools/webaudioeditor/webaudioeditor-view.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -692,22 +692,25 @@ pref("plugin.state.npciscowebcommunicato
 pref("plugin.state.ciscowebcommunicator", 2);
 #endif
 
 // McAfee Security Scanner detection plugin, bug 980772
 #ifdef XP_WIN
 pref("plugin.state.npmcafeemss", 2);
 #endif
 
-// Cisco VGConnect for directv.com, bug 981403
+// Cisco VGConnect for directv.com, bug 981403 & bug 1051772
 #ifdef XP_WIN
 pref("plugin.state.npplayerplugin", 2);
 #endif
 #ifdef XP_MACOSX
 pref("plugin.state.playerplugin", 2);
+pref("plugin.state.playerplugin.dtv", 2);
+pref("plugin.state.playerplugin.ciscodrm", 2);
+pref("plugin.state.playerplugin.charter", 2);
 #endif
 
 // Cisco Jabber Client, bug 981905
 #ifdef XP_WIN
 pref("plugin.state.npchip", 2);
 #endif
 #ifdef XP_MACOSX
 pref("plugin.state.cisco jabber guest plug-in", 2);
@@ -838,16 +841,25 @@ pref("plugin.state.np_prsnl", 2);
 #endif
 #ifdef XP_MACOSX
 pref("plugin.state.personalplugin", 2);
 #endif
 #ifdef UNIX_BUT_NOT_MAC
 pref("plugin.state.libplugins", 2);
 #endif
 
+// Novell iPrint Client, bug 1036693
+#ifdef XP_WIN
+pref("plugin.state.npnipp", 2);
+pref("plugin.state.npnisp", 2);
+#endif
+#ifdef XP_MACOSX
+pref("plugin.state.iprint", 2);
+#endif
+
 #ifdef XP_MACOSX
 pref("browser.preferences.animateFadeIn", true);
 #else
 pref("browser.preferences.animateFadeIn", false);
 #endif
 
 // Toggles between the two Preferences implementations, pop-up window and in-content
 #ifdef NIGHTLY_BUILD
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -109,17 +109,17 @@ loop.Client = (function($) {
      */
     _requestCallUrlInternal: function(nickname, cb) {
       var sessionType;
       if (this.mozLoop.userProfile) {
         sessionType = this.mozLoop.LOOP_SESSION_TYPE.FXA;
       } else {
         sessionType = this.mozLoop.LOOP_SESSION_TYPE.GUEST;
       }
-      
+
       this.mozLoop.hawkRequest(sessionType, "/call-url/", "POST",
                                {callerId: nickname},
         function (error, responseText) {
           if (error) {
             this._telemetryAdd("LOOP_CLIENT_CALL_URL_REQUESTS_SUCCESS", false);
             this._failureHandler(cb, error);
             return;
           }
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -401,16 +401,17 @@ loop.conversation = (function(OT, mozL10
         return;
       }
 
       var callType = this._conversation.get("selectedCallType");
       var videoStream = callType === "audio" ? false : true;
 
       /*jshint newcap:false*/
       this.loadReactComponent(sharedViews.ConversationView({
+        initiate: true,
         sdk: OT,
         model: this._conversation,
         video: {enabled: videoStream}
       }));
     },
 
     /**
      * Handles a error starting the session
@@ -435,17 +436,18 @@ loop.conversation = (function(OT, mozL10
       var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
         product: navigator.mozLoop.getLoopCharPref("feedback.product"),
         platform: appVersionInfo.OS,
         channel: appVersionInfo.channel,
         version: appVersionInfo.version
       });
 
       this.loadReactComponent(sharedViews.FeedbackView({
-        feedbackApiClient: feedbackClient
+        feedbackApiClient: feedbackClient,
+        onAfterFeedbackReceived: window.close.bind(window)
       }));
     }
   });
 
   /**
    * Panel initialisation.
    */
   function init() {
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -401,16 +401,17 @@ loop.conversation = (function(OT, mozL10
         return;
       }
 
       var callType = this._conversation.get("selectedCallType");
       var videoStream = callType === "audio" ? false : true;
 
       /*jshint newcap:false*/
       this.loadReactComponent(sharedViews.ConversationView({
+        initiate: true,
         sdk: OT,
         model: this._conversation,
         video: {enabled: videoStream}
       }));
     },
 
     /**
      * Handles a error starting the session
@@ -435,17 +436,18 @@ loop.conversation = (function(OT, mozL10
       var feedbackClient = new loop.FeedbackAPIClient(feebackAPIBaseUrl, {
         product: navigator.mozLoop.getLoopCharPref("feedback.product"),
         platform: appVersionInfo.OS,
         channel: appVersionInfo.channel,
         version: appVersionInfo.version
       });
 
       this.loadReactComponent(sharedViews.FeedbackView({
-        feedbackApiClient: feedbackClient
+        feedbackApiClient: feedbackClient,
+        onAfterFeedbackReceived: window.close.bind(window)
       }));
     }
   });
 
   /**
    * Panel initialisation.
    */
   function init() {
--- a/browser/components/loop/content/shared/js/feedbackApiClient.js
+++ b/browser/components/loop/content/shared/js/feedbackApiClient.js
@@ -46,17 +46,18 @@ loop.FeedbackAPIClient = (function($, _)
      */
     _supportedFields: ["happy",
                        "category",
                        "description",
                        "product",
                        "platform",
                        "version",
                        "channel",
-                       "user_agent"],
+                       "user_agent",
+                       "url"],
 
     /**
      * Creates a formatted payload object compliant with the Feedback API spec
      * against validated field data.
      *
      * @param  {Object} fields Feedback initial values.
      * @return {Object}        Formatted payload object.
      * @throws {Error}         If provided values are invalid
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -25,35 +25,37 @@ loop.shared.views = (function(_, OT, l10
    * - {Function} action  Function to be executed on click.
    * - {Enabled}  enabled Stream activation status (default: true).
    */
   var MediaControlButton = React.createClass({displayName: 'MediaControlButton',
     propTypes: {
       scope: React.PropTypes.string.isRequired,
       type: React.PropTypes.string.isRequired,
       action: React.PropTypes.func.isRequired,
-      enabled: React.PropTypes.bool.isRequired
+      enabled: React.PropTypes.bool.isRequired,
+      visible: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
-      return {enabled: true};
+      return {enabled: true, visible: true};
     },
 
     handleClick: function() {
       this.props.action();
     },
 
     _getClasses: function() {
       var cx = React.addons.classSet;
       // classes
       var classesObj = {
         "btn": true,
         "media-control": true,
         "local-media": this.props.scope === "local",
-        "muted": !this.props.enabled
+        "muted": !this.props.enabled,
+        "hide": !this.props.visible
       };
       classesObj["btn-mute-" + this.props.type] = true;
       return cx(classesObj);
     },
 
     _getTitle: function(enabled) {
       var prefix = this.props.enabled ? "mute" : "unmute";
       var suffix = "button_title";
@@ -73,18 +75,18 @@ loop.shared.views = (function(_, OT, l10
   });
 
   /**
    * Conversation controls.
    */
   var ConversationToolbar = React.createClass({displayName: 'ConversationToolbar',
     getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     propTypes: {
       video: React.PropTypes.object.isRequired,
       audio: React.PropTypes.object.isRequired,
       hangup: React.PropTypes.func.isRequired,
       publishStream: React.PropTypes.func.isRequired
@@ -98,97 +100,108 @@ loop.shared.views = (function(_, OT, l10
       this.props.publishStream("video", !this.props.video.enabled);
     },
 
     handleToggleAudio: function() {
       this.props.publishStream("audio", !this.props.audio.enabled);
     },
 
     render: function() {
-      /* jshint ignore:start */
+      var cx = React.addons.classSet;
       return (
         React.DOM.ul({className: "conversation-toolbar"}, 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             React.DOM.button({className: "btn btn-hangup", onClick: this.handleClickHangup, 
                     title: l10n.get("hangup_button_title")}, 
               l10n.get("hangup_button_caption2")
             )
           ), 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             MediaControlButton({action: this.handleToggleVideo, 
                                 enabled: this.props.video.enabled, 
+                                visible: this.props.video.visible, 
                                 scope: "local", type: "video"})
           ), 
           React.DOM.li({className: "conversation-toolbar-btn-box"}, 
             MediaControlButton({action: this.handleToggleAudio, 
                                 enabled: this.props.audio.enabled, 
+                                visible: this.props.audio.visible, 
                                 scope: "local", type: "audio"})
           )
         )
       );
-      /* jshint ignore:end */
     }
   });
 
+  /**
+   * Conversation view.
+   */
   var ConversationView = React.createClass({displayName: 'ConversationView',
     mixins: [Backbone.Events],
 
     propTypes: {
       sdk: React.PropTypes.object.isRequired,
-      model: React.PropTypes.object.isRequired
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object,
+      initiate: React.PropTypes.bool
     },
 
     // height set to 100%" to fix video layout on Google Chrome
     // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
     publisherConfig: {
       insertMode: "append",
       width: "100%",
       height: "100%",
       style: {
         bugDisplayMode: "off",
         buttonDisplayMode: "off",
         nameDisplayMode: "off"
       }
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        initiate: true,
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     getInitialState: function() {
       return {
         video: this.props.video,
         audio: this.props.audio
       };
     },
 
     componentWillMount: function() {
-      this.publisherConfig.publishVideo = this.props.video.enabled;
+      if (this.props.initiate) {
+        this.publisherConfig.publishVideo = this.props.video.enabled;
+      }
     },
 
     componentDidMount: function() {
-      this.listenTo(this.props.model, "session:connected",
-                                      this.startPublishing);
-      this.listenTo(this.props.model, "session:stream-created",
-                                      this._streamCreated);
-      this.listenTo(this.props.model, ["session:peer-hungup",
-                                       "session:network-disconnected",
-                                       "session:ended"].join(" "),
-                                       this.stopPublishing);
-
-      this.props.model.startSession();
+      if (this.props.initiate) {
+        this.listenTo(this.props.model, "session:connected",
+                                        this.startPublishing);
+        this.listenTo(this.props.model, "session:stream-created",
+                                        this._streamCreated);
+        this.listenTo(this.props.model, ["session:peer-hungup",
+                                         "session:network-disconnected",
+                                         "session:ended"].join(" "),
+                                         this.stopPublishing);
+        this.props.model.startSession();
+      }
 
       /**
        * 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);
     },
 
     updateVideoContainer: function() {
       var localStreamParent = document.querySelector('.local .OT_publisher');
       var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
       if (localStreamParent) {
@@ -277,20 +290,22 @@ loop.shared.views = (function(_, OT, l10
         this.setState({video: {enabled: enabled}});
       }
     },
 
     /**
      * Unpublishes local stream.
      */
     stopPublishing: function() {
-      // Unregister listeners for publisher events
-      this.stopListening(this.publisher);
+      if (this.publisher) {
+        // Unregister listeners for publisher events
+        this.stopListening(this.publisher);
 
-      this.props.model.session.unpublish(this.publisher);
+        this.props.model.session.unpublish(this.publisher);
+      }
     },
 
     render: function() {
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
         "local-stream-audio": !this.state.video.enabled
       });
@@ -357,17 +372,17 @@ loop.shared.views = (function(_, OT, l10
       sendFeedback: React.PropTypes.func,
       reset:        React.PropTypes.func
     },
 
     getInitialState: function() {
       return {category: "", description: ""};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {pending: false};
     },
 
     _getCategories: function() {
       return {
         audio_quality: l10n.get("feedback_category_audio_quality"),
         video_quality: l10n.get("feedback_category_video_quality"),
         disconnected : l10n.get("feedback_category_was_disconnected"),
@@ -462,18 +477,26 @@ loop.shared.views = (function(_, OT, l10
           )
         )
       );
     }
   });
 
   /**
    * Feedback received view.
+   *
+   * Props:
+   * - {Function} onAfterFeedbackReceived Function to execute after the
+   *   WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS timeout has elapsed
    */
   var FeedbackReceived = React.createClass({displayName: 'FeedbackReceived',
+    propTypes: {
+      onAfterFeedbackReceived: React.PropTypes.func
+    },
+
     getInitialState: function() {
       return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
     },
 
     componentDidMount: function() {
       this._timer = setInterval(function() {
         this.setState({countdown: this.state.countdown - 1});
       }.bind(this), 1000);
@@ -483,17 +506,19 @@ loop.shared.views = (function(_, OT, l10
       if (this._timer) {
         clearInterval(this._timer);
       }
     },
 
     render: function() {
       if (this.state.countdown < 1) {
         clearInterval(this._timer);
-        window.close();
+        if (this.props.onAfterFeedbackReceived) {
+          this.props.onAfterFeedbackReceived();
+        }
       }
       return (
         FeedbackLayout({title: l10n.get("feedback_thank_you_heading")}, 
           React.DOM.p({className: "info thank-you"}, 
             l10n.get("feedback_window_will_close_in2", {
               countdown: this.state.countdown,
               num: this.state.countdown
             }))
@@ -504,25 +529,26 @@ loop.shared.views = (function(_, OT, l10
 
   /**
    * Feedback view.
    */
   var FeedbackView = React.createClass({displayName: 'FeedbackView',
     propTypes: {
       // A loop.FeedbackAPIClient instance
       feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func,
       // The current feedback submission flow step name
       step: React.PropTypes.oneOf(["start", "form", "finished"])
     },
 
     getInitialState: function() {
       return {pending: false, step: this.props.step || "start"};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {step: "start"};
     },
 
     reset: function() {
       this.setState(this.getInitialState());
     },
 
     handleHappyClick: function() {
@@ -547,17 +573,20 @@ loop.shared.views = (function(_, OT, l10
         console.error("Unable to send user feedback", err);
       }
       this.setState({pending: false, step: "finished"});
     },
 
     render: function() {
       switch(this.state.step) {
         case "finished":
-          return FeedbackReceived(null);
+          return (
+            FeedbackReceived({
+              onAfterFeedbackReceived: this.props.onAfterFeedbackReceived})
+          );
         case "form":
           return FeedbackForm({feedbackApiClient: this.props.feedbackApiClient, 
                                sendFeedback: this.sendFeedback, 
                                reset: this.reset, 
                                pending: this.state.pending});
         default:
           return (
             FeedbackLayout({title: 
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -25,35 +25,37 @@ loop.shared.views = (function(_, OT, l10
    * - {Function} action  Function to be executed on click.
    * - {Enabled}  enabled Stream activation status (default: true).
    */
   var MediaControlButton = React.createClass({
     propTypes: {
       scope: React.PropTypes.string.isRequired,
       type: React.PropTypes.string.isRequired,
       action: React.PropTypes.func.isRequired,
-      enabled: React.PropTypes.bool.isRequired
+      enabled: React.PropTypes.bool.isRequired,
+      visible: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
-      return {enabled: true};
+      return {enabled: true, visible: true};
     },
 
     handleClick: function() {
       this.props.action();
     },
 
     _getClasses: function() {
       var cx = React.addons.classSet;
       // classes
       var classesObj = {
         "btn": true,
         "media-control": true,
         "local-media": this.props.scope === "local",
-        "muted": !this.props.enabled
+        "muted": !this.props.enabled,
+        "hide": !this.props.visible
       };
       classesObj["btn-mute-" + this.props.type] = true;
       return cx(classesObj);
     },
 
     _getTitle: function(enabled) {
       var prefix = this.props.enabled ? "mute" : "unmute";
       var suffix = "button_title";
@@ -73,18 +75,18 @@ loop.shared.views = (function(_, OT, l10
   });
 
   /**
    * Conversation controls.
    */
   var ConversationToolbar = React.createClass({
     getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     propTypes: {
       video: React.PropTypes.object.isRequired,
       audio: React.PropTypes.object.isRequired,
       hangup: React.PropTypes.func.isRequired,
       publishStream: React.PropTypes.func.isRequired
@@ -98,97 +100,108 @@ loop.shared.views = (function(_, OT, l10
       this.props.publishStream("video", !this.props.video.enabled);
     },
 
     handleToggleAudio: function() {
       this.props.publishStream("audio", !this.props.audio.enabled);
     },
 
     render: function() {
-      /* jshint ignore:start */
+      var cx = React.addons.classSet;
       return (
         <ul className="conversation-toolbar">
           <li className="conversation-toolbar-btn-box">
             <button className="btn btn-hangup" onClick={this.handleClickHangup}
                     title={l10n.get("hangup_button_title")}>
               {l10n.get("hangup_button_caption2")}
             </button>
           </li>
           <li className="conversation-toolbar-btn-box">
             <MediaControlButton action={this.handleToggleVideo}
                                 enabled={this.props.video.enabled}
+                                visible={this.props.video.visible}
                                 scope="local" type="video" />
           </li>
           <li className="conversation-toolbar-btn-box">
             <MediaControlButton action={this.handleToggleAudio}
                                 enabled={this.props.audio.enabled}
+                                visible={this.props.audio.visible}
                                 scope="local" type="audio" />
           </li>
         </ul>
       );
-      /* jshint ignore:end */
     }
   });
 
+  /**
+   * Conversation view.
+   */
   var ConversationView = React.createClass({
     mixins: [Backbone.Events],
 
     propTypes: {
       sdk: React.PropTypes.object.isRequired,
-      model: React.PropTypes.object.isRequired
+      video: React.PropTypes.object,
+      audio: React.PropTypes.object,
+      initiate: React.PropTypes.bool
     },
 
     // height set to 100%" to fix video layout on Google Chrome
     // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
     publisherConfig: {
       insertMode: "append",
       width: "100%",
       height: "100%",
       style: {
         bugDisplayMode: "off",
         buttonDisplayMode: "off",
         nameDisplayMode: "off"
       }
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {
-        video: {enabled: true},
-        audio: {enabled: true}
+        initiate: true,
+        video: {enabled: true, visible: true},
+        audio: {enabled: true, visible: true}
       };
     },
 
     getInitialState: function() {
       return {
         video: this.props.video,
         audio: this.props.audio
       };
     },
 
     componentWillMount: function() {
-      this.publisherConfig.publishVideo = this.props.video.enabled;
+      if (this.props.initiate) {
+        this.publisherConfig.publishVideo = this.props.video.enabled;
+      }
     },
 
     componentDidMount: function() {
-      this.listenTo(this.props.model, "session:connected",
-                                      this.startPublishing);
-      this.listenTo(this.props.model, "session:stream-created",
-                                      this._streamCreated);
-      this.listenTo(this.props.model, ["session:peer-hungup",
-                                       "session:network-disconnected",
-                                       "session:ended"].join(" "),
-                                       this.stopPublishing);
-
-      this.props.model.startSession();
+      if (this.props.initiate) {
+        this.listenTo(this.props.model, "session:connected",
+                                        this.startPublishing);
+        this.listenTo(this.props.model, "session:stream-created",
+                                        this._streamCreated);
+        this.listenTo(this.props.model, ["session:peer-hungup",
+                                         "session:network-disconnected",
+                                         "session:ended"].join(" "),
+                                         this.stopPublishing);
+        this.props.model.startSession();
+      }
 
       /**
        * 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);
     },
 
     updateVideoContainer: function() {
       var localStreamParent = document.querySelector('.local .OT_publisher');
       var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
       if (localStreamParent) {
@@ -277,20 +290,22 @@ loop.shared.views = (function(_, OT, l10
         this.setState({video: {enabled: enabled}});
       }
     },
 
     /**
      * Unpublishes local stream.
      */
     stopPublishing: function() {
-      // Unregister listeners for publisher events
-      this.stopListening(this.publisher);
+      if (this.publisher) {
+        // Unregister listeners for publisher events
+        this.stopListening(this.publisher);
 
-      this.props.model.session.unpublish(this.publisher);
+        this.props.model.session.unpublish(this.publisher);
+      }
     },
 
     render: function() {
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
         "local-stream-audio": !this.state.video.enabled
       });
@@ -357,17 +372,17 @@ loop.shared.views = (function(_, OT, l10
       sendFeedback: React.PropTypes.func,
       reset:        React.PropTypes.func
     },
 
     getInitialState: function() {
       return {category: "", description: ""};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {pending: false};
     },
 
     _getCategories: function() {
       return {
         audio_quality: l10n.get("feedback_category_audio_quality"),
         video_quality: l10n.get("feedback_category_video_quality"),
         disconnected : l10n.get("feedback_category_was_disconnected"),
@@ -462,18 +477,26 @@ loop.shared.views = (function(_, OT, l10
           </form>
         </FeedbackLayout>
       );
     }
   });
 
   /**
    * Feedback received view.
+   *
+   * Props:
+   * - {Function} onAfterFeedbackReceived Function to execute after the
+   *   WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS timeout has elapsed
    */
   var FeedbackReceived = React.createClass({
+    propTypes: {
+      onAfterFeedbackReceived: React.PropTypes.func
+    },
+
     getInitialState: function() {
       return {countdown: WINDOW_AUTOCLOSE_TIMEOUT_IN_SECONDS};
     },
 
     componentDidMount: function() {
       this._timer = setInterval(function() {
         this.setState({countdown: this.state.countdown - 1});
       }.bind(this), 1000);
@@ -483,17 +506,19 @@ loop.shared.views = (function(_, OT, l10
       if (this._timer) {
         clearInterval(this._timer);
       }
     },
 
     render: function() {
       if (this.state.countdown < 1) {
         clearInterval(this._timer);
-        window.close();
+        if (this.props.onAfterFeedbackReceived) {
+          this.props.onAfterFeedbackReceived();
+        }
       }
       return (
         <FeedbackLayout title={l10n.get("feedback_thank_you_heading")}>
           <p className="info thank-you">{
             l10n.get("feedback_window_will_close_in2", {
               countdown: this.state.countdown,
               num: this.state.countdown
             })}</p>
@@ -504,25 +529,26 @@ loop.shared.views = (function(_, OT, l10
 
   /**
    * Feedback view.
    */
   var FeedbackView = React.createClass({
     propTypes: {
       // A loop.FeedbackAPIClient instance
       feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func,
       // The current feedback submission flow step name
       step: React.PropTypes.oneOf(["start", "form", "finished"])
     },
 
     getInitialState: function() {
       return {pending: false, step: this.props.step || "start"};
     },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {step: "start"};
     },
 
     reset: function() {
       this.setState(this.getInitialState());
     },
 
     handleHappyClick: function() {
@@ -547,17 +573,20 @@ loop.shared.views = (function(_, OT, l10
         console.error("Unable to send user feedback", err);
       }
       this.setState({pending: false, step: "finished"});
     },
 
     render: function() {
       switch(this.state.step) {
         case "finished":
-          return <FeedbackReceived />;
+          return (
+            <FeedbackReceived
+              onAfterFeedbackReceived={this.props.onAfterFeedbackReceived} />
+          );
         case "form":
           return <FeedbackForm feedbackApiClient={this.props.feedbackApiClient}
                                sendFeedback={this.sendFeedback}
                                reset={this.reset}
                                pending={this.state.pending} />;
         default:
           return (
             <FeedbackLayout title={
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -8,16 +8,19 @@
 
 # XXX In the interest of making the build logic simpler and
 # more maintainable, we should be trying to implement new
 # functionality in Gruntfile.js rather than here.
 # Bug 1066176 tracks moving all functionality currently here
 # to the Gruntfile and getting rid of this Makefile entirely.
 
 LOOP_SERVER_URL := $(shell echo $${LOOP_SERVER_URL-http://localhost:5000})
+LOOP_FEEDBACK_API_URL := $(shell echo $${LOOP_FEEDBACK_API_URL-"https://input.allizom.org/api/v1/feedback"})
+LOOP_FEEDBACK_PRODUCT_NAME := $(shell echo $${LOOP_FEEDBACK_PRODUCT_NAME-Loop})
+
 NODE_LOCAL_BIN=./node_modules/.bin
 
 install: npm_install tos
 
 npm_install:
 	@npm install
 
 test:
@@ -62,9 +65,11 @@ remove_old_config:
 
 # The services development deployment, however, still wants a static config
 # file, and needs an easy way to generate one.  This target is for folks
 # working with that deployment.
 .PHONY: config
 config:
 	@echo "var loop = loop || {};" > content/config.js
 	@echo "loop.config = loop.config || {};" >> content/config.js
-	@echo "loop.config.serverUrl          = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
+	@echo "loop.config.serverUrl = '`echo $(LOOP_SERVER_URL)`';" >> content/config.js
+	@echo "loop.config.feedbackApiUrl = '`echo $(LOOP_FEEDBACK_API_URL)`';" >> content/config.js
+	@echo "loop.config.feedbackProductName = '`echo $(LOOP_FEEDBACK_PRODUCT_NAME)`';" >> content/config.js
--- a/browser/components/loop/standalone/README.md
+++ b/browser/components/loop/standalone/README.md
@@ -24,18 +24,22 @@ folks deploying the development server w
 
     $ make config
 
 It will read the configuration from the following env variables and generate the
 appropriate configuration file:
 
 - `LOOP_SERVER_URL` defines the root url of the loop server, without trailing
   slash (default: `http://localhost:5000`).
-- `LOOP_PENDING_CALL_TIMEOUT` defines the amount of time a pending outgoing call
-  should be considered timed out, in milliseconds (default: `20000`).
+- `LOOP_FEEDBACK_API_URL` sets the root URL for the
+  [input API](https://input.mozilla.org/); defaults to the input stage server
+  (https://input.allizom.org/api/v1/feedback). **Don't forget to set this
+  value to the production server URL when deploying to production.**
+- `LOOP_FEEDBACK_PRODUCT_NAME` defines the product name to be sent to the input
+  API (defaults: Loop).
 
 Usage
 -----
 
 For development, run a local static file server:
 
     $ make runserver
 
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -202,8 +202,46 @@ body,
  * Left / Right padding elements
  * used to center components
  * */
 .flex-padding-1 {
   display: flex;
   flex: 1;
 }
 
+/**
+ * Feedback form overlay (standalone only)
+ */
+.standalone .ended-conversation {
+  position: relative;
+  height: 100%;
+  background-color: #444;
+  text-align: left; /* as backup */
+  text-align: start;
+}
+
+.standalone .ended-conversation .feedback {
+  position: absolute;
+  width: 50%;
+  max-width: 400px;
+  margin: 10px auto;
+  top: 20px;
+  left: 10%;
+  right: 10%;
+  background: #FFF;
+  box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.4);
+  border-radius: 3px;
+  z-index: 1002; /* ensures the form is always on top of the control bar */
+}
+
+.standalone .ended-conversation .local-stream {
+  /* Hide  local media stream when feedback form is shown. */
+  display: none;
+}
+
+@media screen and (max-width:640px) {
+  .standalone .ended-conversation .feedback {
+    width: 92%;
+    top: 10%;
+    left: 5px;
+    right: 5px;
+  }
+}
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -36,16 +36,17 @@
     <script type="text/javascript" src="shared/libs/backbone-1.1.2.js"></script>
 
     <!-- app scripts -->
     <script type="text/javascript" src="config.js"></script>
     <script type="text/javascript" src="shared/js/utils.js"></script>
     <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/websocket.js"></script>
     <script type="text/javascript" src="js/standaloneClient.js"></script>
     <script type="text/javascript" src="js/webapp.js"></script>
 
     <script>
       // Wait for all the localization notes to load
       window.addEventListener('localized', function() {
         loop.webapp.init();
--- a/browser/components/loop/standalone/content/js/standaloneClient.js
+++ b/browser/components/loop/standalone/content/js/standaloneClient.js
@@ -117,17 +117,17 @@ loop.StandaloneClient = (function($) {
         dataType:    "json",
         data: JSON.stringify({callType: callType})
       });
 
       req.done(function(sessionData) {
         try {
           cb(null, this._validate(sessionData, expectedCallsProperties));
         } catch (err) {
-          console.log("Error requesting call info", err);
+          console.error("Error requesting call info", err.message);
           cb(err);
         }
       }.bind(this));
 
       req.fail(this._failureHandler.bind(this, cb));
     },
   };
 
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -1,41 +1,35 @@
 /** @jsx React.DOM */
 
 /* 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/. */
 
 /* global loop:true, React */
-/* jshint newcap:false */
+/* jshint newcap:false, maxlen:false */
 
 var loop = loop || {};
 loop.webapp = (function($, _, OT, mozL10n) {
   "use strict";
 
   loop.config = loop.config || {};
   loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
 
   var sharedModels = loop.shared.models,
       sharedViews = loop.shared.views;
 
   /**
-   * App router.
-   * @type {loop.webapp.WebappRouter}
-   */
-  var router;
-
-  /**
    * Homepage view.
    */
   var HomeView = React.createClass({displayName: 'HomeView',
     render: function() {
       return (
         React.DOM.p(null, mozL10n.get("welcome"))
-      )
+      );
     }
   });
 
   /**
    * Unsupported Browsers view.
    */
   var UnsupportedBrowserView = React.createClass({displayName: 'UnsupportedBrowserView',
     render: function() {
@@ -99,28 +93,26 @@ loop.webapp = (function($, _, OT, mozL10
    * Expired call URL view.
    */
   var CallUrlExpiredView = React.createClass({displayName: 'CallUrlExpiredView',
     propTypes: {
       helper: React.PropTypes.object.isRequired
     },
 
     render: function() {
-      /* jshint ignore:start */
       return (
         React.DOM.div({className: "expired-url-info"}, 
           React.DOM.div({className: "info-panel"}, 
             React.DOM.div({className: "firefox-logo"}), 
             React.DOM.h1(null, mozL10n.get("call_url_unavailable_notification_heading")), 
             React.DOM.h4(null, mozL10n.get("call_url_unavailable_notification_message2"))
           ), 
           PromoteFirefoxView({helper: this.props.helper})
         )
       );
-      /* jshint ignore:end */
     }
   });
 
   var ConversationBranding = React.createClass({displayName: 'ConversationBranding',
     render: function() {
       return (
         React.DOM.h1({className: "standalone-header-title"}, 
           React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
@@ -141,28 +133,26 @@ loop.webapp = (function($, _, OT, mozL10
         "hide": !this.props.urlCreationDateString.length
       });
 
       var callUrlCreationDateString = mozL10n.get("call_url_creation_date_label", {
         "call_url_creation_date": this.props.urlCreationDateString
       });
 
       return (
-        /* jshint ignore:start */
         React.DOM.header({className: "standalone-header header-box container-box"}, 
           ConversationBranding(null), 
           React.DOM.div({className: "loop-logo", title: "Firefox WebRTC! logo"}), 
           React.DOM.h3({className: "call-url"}, 
             conversationUrl
           ), 
           React.DOM.h4({className: urlCreationDateClasses}, 
             callUrlCreationDateString
           )
         )
-        /* jshint ignore:end */
       );
     }
   });
 
   var ConversationFooter = React.createClass({displayName: 'ConversationFooter',
     render: function() {
       return (
         React.DOM.div({className: "standalone-footer container-box"}, 
@@ -171,17 +161,17 @@ loop.webapp = (function($, _, OT, mozL10
       );
     }
   });
 
   var PendingConversationView = React.createClass({displayName: 'PendingConversationView',
     getInitialState: function() {
       return {
         callState: this.props.callState || "connecting"
-      }
+      };
     },
 
     propTypes: {
       websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
                       .isRequired
     },
 
     componentDidMount: function() {
@@ -195,17 +185,16 @@ loop.webapp = (function($, _, OT, mozL10
 
     _cancelOutgoingCall: function() {
       this.props.websocket.cancel();
     },
 
     render: function() {
       var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
       return (
-        /* jshint ignore:start */
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
             React.DOM.header({className: "pending-header header-box"}, 
               ConversationBranding(null)
             ), 
 
             React.DOM.div({id: "cameraPreview"}), 
 
@@ -224,55 +213,49 @@ loop.webapp = (function($, _, OT, mozL10
                 )
               ), 
               React.DOM.div({className: "flex-padding-1"})
             )
           ), 
 
           ConversationFooter(null)
         )
-        /* jshint ignore:end */
       );
     }
   });
 
   /**
    * Conversation launcher view. A ConversationModel is associated and attached
    * as a `model` property.
+   *
+   * Required properties:
+   * - {loop.shared.models.ConversationModel}    model    Conversation model.
+   * - {loop.shared.models.NotificationCollection} notifications
    */
   var StartConversationView = React.createClass({displayName: 'StartConversationView',
-    /**
-     * Constructor.
-     *
-     * Required options:
-     * - {loop.shared.models.ConversationModel}    model    Conversation model.
-     * - {loop.shared.models.NotificationCollection} notifications
-     *
-     */
+    propTypes: {
+      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                                       .isRequired,
+      // XXX Check more tightly here when we start injecting window.loop.*
+      notifications: React.PropTypes.object.isRequired,
+      client: React.PropTypes.object.isRequired
+    },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {showCallOptionsMenu: false};
     },
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
         disableCallButton: false,
         showCallOptionsMenu: this.props.showCallOptionsMenu
       };
     },
 
-    propTypes: {
-      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                                       .isRequired,
-      // XXX Check more tightly here when we start injecting window.loop.*
-      notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired
-    },
-
     componentDidMount: function() {
       // Listen for events & hide dropdown menu if user clicks away
       window.addEventListener("click", this.clickHandler);
       this.props.model.listenTo(this.props.model, "session:error",
                                 this._onSessionError);
       this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
                                            this._setConversationTimestamp);
     },
@@ -343,17 +326,16 @@ loop.webapp = (function($, _, OT, mozL10
         "visually-hidden": !this.state.showCallOptionsMenu
       });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
 
       return (
-        /* jshint ignore:start */
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
 
             ConversationHeader({
               urlCreationDateString: this.state.urlCreationDateString}), 
 
             React.DOM.p({className: "standalone-btn-label"}, 
               mozL10n.get("initiate_call_button_label2")
@@ -402,17 +384,47 @@ loop.webapp = (function($, _, OT, mozL10
             ), 
 
             React.DOM.p({className: tosClasses, 
                dangerouslySetInnerHTML: {__html: tosHTML}})
           ), 
 
           ConversationFooter(null)
         )
-        /* jshint ignore:end */
+      );
+    }
+  });
+
+  /**
+   * Ended conversation view.
+   */
+  var EndedConversationView = React.createClass({displayName: 'EndedConversationView',
+    propTypes: {
+      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                         .isRequired,
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func.isRequired
+    },
+
+    render: function() {
+      return (
+        React.DOM.div({className: "ended-conversation"}, 
+          sharedViews.FeedbackView({
+            feedbackApiClient: this.props.feedbackApiClient, 
+            onAfterFeedbackReceived: this.props.onAfterFeedbackReceived}
+          ), 
+          sharedViews.ConversationView({
+            initiate: false, 
+            sdk: this.props.sdk, 
+            model: this.props.conversation, 
+            audio: {enabled: false, visible: false}, 
+            video: {enabled: false, visible: false}}
+          )
+        )
       );
     }
   });
 
   /**
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
@@ -421,17 +433,18 @@ loop.webapp = (function($, _, OT, mozL10
   var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
-      sdk: React.PropTypes.object.isRequired
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         callStatus: "start"
       };
     },
 
@@ -445,61 +458,82 @@ loop.webapp = (function($, _, OT, mozL10
       this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
       this.props.conversation.on("session:connection-error", this._notifyError, this);
     },
 
     componentDidUnmount: function() {
       this.props.conversation.off(null, null, this);
     },
 
+    shouldComponentUpdate: function(nextProps, nextState) {
+      // Only rerender if current state has actually changed
+      return nextState.callStatus !== this.state.callStatus;
+    },
+
+    callStatusSwitcher: function(status) {
+      return function() {
+        this.setState({callStatus: status});
+      }.bind(this);
+    },
+
     /**
      * Renders the conversation views.
      */
     render: function() {
       switch (this.state.callStatus) {
         case "failure":
-        case "end":
         case "start": {
           return (
             StartConversationView({
               model: this.props.conversation, 
               notifications: this.props.notifications, 
               client: this.props.client}
             )
           );
         }
         case "pending": {
           return PendingConversationView({websocket: this._websocket});
         }
         case "connected": {
           return (
             sharedViews.ConversationView({
+              initiate: true, 
               sdk: this.props.sdk, 
               model: this.props.conversation, 
               video: {enabled: this.props.conversation.hasVideoStream("outgoing")}}
             )
           );
         }
+        case "end": {
+          return (
+            EndedConversationView({
+              sdk: this.props.sdk, 
+              conversation: this.props.conversation, 
+              feedbackApiClient: this.props.feedbackApiClient, 
+              onAfterFeedbackReceived: this.callStatusSwitcher("start")}
+            )
+          );
+        }
         case "expired": {
           return (
             CallUrlExpiredView({helper: this.props.helper})
           );
         }
         default: {
-          return HomeView(null)
+          return HomeView(null);
         }
       }
     },
 
     /**
      * Notify the user that the connection was not possible
      * @param {{code: number, message: string}} error
      */
     _notifyError: function(error) {
-      console.log(error);
+      console.error(error);
       this.props.notifications.errorL10n("connection_error_see_console_notification");
       this.setState({callStatus: "end"});
     },
 
     /**
      * Peer hung up. Notifies the user and ends the call.
      *
      * Event properties:
@@ -623,23 +657,25 @@ loop.webapp = (function($, _, OT, mozL10
     },
 
     /**
      * Handles call rejection.
      *
      * @param {String} reason The reason the call was terminated.
      */
     _handleCallTerminated: function(reason) {
-      this.setState({callStatus: "end"});
-      // For reasons other than cancel, display some notification text.
       if (reason !== "cancel") {
         // XXX This should really display the call failed view - bug 1046959
         // will implement this.
         this.props.notifications.errorL10n("call_timeout_notification_text");
       }
+      // redirects the user to the call start view
+      // XXX should switch callStatus to failed for specific reasons when we
+      // get the call failed view; for now, switch back to start.
+      this.setState({callStatus: "start"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       this.setState({callStatus: "end"});
     },
@@ -652,17 +688,18 @@ loop.webapp = (function($, _, OT, mozL10
   var WebappRootView = React.createClass({displayName: 'WebappRootView',
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
-      sdk: React.PropTypes.object.isRequired
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         unsupportedDevice: this.props.helper.isIOS(navigator.platform),
         unsupportedBrowser: !this.props.sdk.checkSystemRequirements(),
       };
     },
@@ -674,17 +711,18 @@ loop.webapp = (function($, _, OT, mozL10
         return UnsupportedBrowserView(null);
       } else if (this.props.conversation.get("loopToken")) {
         return (
           OutgoingConversationView({
              client: this.props.client, 
              conversation: this.props.conversation, 
              helper: this.props.helper, 
              notifications: this.props.notifications, 
-             sdk: this.props.sdk}
+             sdk: this.props.sdk, 
+             feedbackApiClient: this.props.feedbackApiClient}
           )
         );
       } else {
         return HomeView(null);
       }
     }
   });
 
@@ -716,41 +754,49 @@ loop.webapp = (function($, _, OT, mozL10
     var helper = new WebappHelper();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
     var notifications = new sharedModels.NotificationCollection();
     var conversation = new sharedModels.ConversationModel({}, {
       sdk: OT
     });
+    var feedbackApiClient = new loop.FeedbackAPIClient(
+      loop.config.feedbackApiUrl, {
+        product: loop.config.feedbackProductName,
+        user_agent: navigator.userAgent,
+        url: document.location.origin
+      });
 
     // Obtain the loopToken and pass it to the conversation
     var locationHash = helper.locationHash();
     if (locationHash) {
       conversation.set("loopToken", locationHash.match(/\#call\/(.*)/)[1]);
     }
 
     React.renderComponent(WebappRootView({
       client: client, 
       conversation: conversation, 
       helper: helper, 
       notifications: notifications, 
-      sdk: OT}
+      sdk: OT, 
+      feedbackApiClient: feedbackApiClient}
     ), document.querySelector("#main"));
 
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
     OutgoingConversationView: OutgoingConversationView,
+    EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappHelper: WebappHelper,
     WebappRootView: WebappRootView
   };
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -1,41 +1,35 @@
 /** @jsx React.DOM */
 
 /* 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/. */
 
 /* global loop:true, React */
-/* jshint newcap:false */
+/* jshint newcap:false, maxlen:false */
 
 var loop = loop || {};
 loop.webapp = (function($, _, OT, mozL10n) {
   "use strict";
 
   loop.config = loop.config || {};
   loop.config.serverUrl = loop.config.serverUrl || "http://localhost:5000";
 
   var sharedModels = loop.shared.models,
       sharedViews = loop.shared.views;
 
   /**
-   * App router.
-   * @type {loop.webapp.WebappRouter}
-   */
-  var router;
-
-  /**
    * Homepage view.
    */
   var HomeView = React.createClass({
     render: function() {
       return (
         <p>{mozL10n.get("welcome")}</p>
-      )
+      );
     }
   });
 
   /**
    * Unsupported Browsers view.
    */
   var UnsupportedBrowserView = React.createClass({
     render: function() {
@@ -99,28 +93,26 @@ loop.webapp = (function($, _, OT, mozL10
    * Expired call URL view.
    */
   var CallUrlExpiredView = React.createClass({
     propTypes: {
       helper: React.PropTypes.object.isRequired
     },
 
     render: function() {
-      /* jshint ignore:start */
       return (
         <div className="expired-url-info">
           <div className="info-panel">
             <div className="firefox-logo" />
             <h1>{mozL10n.get("call_url_unavailable_notification_heading")}</h1>
             <h4>{mozL10n.get("call_url_unavailable_notification_message2")}</h4>
           </div>
           <PromoteFirefoxView helper={this.props.helper} />
         </div>
       );
-      /* jshint ignore:end */
     }
   });
 
   var ConversationBranding = React.createClass({
     render: function() {
       return (
         <h1 className="standalone-header-title">
           <strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
@@ -141,28 +133,26 @@ loop.webapp = (function($, _, OT, mozL10
         "hide": !this.props.urlCreationDateString.length
       });
 
       var callUrlCreationDateString = mozL10n.get("call_url_creation_date_label", {
         "call_url_creation_date": this.props.urlCreationDateString
       });
 
       return (
-        /* jshint ignore:start */
         <header className="standalone-header header-box container-box">
           <ConversationBranding />
           <div className="loop-logo" title="Firefox WebRTC! logo"></div>
           <h3 className="call-url">
             {conversationUrl}
           </h3>
           <h4 className={urlCreationDateClasses} >
             {callUrlCreationDateString}
           </h4>
         </header>
-        /* jshint ignore:end */
       );
     }
   });
 
   var ConversationFooter = React.createClass({
     render: function() {
       return (
         <div className="standalone-footer container-box">
@@ -171,17 +161,17 @@ loop.webapp = (function($, _, OT, mozL10
       );
     }
   });
 
   var PendingConversationView = React.createClass({
     getInitialState: function() {
       return {
         callState: this.props.callState || "connecting"
-      }
+      };
     },
 
     propTypes: {
       websocket: React.PropTypes.instanceOf(loop.CallConnectionWebSocket)
                       .isRequired
     },
 
     componentDidMount: function() {
@@ -195,17 +185,16 @@ loop.webapp = (function($, _, OT, mozL10
 
     _cancelOutgoingCall: function() {
       this.props.websocket.cancel();
     },
 
     render: function() {
       var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
       return (
-        /* jshint ignore:start */
         <div className="container">
           <div className="container-box">
             <header className="pending-header header-box">
               <ConversationBranding />
             </header>
 
             <div id="cameraPreview"></div>
 
@@ -224,55 +213,49 @@ loop.webapp = (function($, _, OT, mozL10
                 </span>
               </button>
               <div className="flex-padding-1"></div>
             </div>
           </div>
 
           <ConversationFooter />
         </div>
-        /* jshint ignore:end */
       );
     }
   });
 
   /**
    * Conversation launcher view. A ConversationModel is associated and attached
    * as a `model` property.
+   *
+   * Required properties:
+   * - {loop.shared.models.ConversationModel}    model    Conversation model.
+   * - {loop.shared.models.NotificationCollection} notifications
    */
   var StartConversationView = React.createClass({
-    /**
-     * Constructor.
-     *
-     * Required options:
-     * - {loop.shared.models.ConversationModel}    model    Conversation model.
-     * - {loop.shared.models.NotificationCollection} notifications
-     *
-     */
+    propTypes: {
+      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                                       .isRequired,
+      // XXX Check more tightly here when we start injecting window.loop.*
+      notifications: React.PropTypes.object.isRequired,
+      client: React.PropTypes.object.isRequired
+    },
 
-    getInitialProps: function() {
+    getDefaultProps: function() {
       return {showCallOptionsMenu: false};
     },
 
     getInitialState: function() {
       return {
         urlCreationDateString: '',
         disableCallButton: false,
         showCallOptionsMenu: this.props.showCallOptionsMenu
       };
     },
 
-    propTypes: {
-      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                                       .isRequired,
-      // XXX Check more tightly here when we start injecting window.loop.*
-      notifications: React.PropTypes.object.isRequired,
-      client: React.PropTypes.object.isRequired
-    },
-
     componentDidMount: function() {
       // Listen for events & hide dropdown menu if user clicks away
       window.addEventListener("click", this.clickHandler);
       this.props.model.listenTo(this.props.model, "session:error",
                                 this._onSessionError);
       this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
                                            this._setConversationTimestamp);
     },
@@ -343,17 +326,16 @@ loop.webapp = (function($, _, OT, mozL10
         "visually-hidden": !this.state.showCallOptionsMenu
       });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
 
       return (
-        /* jshint ignore:start */
         <div className="container">
           <div className="container-box">
 
             <ConversationHeader
               urlCreationDateString={this.state.urlCreationDateString} />
 
             <p className="standalone-btn-label">
               {mozL10n.get("initiate_call_button_label2")}
@@ -402,17 +384,47 @@ loop.webapp = (function($, _, OT, mozL10
             </div>
 
             <p className={tosClasses}
                dangerouslySetInnerHTML={{__html: tosHTML}}></p>
           </div>
 
           <ConversationFooter />
         </div>
-        /* jshint ignore:end */
+      );
+    }
+  });
+
+  /**
+   * Ended conversation view.
+   */
+  var EndedConversationView = React.createClass({
+    propTypes: {
+      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
+                         .isRequired,
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired,
+      onAfterFeedbackReceived: React.PropTypes.func.isRequired
+    },
+
+    render: function() {
+      return (
+        <div className="ended-conversation">
+          <sharedViews.FeedbackView
+            feedbackApiClient={this.props.feedbackApiClient}
+            onAfterFeedbackReceived={this.props.onAfterFeedbackReceived}
+          />
+          <sharedViews.ConversationView
+            initiate={false}
+            sdk={this.props.sdk}
+            model={this.props.conversation}
+            audio={{enabled: false, visible: false}}
+            video={{enabled: false, visible: false}}
+          />
+        </div>
       );
     }
   });
 
   /**
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
@@ -421,17 +433,18 @@ loop.webapp = (function($, _, OT, mozL10
   var OutgoingConversationView = React.createClass({
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
-      sdk: React.PropTypes.object.isRequired
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         callStatus: "start"
       };
     },
 
@@ -445,61 +458,82 @@ loop.webapp = (function($, _, OT, mozL10
       this.props.conversation.on("session:network-disconnected", this._onNetworkDisconnected, this);
       this.props.conversation.on("session:connection-error", this._notifyError, this);
     },
 
     componentDidUnmount: function() {
       this.props.conversation.off(null, null, this);
     },
 
+    shouldComponentUpdate: function(nextProps, nextState) {
+      // Only rerender if current state has actually changed
+      return nextState.callStatus !== this.state.callStatus;
+    },
+
+    callStatusSwitcher: function(status) {
+      return function() {
+        this.setState({callStatus: status});
+      }.bind(this);
+    },
+
     /**
      * Renders the conversation views.
      */
     render: function() {
       switch (this.state.callStatus) {
         case "failure":
-        case "end":
         case "start": {
           return (
             <StartConversationView
               model={this.props.conversation}
               notifications={this.props.notifications}
               client={this.props.client}
             />
           );
         }
         case "pending": {
           return <PendingConversationView websocket={this._websocket} />;
         }
         case "connected": {
           return (
             <sharedViews.ConversationView
+              initiate={true}
               sdk={this.props.sdk}
               model={this.props.conversation}
               video={{enabled: this.props.conversation.hasVideoStream("outgoing")}}
             />
           );
         }
+        case "end": {
+          return (
+            <EndedConversationView
+              sdk={this.props.sdk}
+              conversation={this.props.conversation}
+              feedbackApiClient={this.props.feedbackApiClient}
+              onAfterFeedbackReceived={this.callStatusSwitcher("start")}
+            />
+          );
+        }
         case "expired": {
           return (
             <CallUrlExpiredView helper={this.props.helper} />
           );
         }
         default: {
-          return <HomeView />
+          return <HomeView />;
         }
       }
     },
 
     /**
      * Notify the user that the connection was not possible
      * @param {{code: number, message: string}} error
      */
     _notifyError: function(error) {
-      console.log(error);
+      console.error(error);
       this.props.notifications.errorL10n("connection_error_see_console_notification");
       this.setState({callStatus: "end"});
     },
 
     /**
      * Peer hung up. Notifies the user and ends the call.
      *
      * Event properties:
@@ -623,23 +657,25 @@ loop.webapp = (function($, _, OT, mozL10
     },
 
     /**
      * Handles call rejection.
      *
      * @param {String} reason The reason the call was terminated.
      */
     _handleCallTerminated: function(reason) {
-      this.setState({callStatus: "end"});
-      // For reasons other than cancel, display some notification text.
       if (reason !== "cancel") {
         // XXX This should really display the call failed view - bug 1046959
         // will implement this.
         this.props.notifications.errorL10n("call_timeout_notification_text");
       }
+      // redirects the user to the call start view
+      // XXX should switch callStatus to failed for specific reasons when we
+      // get the call failed view; for now, switch back to start.
+      this.setState({callStatus: "start"});
     },
 
     /**
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       this.setState({callStatus: "end"});
     },
@@ -652,17 +688,18 @@ loop.webapp = (function($, _, OT, mozL10
   var WebappRootView = React.createClass({
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       helper: React.PropTypes.instanceOf(WebappHelper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
-      sdk: React.PropTypes.object.isRequired
+      sdk: React.PropTypes.object.isRequired,
+      feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
       return {
         unsupportedDevice: this.props.helper.isIOS(navigator.platform),
         unsupportedBrowser: !this.props.sdk.checkSystemRequirements(),
       };
     },
@@ -675,16 +712,17 @@ loop.webapp = (function($, _, OT, mozL10
       } else if (this.props.conversation.get("loopToken")) {
         return (
           <OutgoingConversationView
              client={this.props.client}
              conversation={this.props.conversation}
              helper={this.props.helper}
              notifications={this.props.notifications}
              sdk={this.props.sdk}
+             feedbackApiClient={this.props.feedbackApiClient}
           />
         );
       } else {
         return <HomeView />;
       }
     }
   });
 
@@ -716,41 +754,49 @@ loop.webapp = (function($, _, OT, mozL10
     var helper = new WebappHelper();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
     var notifications = new sharedModels.NotificationCollection();
     var conversation = new sharedModels.ConversationModel({}, {
       sdk: OT
     });
+    var feedbackApiClient = new loop.FeedbackAPIClient(
+      loop.config.feedbackApiUrl, {
+        product: loop.config.feedbackProductName,
+        user_agent: navigator.userAgent,
+        url: document.location.origin
+      });
 
     // Obtain the loopToken and pass it to the conversation
     var locationHash = helper.locationHash();
     if (locationHash) {
       conversation.set("loopToken", locationHash.match(/\#call\/(.*)/)[1]);
     }
 
     React.renderComponent(<WebappRootView
       client={client}
       conversation={conversation}
       helper={helper}
       notifications={notifications}
       sdk={OT}
+      feedbackApiClient={feedbackApiClient}
     />, document.querySelector("#main"));
 
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     StartConversationView: StartConversationView,
     OutgoingConversationView: OutgoingConversationView,
+    EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
     WebappHelper: WebappHelper,
     WebappRootView: WebappRootView
   };
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -41,8 +41,37 @@ legal_text_and_links=By using this produ
 terms_of_use_link_text=Terms of use
 privacy_notice_link_text=Privacy notice
 brandShortname=Firefox
 clientShortname=WebRTC!
 ## LOCALIZATION NOTE (call_url_creation_date_label): Example output: (from May 26, 2014)
 call_url_creation_date_label=(from {{call_url_creation_date}})
 call_progress_connecting_description=Connecting…
 call_progress_ringing_description=Ringing…
+
+feedback_call_experience_heading2=How was your conversation?
+feedback_what_makes_you_sad=What makes you sad?
+feedback_thank_you_heading=Thank you for your feedback!
+feedback_category_audio_quality=Audio quality
+feedback_category_video_quality=Video quality
+feedback_category_was_disconnected=Was disconnected
+feedback_category_confusing=Confusing
+feedback_category_other=Other:
+feedback_custom_category_text_placeholder=What went wrong?
+feedback_submit_button=Submit
+feedback_back_button=Back
+## LOCALIZATION NOTE (feedback_window_will_close_in2):
+## Gaia l10n format; see https://github.com/mozilla-b2g/gaia/blob/f108c706fae43cd61628babdd9463e7695b2496e/apps/email/locales/email.en-US.properties#L387
+## In this item, don't translate the part between {{..}}
+feedback_window_will_close_in2={[ plural(countdown) ]}
+feedback_window_will_close_in2[one] = This window will close in {{countdown}} second
+feedback_window_will_close_in2[two] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[few] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[many] = This window will close in {{countdown}} seconds
+feedback_window_will_close_in2[other] = This window will close in {{countdown}} seconds
+
+## LOCALIZATION_NOTE (feedback_rejoin_button): Displayed on the feedback form after
+## a signed-in to signed-in user call.
+## https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#feedback
+feedback_rejoin_button=Rejoin
+## LOCALIZATION NOTE (feedback_report_user_button): Used to report a user in the case of
+## an abusive user.
+feedback_report_user_button=Report User
--- a/browser/components/loop/standalone/server.js
+++ b/browser/components/loop/standalone/server.js
@@ -2,27 +2,31 @@
  * 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/. */
 
 var express = require('express');
 var app = express();
 
 var port = process.env.PORT || 3000;
 var loopServerPort = process.env.LOOP_SERVER_PORT || 5000;
+var feedbackApiUrl = process.env.LOOP_FEEDBACK_API_URL ||
+                     "https://input.allizom.org/api/v1/feedback";
+var feedbackProductName = process.env.LOOP_FEEDBACK_PRODUCT_NAME || "Loop";
 
 function getConfigFile(req, res) {
   "use strict";
 
   res.set('Content-Type', 'text/javascript');
-  res.send(
-    "var loop = loop || {};" +
-    "loop.config = loop.config || {};" +
-    "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';" +
-    "loop.config.pendingCallTimeout = 20000;"
-  );
+  res.send([
+    "var loop = loop || {};",
+    "loop.config = loop.config || {};",
+    "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';",
+    "loop.config.feedbackApiUrl = '" + feedbackApiUrl + "';",
+    "loop.config.feedbackProductName = '" + feedbackProductName + "';",
+  ].join("\n"));
 }
 
 app.get('/content/config.js', getConfigFile);
 
 // This lets /test/ be mapped to the right place for running tests
 app.use('/', express.static(__dirname + '/../'));
 
 // Magic so that the legal content works both in the standalone server
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -103,18 +103,17 @@ describe("loop.conversation", function()
   });
 
   describe("ConversationRouter", function() {
     var conversation, client;
 
     beforeEach(function() {
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
-        sdk: {},
-        pendingCallTimeout: 1000,
+        sdk: {}
       });
       sandbox.spy(conversation, "setIncomingSessionData");
       sandbox.stub(conversation, "setOutgoingSessionData");
     });
 
     describe("Routes", function() {
       var router;
 
--- a/browser/components/loop/test/shared/feedbackApiClient_test.js
+++ b/browser/components/loop/test/shared/feedbackApiClient_test.js
@@ -133,16 +133,23 @@ describe("loop.FeedbackAPIClient", funct
 
       it("should send user_agent information when provided", function() {
         client.send({user_agent: "MOZAGENT"}, function(){});
 
         var parsed = JSON.parse(requests[0].requestBody);
         expect(parsed.user_agent).eql("MOZAGENT");
       });
 
+      it("should send url information when provided", function() {
+        client.send({url: "http://fake.invalid"}, function(){});
+
+        var parsed = JSON.parse(requests[0].requestBody);
+        expect(parsed.url).eql("http://fake.invalid");
+      });
+
       it("should throw on invalid feedback data", function() {
         expect(function() {
           client.send("invalid data", function(){});
         }).to.Throw(/Invalid/);
       });
 
       it("should throw on unsupported field name", function() {
         expect(function() {
--- a/browser/components/loop/test/shared/router_test.js
+++ b/browser/components/loop/test/shared/router_test.js
@@ -55,18 +55,17 @@ describe("loop.shared.router", function(
 
     beforeEach(function() {
       TestRouter = loop.shared.router.BaseConversationRouter.extend({
         endCall: sandbox.spy()
       });
       conversation = new loop.shared.models.ConversationModel({
         loopToken: "fakeToken"
       }, {
-        sdk: {},
-        pendingCallTimeout: 1000
+        sdk: {}
       });
     });
 
     describe("#constructor", function() {
       it("should require a ConversationModel instance", function() {
         expect(function() {
           new TestRouter({ client: {} });
         }).to.Throw(Error, /missing required conversation/);
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -182,34 +182,46 @@ describe("loop.shared.views", function()
         publishAudio: sandbox.spy(),
         publishVideo: sandbox.spy()
       }, Backbone.Events);
       fakeSDK = {
         initPublisher: sandbox.stub().returns(fakePublisher),
         initSession: sandbox.stub().returns(fakeSession)
       };
       model = new sharedModels.ConversationModel(fakeSessionData, {
-        sdk: fakeSDK,
-        pendingCallTimeout: 1000
+        sdk: fakeSDK
       });
     });
 
     describe("#componentDidMount", function() {
-      it("should start a session", function() {
+      it("should start a session by default", function() {
         sandbox.stub(model, "startSession");
 
         mountTestComponent({
           sdk: fakeSDK,
           model: model,
           video: {enabled: true}
         });
 
         sinon.assert.calledOnce(model.startSession);
       });
 
+      it("shouldn't start a session if initiate is false", function() {
+        sandbox.stub(model, "startSession");
+
+        mountTestComponent({
+          initiate: false,
+          sdk: fakeSDK,
+          model: model,
+          video: {enabled: true}
+        });
+
+        sinon.assert.notCalled(model.startSession);
+      });
+
       it("should set the correct stream publish options", function() {
 
         var component = mountTestComponent({
           sdk: fakeSDK,
           model: model,
           video: {enabled: false}
         });
 
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -31,16 +31,17 @@
     mocha.setup('bdd');
   </script>
   <!-- App scripts -->
   <script src="../../content/shared/js/utils.js"></script>
   <script src="../../content/shared/js/models.js"></script>
   <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="../../standalone/content/js/standaloneClient.js"></script>
   <script src="../../standalone/content/js/webapp.js"></script>
  <!-- Test scripts -->
   <script src="standalone_client_test.js"></script>
   <script src="webapp_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -8,34 +8,39 @@ var expect = chai.expect;
 var TestUtils = React.addons.TestUtils;
 
 describe("loop.webapp", function() {
   "use strict";
 
   var sharedModels = loop.shared.models,
       sharedViews = loop.shared.views,
       sandbox,
-      notifications;
+      notifications,
+      feedbackApiClient;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     notifications = new sharedModels.NotificationCollection();
+    feedbackApiClient = new loop.FeedbackAPIClient("http://invalid", {
+      product: "Loop"
+    });
   });
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#init", function() {
     var conversationSetStub;
 
     beforeEach(function() {
       sandbox.stub(React, "renderComponent");
       sandbox.stub(loop.webapp.WebappHelper.prototype,
                    "locationHash").returns("#call/fake-Token");
+      loop.config.feedbackApiUrl = "http://fake.invalid";
       conversationSetStub =
         sandbox.stub(sharedModels.ConversationModel.prototype, "set");
     });
 
     it("should create the WebappRootView", function() {
       loop.webapp.init();
 
       sinon.assert.calledOnce(React.renderComponent);
@@ -72,17 +77,18 @@ describe("loop.webapp", function() {
         sdk: {}
       });
       conversation.set("loopToken", "fakeToken");
       ocView = mountTestComponent({
         helper: new loop.webapp.WebappHelper(),
         client: client,
         conversation: conversation,
         notifications: notifications,
-        sdk: {}
+        sdk: {},
+        feedbackApiClient: feedbackApiClient
       });
     });
 
     describe("start", function() {
       it("should display the StartConversationView", function() {
         TestUtils.findRenderedComponentWithType(ocView,
           loop.webapp.StartConversationView);
       });
@@ -300,26 +306,26 @@ describe("loop.webapp", function() {
           });
       });
 
       describe("session:ended", function() {
         it("should set display the StartConversationView", function() {
           conversation.trigger("session:ended");
 
           TestUtils.findRenderedComponentWithType(ocView,
-            loop.webapp.StartConversationView);
+            loop.webapp.EndedConversationView);
         });
       });
 
       describe("session:peer-hungup", function() {
         it("should set display the StartConversationView", function() {
           conversation.trigger("session:peer-hungup");
 
           TestUtils.findRenderedComponentWithType(ocView,
-            loop.webapp.StartConversationView);
+            loop.webapp.EndedConversationView);
         });
 
         it("should notify the user", function() {
           conversation.trigger("session:peer-hungup");
 
           sinon.assert.calledOnce(notifications.warnL10n);
           sinon.assert.calledWithExactly(notifications.warnL10n,
                                          "peer_ended_conversation2");
@@ -328,17 +334,17 @@ describe("loop.webapp", function() {
       });
 
       describe("session:network-disconnected", function() {
         it("should display the StartConversationView",
           function() {
             conversation.trigger("session:network-disconnected");
 
             TestUtils.findRenderedComponentWithType(ocView,
-              loop.webapp.StartConversationView);
+              loop.webapp.EndedConversationView);
           });
 
         it("should notify the user", function() {
           conversation.trigger("session:network-disconnected");
 
           sinon.assert.calledOnce(notifications.warnL10n);
           sinon.assert.calledWithExactly(notifications.warnL10n,
                                          "network_disconnected");
@@ -469,18 +475,20 @@ describe("loop.webapp", function() {
   describe("WebappRootView", function() {
     var webappHelper, sdk, conversationModel, client, props;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.webapp.WebappRootView({
         client: client,
         helper: webappHelper,
+        notifications: notifications,
         sdk: sdk,
-        conversation: conversationModel
+        conversation: conversationModel,
+        feedbackApiClient: feedbackApiClient
       }));
     }
 
     beforeEach(function() {
       webappHelper = new loop.webapp.WebappHelper();
       sdk = {
         checkSystemRequirements: function() { return true; }
       };
@@ -767,16 +775,42 @@ describe("loop.webapp", function() {
         );
         tos = view.getDOMNode().querySelector(".terms-service");
 
         expect(tos.classList.contains("hide")).to.equal(true);
       });
     });
   });
 
+  describe("EndedConversationView", function() {
+    var view, conversation;
+
+    beforeEach(function() {
+      conversation = new sharedModels.ConversationModel({}, {
+        sdk: {}
+      });
+      view = React.addons.TestUtils.renderIntoDocument(
+        loop.webapp.EndedConversationView({
+          conversation: conversation,
+          sdk: {},
+          feedbackApiClient: feedbackApiClient,
+          onAfterFeedbackReceived: function(){}
+        })
+      );
+    });
+
+    it("should render a ConversationView", function() {
+      TestUtils.findRenderedComponentWithType(view, sharedViews.ConversationView);
+    });
+
+    it("should render a FeedbackView", function() {
+      TestUtils.findRenderedComponentWithType(view, sharedViews.FeedbackView);
+    });
+  });
+
   describe("PromoteFirefoxView", function() {
     describe("#render", function() {
       it("should not render when using Firefox", function() {
         var comp = TestUtils.renderIntoDocument(loop.webapp.PromoteFirefoxView({
           helper: {isFirefox: function() { return true; }}
         }));
 
         expect(comp.getDOMNode().querySelectorAll("h3").length).eql(0);
--- a/browser/components/loop/ui/fake-l10n.js
+++ b/browser/components/loop/ui/fake-l10n.js
@@ -4,16 +4,20 @@
 
 /**
  * /!\ FIXME: THIS IS A HORRID HACK which fakes both the mozL10n and webL10n
  * objects and makes them returning the string id and serialized vars if any,
  * for any requested string id.
  * @type {Object}
  */
 navigator.mozL10n = document.mozL10n = {
+  initialize: function(){},
+
+  getDirection: function(){},
+
   get: function(stringId, vars) {
 
     // upcase the first letter
     var readableStringId = stringId.replace(/^./, function(match) {
       "use strict";
       return match.toUpperCase();
     }).replace(/_/g, " ");  // and convert _ chars to spaces
 
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -2,11 +2,12 @@
  * 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/. */
 
 /**
  * Faking the mozLoop object which doesn't exist in regular web pages.
  * @type {Object}
  */
 navigator.mozLoop = {
+  ensureRegistered: function() {},
   getLoopCharPref: function() {},
   getLoopBoolPref: function() {}
 };
--- a/browser/components/loop/ui/ui-showcase.css
+++ b/browser/components/loop/ui/ui-showcase.css
@@ -32,17 +32,17 @@
 .showcase-menu > a {
   margin-right: .5em;
   padding: .4rem;
   margin-top: .2rem;
 }
 
 .showcase > section {
   position: relative;
-  padding-top: 12em;
+  padding-top: 14em;
   clear: both;
 }
 
 .showcase > section > h1 {
   margin: 1em 0;
   border-bottom: 1px solid #aaa;
 }
 
@@ -144,8 +144,14 @@
   max-width: 120px;
 }
 
 .conversation .media.nested .remote {
   /* Height of obsolute box covers media control buttons. UI showcase only.
    * When tokbox inserts the markup into the page the problem goes away */
   bottom: auto;
 }
+
+.standalone .ended-conversation .remote_wrapper,
+.standalone .video-layout-wrapper {
+  /* Removes the fake video image for ended conversations */
+  background: none;
+}
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -18,16 +18,17 @@
 
   // 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 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;
 
   // Local helpers
   function returnTrue() {
@@ -333,16 +334,29 @@
             Example({summary: "Firefox User"}, 
               CallUrlExpiredView({helper: {isFirefox: returnTrue}})
             ), 
             Example({summary: "Non-Firefox User"}, 
               CallUrlExpiredView({helper: {isFirefox: returnFalse}})
             )
           ), 
 
+          Section({name: "EndedConversationView"}, 
+            Example({summary: "Displays the feedback form"}, 
+              React.DOM.div({className: "standalone"}, 
+                EndedConversationView({sdk: mockSDK, 
+                                       video: {enabled: true}, 
+                                       audio: {enabled: true}, 
+                                       conversation: mockConversationModel, 
+                                       feedbackApiClient: stageFeedbackApiClient, 
+                                       onAfterFeedbackReceived: noop})
+              )
+            )
+          ), 
+
           Section({name: "AlertMessages"}, 
             Example({summary: "Various alerts"}, 
               React.DOM.div({className: "alert alert-warning"}, 
                 React.DOM.button({className: "close"}), 
                 React.DOM.p({className: "message"}, 
                   "The person you were calling has ended the conversation."
                 )
               ), 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -18,16 +18,17 @@
 
   // 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 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;
 
   // Local helpers
   function returnTrue() {
@@ -333,16 +334,29 @@
             <Example summary="Firefox User">
               <CallUrlExpiredView helper={{isFirefox: returnTrue}} />
             </Example>
             <Example summary="Non-Firefox User">
               <CallUrlExpiredView helper={{isFirefox: returnFalse}} />
             </Example>
           </Section>
 
+          <Section name="EndedConversationView">
+            <Example summary="Displays the feedback form">
+              <div className="standalone">
+                <EndedConversationView sdk={mockSDK}
+                                       video={{enabled: true}}
+                                       audio={{enabled: true}}
+                                       conversation={mockConversationModel}
+                                       feedbackApiClient={stageFeedbackApiClient}
+                                       onAfterFeedbackReceived={noop} />
+              </div>
+            </Example>
+          </Section>
+
           <Section name="AlertMessages">
             <Example summary="Various alerts">
               <div className="alert alert-warning">
                 <button className="close"></button>
                 <p className="message">
                   The person you were calling has ended the conversation.
                 </p>
               </div>
--- a/browser/devtools/webide/content/webide.js
+++ b/browser/devtools/webide/content/webide.js
@@ -70,16 +70,18 @@ let UI = {
     let autoInstallADBHelper = Services.prefs.getBoolPref("devtools.webide.autoinstallADBHelper");
     if (autoInstallADBHelper && !Devices.helperAddonInstalled) {
       GetAvailableAddons().then(addons => {
         addons.adb.install();
       }, console.error);
     }
     Services.prefs.setBoolPref("devtools.webide.autoinstallADBHelper", false);
 
+    this.lastConnectedRuntime = Services.prefs.getCharPref("devtools.webide.lastConnectedRuntime");
+
     this.setupDeck();
   },
 
   openLastProject: function() {
     let lastProjectLocation = Services.prefs.getCharPref("devtools.webide.lastprojectlocation");
     let shouldRestore = Services.prefs.getBoolPref("devtools.webide.restoreLastProject");
     if (lastProjectLocation && shouldRestore) {
       let lastProject = AppProjects.get(lastProjectLocation);
@@ -120,16 +122,17 @@ let UI = {
     }
   },
 
   appManagerUpdate: function(event, what, details) {
     // Got a message from app-manager.js
     switch (what) {
       case "runtimelist":
         this.updateRuntimeList();
+        this.autoConnectRuntime();
         break;
       case "connection":
         this.updateRuntimeButton();
         this.updateCommands();
         break;
       case "project":
         this.updateTitle();
         this.destroyToolbox();
@@ -140,16 +143,17 @@ let UI = {
         break;
       case "project-is-not-running":
       case "project-is-running":
       case "list-tabs-response":
         this.updateCommands();
         break;
       case "runtime":
         this.updateRuntimeButton();
+        this.saveLastConnectedRuntime();
         break;
       case "project-validated":
         this.updateTitle();
         this.updateCommands();
         this.updateProjectButton();
         this.updateProjectEditorHeader();
         break;
       case "install-progress":
@@ -338,32 +342,71 @@ let UI = {
           this.hidePanels();
           this.dismissErrorNotification();
           this.connectToRuntime(r);
         }, true);
       }
     }
   },
 
+  autoConnectRuntime: function () {
+    // Automatically reconnect to the previously selected runtime,
+    // if available and has an ID
+    if (AppManager.selectedRuntime || !this.lastConnectedRuntime) {
+      return;
+    }
+    let [_, type, id] = this.lastConnectedRuntime.match(/^(\w+):(.+)$/);
+
+    type = type.toLowerCase();
+
+    // Local connection is mapped to AppManager.runtimeList.custom array
+    if (type == "local") {
+      type = "custom";
+    }
+
+    // We support most runtimes except simulator, that needs to be manually
+    // launched
+    if (type == "usb" || type == "wifi" || type == "custom") {
+      for (let runtime of AppManager.runtimeList[type]) {
+        // Some runtimes do not expose getID function and don't support
+        // autoconnect (like remote connection)
+        if (typeof(runtime.getID) == "function" && runtime.getID() == id) {
+          this.connectToRuntime(runtime);
+        }
+      }
+    }
+  },
+
   connectToRuntime: function(runtime) {
     let name = runtime.getName();
     let promise = AppManager.connectToRuntime(runtime);
     return this.busyUntil(promise, "connecting to runtime");
   },
 
   updateRuntimeButton: function() {
     let labelNode = document.querySelector("#runtime-panel-button > .panel-button-label");
     if (!AppManager.selectedRuntime) {
       labelNode.setAttribute("value", Strings.GetStringFromName("runtimeButton_label"));
     } else {
       let name = AppManager.selectedRuntime.getName();
       labelNode.setAttribute("value", name);
     }
   },
 
+  saveLastConnectedRuntime: function () {
+    if (AppManager.selectedRuntime &&
+        typeof(AppManager.selectedRuntime.getID) === "function") {
+      this.lastConnectedRuntime = AppManager.selectedRuntime.type + ":" + AppManager.selectedRuntime.getID();
+    } else {
+      this.lastConnectedRuntime = "";
+    }
+    Services.prefs.setCharPref("devtools.webide.lastConnectedRuntime",
+                               this.lastConnectedRuntime);
+  },
+
   /********** PROJECTS **********/
 
   // Panel & button
 
   updateProjectButton: function() {
     let buttonNode = document.querySelector("#project-panel-button");
     let labelNode = buttonNode.querySelector(".panel-button-label");
     let imageNode = buttonNode.querySelector(".panel-button-image");
--- a/browser/devtools/webide/modules/app-manager.js
+++ b/browser/devtools/webide/modules/app-manager.js
@@ -646,17 +646,25 @@ exports.AppManager = AppManager = {
     Devices.off("addon-status-updated", this._updateUSBRuntimes);
   },
   _updateUSBRuntimes: function() {
     this.runtimeList.usb = [];
     for (let id of Devices.available()) {
       let r = new USBRuntime(id);
       this.runtimeList.usb.push(r);
       r.updateNameFromADB().then(
-        () => this.update("runtimelist"), () => {});
+        () => {
+          this.update("runtimelist");
+          // Also update the runtime button label, if the currently selected
+          // runtime name changes
+          if (r == this.selectedRuntime) {
+            this.update("runtime");
+          }
+        },
+        () => {});
     }
     this.update("runtimelist");
   },
 
   get isWiFiScanningEnabled() {
     return Services.prefs.getBoolPref(WIFI_SCANNING_PREF);
   },
   scanForWiFiRuntimes: function() {
--- a/browser/devtools/webide/modules/runtimes.js
+++ b/browser/devtools/webide/modules/runtimes.js
@@ -8,21 +8,31 @@ const {Services} = Cu.import("resource:/
 const {Simulator} = Cu.import("resource://gre/modules/devtools/Simulator.jsm");
 const {ConnectionManager, Connection} = require("devtools/client/connection-manager");
 const {DebuggerServer} = require("resource://gre/modules/devtools/dbg-server.jsm");
 const discovery = require("devtools/toolkit/discovery/discovery");
 const promise = require("promise");
 
 const Strings = Services.strings.createBundle("chrome://browser/locale/devtools/webide.properties");
 
+// These type strings are used for logging events to Telemetry
+let RuntimeTypes = {
+  usb: "USB",
+  wifi: "WIFI",
+  simulator: "SIMULATOR",
+  remote: "REMOTE",
+  local: "LOCAL"
+};
+
 function USBRuntime(id) {
   this.id = id;
 }
 
 USBRuntime.prototype = {
+  type: RuntimeTypes.usb,
   connect: function(connection) {
     let device = Devices.getByName(this.id);
     if (!device) {
       return promise.reject("Can't find device: " + this.getName());
     }
     return device.connect().then((port) => {
       connection.host = "localhost";
       connection.port = port;
@@ -54,16 +64,17 @@ USBRuntime.prototype = {
   },
 }
 
 function WiFiRuntime(deviceName) {
   this.deviceName = deviceName;
 }
 
 WiFiRuntime.prototype = {
+  type: RuntimeTypes.wifi,
   connect: function(connection) {
     let service = discovery.getRemoteService("devtools", this.deviceName);
     if (!service) {
       return promise.reject("Can't find device: " + this.getName());
     }
     connection.host = service.host;
     connection.port = service.port;
     connection.connect();
@@ -77,16 +88,17 @@ WiFiRuntime.prototype = {
   },
 }
 
 function SimulatorRuntime(version) {
   this.version = version;
 }
 
 SimulatorRuntime.prototype = {
+  type: RuntimeTypes.simulator,
   connect: function(connection) {
     let port = ConnectionManager.getFreeTCPPort();
     let simulator = Simulator.getByVersion(this.version);
     if (!simulator || !simulator.launch) {
       return promise.reject("Can't find simulator: " + this.getName());
     }
     return simulator.launch({port: port}).then(() => {
       connection.host = "localhost";
@@ -100,32 +112,37 @@ SimulatorRuntime.prototype = {
     return this.version;
   },
   getName: function() {
     return Simulator.getByVersion(this.version).appinfo.label;
   },
 }
 
 let gLocalRuntime = {
+  type: RuntimeTypes.local,
   connect: function(connection) {
     if (!DebuggerServer.initialized) {
       DebuggerServer.init();
       DebuggerServer.addBrowserActors();
     }
     connection.host = null; // Force Pipe transport
     connection.port = null;
     connection.connect();
     return promise.resolve();
   },
   getName: function() {
     return Strings.GetStringFromName("local_runtime");
   },
+  getID: function () {
+    return "local";
+  }
 }
 
 let gRemoteRuntime = {
+  type: RuntimeTypes.remote,
   connect: function(connection) {
     let win = Services.wm.getMostRecentWindow("devtools:webide");
     if (!win) {
       return promise.reject();
     }
     let ret = {value: connection.host + ":" + connection.port};
     let title = Strings.GetStringFromName("remote_runtime_promptTitle");
     let message = Strings.GetStringFromName("remote_runtime_promptMessage");
--- a/browser/devtools/webide/test/chrome.ini
+++ b/browser/devtools/webide/test/chrome.ini
@@ -26,8 +26,9 @@ support-files =
 
 [test_basic.html]
 [test_newapp.html]
 [test_import.html]
 [test_runtime.html]
 [test_manifestUpdate.html]
 [test_addons.html]
 [test_deviceinfo.html]
+[test_autoconnect_runtime.html]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webide/test/test_autoconnect_runtime.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+
+<html>
+
+  <head>
+    <meta charset="utf8">
+    <title></title>
+
+    <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+    <script type="application/javascript" src="chrome://mochikit/content/chrome-harness.js"></script>
+    <script type="application/javascript;version=1.8" src="head.js"></script>
+    <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  </head>
+
+  <body>
+
+    <script type="application/javascript;version=1.8">
+      window.onload = function() {
+        SimpleTest.waitForExplicitFinish();
+
+        Task.spawn(function* () {
+
+          Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
+          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");
+              connection.host = null; // force connectPipe
+              connection.connect();
+              return promise.resolve();
+            },
+
+            getID: function() {
+              return "fakeRuntime";
+            },
+
+            getName: function() {
+              return "fakeRuntime";
+            }
+          };
+          win.AppManager.runtimeList.usb.push(fakeRuntime);
+          win.AppManager.update("runtimelist");
+
+          let panelNode = win.document.querySelector("#runtime-panel");
+          let items = panelNode.querySelectorAll(".runtime-panel-item-usb");
+          is(items.length, 1, "Found one runtime button");
+
+          let deferred = promise.defer();
+          win.AppManager.connection.once(
+              win.Connection.Events.CONNECTED,
+              () => deferred.resolve());
+          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 nextTick();
+
+          yield closeWebIDE(win);
+
+          is(Object.keys(DebuggerServer._connections).length, 0, "Disconnected");
+
+          win = yield openWebIDE();
+
+          win.AppManager.runtimeList.usb.push(fakeRuntime);
+          win.AppManager.update("runtimelist");
+
+          yield waitForUpdate(win, "list-tabs-response");
+
+          is(Object.keys(DebuggerServer._connections).length, 1, "Automatically reconnected");
+
+          yield win.Cmds.disconnectRuntime();
+
+          yield closeWebIDE(win);
+
+          DebuggerServer.destroy();
+
+          SimpleTest.finish();
+        });
+      }
+
+
+    </script>
+  </body>
+</html>
--- a/browser/devtools/webide/webide-prefs.js
+++ b/browser/devtools/webide/webide-prefs.js
@@ -10,8 +10,9 @@ pref("devtools.webide.lastprojectlocatio
 pref("devtools.webide.restoreLastProject", true);
 pref("devtools.webide.enableLocalRuntime", false);
 pref("devtools.webide.addonsURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/index.json");
 pref("devtools.webide.simulatorAddonsURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/#VERSION#/#OS#/fxos_#SLASHED_VERSION#_simulator-#OS#-latest.xpi");
 pref("devtools.webide.simulatorAddonID", "fxos_#SLASHED_VERSION#_simulator@mozilla.org");
 pref("devtools.webide.adbAddonURL", "https://ftp.mozilla.org/pub/mozilla.org/labs/fxos-simulator/adb-helper/#OS#/adbhelper-#OS#-latest.xpi");
 pref("devtools.webide.adbAddonID", "adbhelper@mozilla.org");
 pref("devtools.webide.monitorWebSocketURL", "ws://localhost:9000");
+pref("devtools.webide.lastConnectedRuntime", "");
--- a/browser/themes/shared/devtools/webaudioeditor.inc.css
+++ b/browser/themes/shared/devtools/webaudioeditor.inc.css
@@ -100,34 +100,38 @@ g.edgePath.param-connection {
 .theme-dark .nodes g.selected rect {
   fill: #1d4f73; /* Select Highlight Blue */
 }
 
 .theme-light .nodes g.selected rect {
   fill: #4c9ed9; /* Select Highlight Blue */
 }
 
-/* Text in nodes */
+/* Text in nodes and edges */
 text {
-  cursor: pointer;
+  cursor: default; /* override the "text" cursor */
   font-weight: 300;
   font-family: "Helvetica Neue", Helvetica, Arial, sans-serf;
   font-size: 14px;
 }
 
 .theme-dark text {
   fill: #b6babf; /* Grey foreground text */
 }
 .theme-light text {
   fill: #585959; /* Grey foreground text */
 }
 .theme-light g.selected text {
   fill: #f0f1f2; /* Toolbars */
 }
 
+.nodes text {
+  cursor: pointer;
+}
+
 /**
  * Inspector Styles
  */
 
 #web-audio-inspector-title {
   margin: 6px;
 }
 
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -278,16 +278,23 @@ pref("browser.search.noCurrentEngine", t
 
 #ifdef MOZ_OFFICIAL_BRANDING
 // {moz:official} expands to "official"
 pref("browser.search.official", true);
 #endif
 
 // Control media casting feature
 pref("browser.casting.enabled", true);
+#ifdef RELEASE_BUILD
+pref("browser.mirroring.enabled", false);
+pref("browser.mirroring.enabled.roku", false);
+#else
+pref("browser.mirroring.enabled", true);
+pref("browser.mirroring.enabled.roku", true);
+#endif
 
 // Enable sparse localization by setting a few package locale overrides
 pref("chrome.override_package.global", "browser");
 pref("chrome.override_package.mozapps", "browser");
 pref("chrome.override_package.passwordmgr", "browser");
 
 // enable xul error pages
 pref("browser.xul.error_pages.enabled", true);
--- a/mobile/android/base/resources/layout-large-land-v11/tabs_panel.xml
+++ b/mobile/android/base/resources/layout-large-land-v11/tabs_panel.xml
@@ -21,17 +21,17 @@
 
         <View android:layout_width="match_parent"
               android:layout_height="2dp"
               android:layout_alignParentBottom="true"
               android:background="#1A000000"/>
 
     </RelativeLayout>
 
-    <view class="org.mozilla.gecko.tabs.TabsPanel$TabsListContainer"
+    <view class="org.mozilla.gecko.tabs.TabsPanel$PanelViewContainer"
           android:id="@+id/tabs_container"
           android:layout_width="match_parent"
           android:layout_height="0dip"
           android:layout_weight="1.0">
 
         <view class="org.mozilla.gecko.tabs.TabsPanel$TabsLayout"
               android:id="@+id/normal_tabs"
               style="@style/TabsList"
--- a/mobile/android/base/resources/layout/tabs_panel.xml
+++ b/mobile/android/base/resources/layout/tabs_panel.xml
@@ -21,17 +21,17 @@
 
         <View android:layout_width="match_parent"
               android:layout_height="2dp"
               android:layout_alignParentBottom="true"
               android:background="#1A000000"/>
 
     </RelativeLayout>
 
-    <view class="org.mozilla.gecko.tabs.TabsPanel$TabsListContainer"
+    <view class="org.mozilla.gecko.tabs.TabsPanel$PanelViewContainer"
           android:id="@+id/tabs_container"
           android:layout_width="match_parent"
           android:layout_height="wrap_content">
 
         <view class="org.mozilla.gecko.tabs.TabsPanel$TabsLayout"
               android:id="@+id/normal_tabs"
               style="@style/TabsList"
               android:layout_width="match_parent"
--- a/mobile/android/base/resources/values-land/layout.xml
+++ b/mobile/android/base/resources/values-land/layout.xml
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- 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/. -->
 
 <resources>
-    <item type="layout" name="tabs_row">@layout/tabs_item_cell</item>
+    <item type="layout" name="tabs_layout_item_view">@layout/tabs_item_cell</item>
 </resources>
--- a/mobile/android/base/resources/values-large-v11/layout.xml
+++ b/mobile/android/base/resources/values-large-v11/layout.xml
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- 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/. -->
 
 <resources>
-    <item type="layout" name="tabs_row">@layout/tabs_item_cell</item>
+    <item type="layout" name="tabs_layout_item_view">@layout/tabs_item_cell</item>
 </resources>
--- a/mobile/android/base/resources/values/layout.xml
+++ b/mobile/android/base/resources/values/layout.xml
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- 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/. -->
 
 <resources>
-    <item type="layout" name="tabs_row">@layout/tabs_item_row</item>
+    <item type="layout" name="tabs_layout_item_view">@layout/tabs_item_row</item>
 </resources>
--- a/mobile/android/base/tabs/TabsLayoutAdapter.java
+++ b/mobile/android/base/tabs/TabsLayoutAdapter.java
@@ -76,17 +76,17 @@ public class TabsLayoutAdapter extends B
             view = convertView;
         }
         final Tab tab = mTabs.get(position);
         bindView(view, tab);
         return view;
     }
 
     View newView(int position, ViewGroup parent) {
-        final View view = mInflater.inflate(R.layout.tabs_row, parent, false);
+        final View view = mInflater.inflate(R.layout.tabs_layout_item_view, parent, false);
         final TabsLayoutItemView item = new TabsLayoutItemView(view);
         view.setTag(item);
         return view;
     }
 
     void bindView(View view, Tab tab) {
         TabsLayoutItemView item = (TabsLayoutItemView) view.getTag();
         item.assignValues(tab);
--- a/mobile/android/base/tabs/TabsPanel.java
+++ b/mobile/android/base/tabs/TabsPanel.java
@@ -72,17 +72,17 @@ public class TabsPanel extends LinearLay
     public static interface TabsLayoutChangeListener {
         public void onTabsLayoutChange(int width, int height);
     }
 
     private Context mContext;
     private final GeckoApp mActivity;
     private final LightweightTheme mTheme;
     private RelativeLayout mHeader;
-    private TabsListContainer mTabsContainer;
+    private PanelViewContainer mPanelsContainer;
     private PanelView mPanel;
     private PanelView mPanelNormal;
     private PanelView mPanelPrivate;
     private PanelView mPanelRemote;
     private RelativeLayout mFooter;
     private TabsLayoutChangeListener mLayoutChangeListener;
     private AppStateListener mAppStateListener;
 
@@ -132,17 +132,17 @@ public class TabsPanel extends LinearLay
 
             @Override
             public void onPause() {}
         };
     }
 
     private void initialize() {
         mHeader = (RelativeLayout) findViewById(R.id.tabs_panel_header);
-        mTabsContainer = (TabsListContainer) findViewById(R.id.tabs_container);
+        mPanelsContainer = (PanelViewContainer) findViewById(R.id.tabs_container);
 
         mPanelNormal = (PanelView) findViewById(R.id.normal_tabs);
         mPanelNormal.setTabsPanel(this);
 
         mPanelPrivate = (PanelView) findViewById(R.id.private_tabs_panel);
         mPanelPrivate.setTabsPanel(this);
 
         mPanelRemote = (PanelView) findViewById(R.id.remote_tabs);
@@ -259,29 +259,29 @@ public class TabsPanel extends LinearLay
 
         if (itemId == R.id.new_tab || itemId == R.id.new_private_tab) {
             hide();
         }
 
         return mActivity.onOptionsItemSelected(item);
     }
 
-    private static int getTabContainerHeight(TabsListContainer listContainer) {
-        Resources resources = listContainer.getContext().getResources();
+    private static int getPanelsContainerHeight(PanelViewContainer panelsContainer) {
+        Resources resources = panelsContainer.getContext().getResources();
 
-        PanelView panelView = listContainer.getCurrentPanelView();
+        PanelView panelView = panelsContainer.getCurrentPanelView();
         if (panelView != null && !panelView.shouldExpand()) {
             return resources.getDimensionPixelSize(R.dimen.tabs_tray_horizontal_height);
         }
 
         int actionBarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height);
         int screenHeight = resources.getDisplayMetrics().heightPixels;
 
         Rect windowRect = new Rect();
-        listContainer.getWindowVisibleDisplayFrame(windowRect);
+        panelsContainer.getWindowVisibleDisplayFrame(windowRect);
         int windowHeight = windowRect.bottom - windowRect.top;
 
         // The web content area should have at least 1.5x the height of the action bar.
         // The tabs panel shouldn't take less than 50% of the screen height and can take
         // up to 80% of the window height.
         return (int) Math.max(screenHeight * 0.5f,
                               Math.min(windowHeight - 2.5f * actionBarHeight, windowHeight * 0.8f) - actionBarHeight);
     }
@@ -318,19 +318,19 @@ public class TabsPanel extends LinearLay
     }
 
     @Override
     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
         super.onLayout(changed, left, top, right, bottom);
         onLightweightThemeChanged();
     }
 
-    // Tabs List Container holds the ListView
-    static class TabsListContainer extends FrameLayout {
-        public TabsListContainer(Context context, AttributeSet attrs) {
+    // Panel View Container holds the ListView
+    static class PanelViewContainer extends FrameLayout {
+        public PanelViewContainer(Context context, AttributeSet attrs) {
             super(context, attrs);
         }
 
         public PanelView getCurrentPanelView() {
             final int childCount = getChildCount();
             for (int i = 0; i < childCount; i++) {
                 View child = getChildAt(i);
                 if (!(child instanceof PanelView))
@@ -341,17 +341,17 @@ public class TabsPanel extends LinearLay
             }
 
             return null;
         }
 
         @Override
         protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
             if (!GeckoAppShell.getGeckoInterface().hasTabsSideBar()) {
-                int heightSpec = MeasureSpec.makeMeasureSpec(getTabContainerHeight(TabsListContainer.this), MeasureSpec.EXACTLY);
+                int heightSpec = MeasureSpec.makeMeasureSpec(getPanelsContainerHeight(PanelViewContainer.this), MeasureSpec.EXACTLY);
                 super.onMeasure(widthMeasureSpec, heightSpec);
             } else {
                 super.onMeasure(widthMeasureSpec, heightMeasureSpec);
             }
         }
     }
 
     // Tabs Panel Toolbar contains the Buttons
@@ -463,17 +463,17 @@ public class TabsPanel extends LinearLay
             }
         }
 
         if (isSideBar()) {
             if (showAnimation)
                 dispatchLayoutChange(getWidth(), getHeight());
         } else {
             int actionBarHeight = mContext.getResources().getDimensionPixelSize(R.dimen.browser_toolbar_height);
-            int height = actionBarHeight + getTabContainerHeight(mTabsContainer);
+            int height = actionBarHeight + getPanelsContainerHeight(mPanelsContainer);
             dispatchLayoutChange(getWidth(), height);
         }
         mHeaderVisible = true;
     }
 
     public void hide() {
         mHeaderVisible = false;
 
@@ -521,51 +521,51 @@ public class TabsPanel extends LinearLay
         if (Versions.preHC) {
             return;
         }
 
         if (mIsSideBar) {
             final int tabsPanelWidth = getWidth();
             if (mVisible) {
                 ViewHelper.setTranslationX(mHeader, -tabsPanelWidth);
-                ViewHelper.setTranslationX(mTabsContainer, -tabsPanelWidth);
+                ViewHelper.setTranslationX(mPanelsContainer, -tabsPanelWidth);
 
                 // The footer view is only present on the sidebar, v11+.
                 ViewHelper.setTranslationX(mFooter, -tabsPanelWidth);
             }
             final int translationX = (mVisible ? 0 : -tabsPanelWidth);
-            animator.attach(mTabsContainer, PropertyAnimator.Property.TRANSLATION_X, translationX);
+            animator.attach(mPanelsContainer, PropertyAnimator.Property.TRANSLATION_X, translationX);
             animator.attach(mHeader, PropertyAnimator.Property.TRANSLATION_X, translationX);
             animator.attach(mFooter, PropertyAnimator.Property.TRANSLATION_X, translationX);
 
         } else if (!mHeaderVisible) {
             final Resources resources = getContext().getResources();
             final int toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height);
             final int translationY = (mVisible ? 0 : -toolbarHeight);
             if (mVisible) {
                 ViewHelper.setTranslationY(mHeader, -toolbarHeight);
-                ViewHelper.setTranslationY(mTabsContainer, -toolbarHeight);
-                ViewHelper.setAlpha(mTabsContainer, 0.0f);
+                ViewHelper.setTranslationY(mPanelsContainer, -toolbarHeight);
+                ViewHelper.setAlpha(mPanelsContainer, 0.0f);
             }
-            animator.attach(mTabsContainer, PropertyAnimator.Property.ALPHA, mVisible ? 1.0f : 0.0f);
-            animator.attach(mTabsContainer, PropertyAnimator.Property.TRANSLATION_Y, translationY);
+            animator.attach(mPanelsContainer, PropertyAnimator.Property.ALPHA, mVisible ? 1.0f : 0.0f);
+            animator.attach(mPanelsContainer, PropertyAnimator.Property.TRANSLATION_Y, translationY);
             animator.attach(mHeader, PropertyAnimator.Property.TRANSLATION_Y, translationY);
         }
 
         mHeader.setLayerType(View.LAYER_TYPE_HARDWARE, null);
-        mTabsContainer.setLayerType(View.LAYER_TYPE_HARDWARE, null);
+        mPanelsContainer.setLayerType(View.LAYER_TYPE_HARDWARE, null);
     }
 
     public void finishTabsAnimation() {
         if (Versions.preHC) {
             return;
         }
 
         mHeader.setLayerType(View.LAYER_TYPE_NONE, null);
-        mTabsContainer.setLayerType(View.LAYER_TYPE_NONE, null);
+        mPanelsContainer.setLayerType(View.LAYER_TYPE_NONE, null);
 
         // If the tray is now hidden, call hide() on current panel and unset it as the current panel
         // to avoid hide() being called again when the tray is opened next.
         if (!mVisible && mPanel != null) {
             mPanel.hide();
             mPanel = null;
         }
     }
--- a/mobile/android/chrome/content/CastingApps.js
+++ b/mobile/android/chrome/content/CastingApps.js
@@ -11,16 +11,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 // JSM files, but we left them here to allow for better lazy JSM loading.
 var rokuDevice = {
   id: "roku:ecp",
   target: "roku:ecp",
   factory: function(aService) {
     Cu.import("resource://gre/modules/RokuApp.jsm");
     return new RokuApp(aService);
   },
+  mirror: Services.prefs.getBoolPref("browser.mirroring.enabled.roku"),
   types: ["video/mp4"],
   extensions: ["mp4"]
 };
 
 var fireflyDevice = {
   id: "firefly:dial",
   target: "urn:dial-multiscreen-org:service:dial:1",
   filters: {
@@ -47,17 +48,17 @@ var mediaPlayerDevice = {
 };
 
 var CastingApps = {
   _castMenuId: -1,
   mirrorStartMenuId: -1,
   mirrorStopMenuId: -1,
 
   init: function ca_init() {
-    if (!this.isEnabled()) {
+    if (!this.isCastingEnabled()) {
       return;
     }
 
     // Register targets
     SimpleServiceDiscovery.registerDevice(rokuDevice);
     SimpleServiceDiscovery.registerDevice(fireflyDevice);
     SimpleServiceDiscovery.registerDevice(mediaPlayerDevice);
 
@@ -94,50 +95,58 @@ var CastingApps = {
     Services.obs.removeObserver(this, "Casting:Stop");
     Services.obs.removeObserver(this, "Casting:Mirror");
     Services.obs.removeObserver(this, "ssdp-service-found");
     Services.obs.removeObserver(this, "ssdp-service-lost");
 
     NativeWindow.contextmenus.remove(this._castMenuId);
   },
 
+  _mirrorStarted: function(stopMirrorCallback) {
+    this.stopMirrorCallback = stopMirrorCallback;
+    NativeWindow.menu.update(this.mirrorStartMenuId, { visible: false });
+    NativeWindow.menu.update(this.mirrorStopMenuId, { visible: true });
+  },
+
   serviceAdded: function(aService) {
-    if (aService.mirror && this.mirrorStartMenuId == -1) {
+    if (this.isMirroringEnabled() && aService.mirror && this.mirrorStartMenuId == -1) {
       this.mirrorStartMenuId = NativeWindow.menu.add({
         name: Strings.browser.GetStringFromName("casting.mirrorTab"),
         callback: function() {
-          function callbackFunc(aService) {
+          let callbackFunc = function(aService) {
             let app = SimpleServiceDiscovery.findAppForService(aService);
-            if (app)
-              app.mirror(function() {
-              });
-          }
+            if (app) {
+              app.mirror(function() {}, window, BrowserApp.selectedTab.getViewport(), this._mirrorStarted.bind(this));
+            }
+          }.bind(this);
 
-          function filterFunc(aService) {
-            return aService.mirror == true;
-          }
-          this.prompt(callbackFunc, filterFunc);
+          this.prompt(callbackFunc, aService => aService.mirror);
         }.bind(this),
         parent: NativeWindow.menu.toolsMenuID
       });
 
       this.mirrorStopMenuId = NativeWindow.menu.add({
         name: Strings.browser.GetStringFromName("casting.mirrorTabStop"),
         callback: function() {
           if (this.tabMirror) {
             this.tabMirror.stop();
             this.tabMirror = null;
+          } else if (this.stopMirrorCallback) {
+            this.stopMirrorCallback();
+            this.stopMirrorCallback = null;
           }
           NativeWindow.menu.update(this.mirrorStartMenuId, { visible: true });
           NativeWindow.menu.update(this.mirrorStopMenuId, { visible: false });
         }.bind(this),
         parent: NativeWindow.menu.toolsMenuID
       });
     }
-    NativeWindow.menu.update(this.mirrorStopMenuId, { visible: false });
+    if (this.mirrorStartMenuId != -1) {
+      NativeWindow.menu.update(this.mirrorStopMenuId, { visible: false });
+    }
   },
 
   serviceLost: function(aService) {
     if (aService.mirror && this.mirrorStartMenuId != -1) {
       let haveMirror = false;
       SimpleServiceDiscovery.services.forEach(function(service) {
         if (service.mirror) {
           haveMirror = true;
@@ -145,20 +154,24 @@ var CastingApps = {
       });
       if (!haveMirror) {
         NativeWindow.menu.remove(this.mirrorStartMenuId);
         this.mirrorStartMenuId = -1;
       }
     }
   },
 
-  isEnabled: function isEnabled() {
+  isCastingEnabled: function isCastingEnabled() {
     return Services.prefs.getBoolPref("browser.casting.enabled");
   },
 
+  isMirroringEnabled: function isMirroringEnabled() {
+    return Services.prefs.getBoolPref("browser.mirroring.enabled");
+  },
+
   observe: function (aSubject, aTopic, aData) {
     switch (aTopic) {
       case "Casting:Play":
         if (this.session && this.session.remoteMedia.status == "paused") {
           this.session.remoteMedia.play();
         }
         break;
       case "Casting:Pause":
--- a/mobile/android/chrome/content/WebrtcUI.js
+++ b/mobile/android/chrome/content/WebrtcUI.js
@@ -1,22 +1,17 @@
 /* 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/. */
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetter(this, "Notifications", "resource://gre/modules/Notifications.jsm");
-XPCOMUtils.defineLazyServiceGetter(this, "contentPrefs",
-                                   "@mozilla.org/content-pref/service;1",
-                                   "nsIContentPrefService2");
 
 var WebrtcUI = {
   _notificationId: null,
-  VIDEO_SOURCE: "videoSource",
-  AUDIO_SOURCE: "audioDevice",
 
   observe: function(aSubject, aTopic, aData) {
     if (aTopic === "getUserMedia:request") {
       this.handleRequest(aSubject, aTopic, aData);
     } else if (aTopic === "recording-device-events") {
       switch (aData) {
         case "shutdown":
         case "starting":
@@ -78,203 +73,144 @@ var WebrtcUI = {
   },
 
   handleRequest: function handleRequest(aSubject, aTopic, aData) {
     let constraints = aSubject.getConstraints();
     let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID);
 
     contentWindow.navigator.mozGetUserMediaDevices(
       constraints,
-      function (aDevices) {
-        WebrtcUI.prompt(contentWindow, aSubject.callID, constraints.audio, constraints.video, aDevices);
+      function (devices) {
+        WebrtcUI.prompt(contentWindow, aSubject.callID, constraints.audio,
+                        constraints.video, devices);
       },
-      Cu.reportError, aSubject.innerWindowID);
+      function (error) {
+        Cu.reportError(error);
+      },
+      aSubject.innerWindowID);
   },
 
-  getDeviceButtons: function(aAudioDevices, aVideoDevices, aCallID, aHost) {
+  getDeviceButtons: function(audioDevices, videoDevices, aCallID) {
     return [{
       label: Strings.browser.GetStringFromName("getUserMedia.denyRequest.label"),
-      callback: () => {
+      callback: function() {
         Services.obs.notifyObservers(null, "getUserMedia:response:deny", aCallID);
       }
-    }, {
+    },
+    {
       label: Strings.browser.GetStringFromName("getUserMedia.shareRequest.label"),
-      callback: (checked /* ignored */, inputs) => {
+      callback: function(checked /* ignored */, inputs) {
         let allowedDevices = Cc["@mozilla.org/supports-array;1"].createInstance(Ci.nsISupportsArray);
 
         let audioId = 0;
-        if (inputs && inputs[this.AUDIO_SOURCE] != undefined) {
-          audioId = inputs[this.AUDIO_SOURCE];
-        }
-
-        if (aAudioDevices[audioId]) {
-          allowedDevices.AppendElement(aAudioDevices[audioId]);
-          this.setDefaultDevice(this.AUDIO_SOURCE, aAudioDevices[audioId].name, aHost);
-        }
+        if (inputs && inputs.audioDevice != undefined)
+          audioId = inputs.audioDevice;
+        if (audioDevices[audioId])
+          allowedDevices.AppendElement(audioDevices[audioId]);
 
         let videoId = 0;
-        if (inputs && inputs[this.VIDEO_SOURCE] != undefined) {
-          videoId = inputs[this.VIDEO_SOURCE];
-        }
-
-        if (aVideoDevices[videoId]) {
-          allowedDevices.AppendElement(aVideoDevices[videoId]);
-          this.setDefaultDevice(this.VIDEO_SOURCE, aVideoDevices[videoId].name, aHost);
-        }
+        if (inputs && inputs.videoSource != undefined)
+          videoId = inputs.videoSource;
+        if (videoDevices[videoId])
+          allowedDevices.AppendElement(videoDevices[videoId]);
 
         Services.obs.notifyObservers(allowedDevices, "getUserMedia:response:allow", aCallID);
       }
     }];
   },
 
   // Get a list of string names for devices. Ensures that none of the strings are blank
   _getList: function(aDevices, aType) {
     let defaultCount = 0;
     return aDevices.map(function(device) {
-      let name = device.name;
-      // if this is a Camera input, convert the name to something readable
-      let res = /Camera\ \d+,\ Facing (front|back)/.exec(name);
-      if (res) {
-        return Strings.browser.GetStringFromName("getUserMedia." + aType + "." + res[1] + "Camera");
-      }
+        // if this is a Camera input, convert the name to something readable
+        let res = /Camera\ \d+,\ Facing (front|back)/.exec(device.name);
+        if (res)
+          return Strings.browser.GetStringFromName("getUserMedia." + aType + "." + res[1] + "Camera");
 
-      if (name.startsWith("&") && name.endsWith(";")) {
-        return Strings.browser.GetStringFromName(name.substring(1, name.length -1));
-      }
+        if (device.name.startsWith("&") && device.name.endsWith(";"))
+          return Strings.browser.GetStringFromName(device.name.substring(1, device.name.length -1));
 
-      if (name.trim() == "") {
-        defaultCount++;
-        return Strings.browser.formatStringFromName("getUserMedia." + aType + ".default", [defaultCount], 1);
-      }
-
-      return name;
-    }, this);
+        if (device.name.trim() == "") {
+          defaultCount++;
+          return Strings.browser.formatStringFromName("getUserMedia." + aType + ".default", [defaultCount], 1);
+        }
+        return device.name
+      }, this);
   },
 
-  _addDevicesToOptions: function(aDevices, aType, aOptions, aHost, aContext) {
-    if (aDevices.length == 0) {
-      return Promise.resolve(aOptions);
-    }
+  _addDevicesToOptions: function(aDevices, aType, aOptions, extraOptions) {
+    if (aDevices.length) {
 
-    let updateOptions = () => {
       // Filter out empty items from the list
       let list = this._getList(aDevices, aType);
+      if (extraOptions)
+        list = list.concat(extraOptions);
+
       if (list.length > 0) {
         aOptions.inputs.push({
           id: aType,
           type: "menulist",
           label: Strings.browser.GetStringFromName("getUserMedia." + aType + ".prompt"),
           values: list
         });
+
       }
-
-      return aOptions;
-    }
-
-    return this.getDefaultDevice(aType, aHost, aContext).then((defaultDevice) => {
-      aDevices.sort((a, b) => {
-        if (b.name === defaultDevice) return 1;
-        return 0;
-      });
-      return updateOptions();
-    }).catch(updateOptions);
-  },
-
-  // Sets the default for a aHost. If no aHost is specified, sets the browser wide default.
-  // Saving is async, but this doesn't wait for a result.
-  setDefaultDevice: function(aType, aValue, aHost, aContext) {
-    if (aHost) {
-      contentPrefs.set(aHost, "webrtc." + aType, aValue, aContext);
-    } else {
-      contentPrefs.setGlobal("webrtc." + aType, aValue, aContext);
     }
   },
 
-  _checkContentPref(aHost, aType, aContext) {
-    return new Promise((resolve, reject) => {
-      let result = null;
-      let handler = {
-        handleResult: (aResult) => result = aResult,
-        handleCompletion: function(aReason) {
-          if (aReason == Components.interfaces.nsIContentPrefCallback2.COMPLETE_OK &&
-              result instanceof Components.interfaces.nsIContentPref) {
-            resolve(result.value);
-          } else {
-            reject(result);
-          }
-        }
-      };
-
-      if (aHost) {
-        contentPrefs.getByDomainAndName(aHost, "webrtc." + aType, aContext, handler);
-      } else {
-        contentPrefs.getGlobal("webrtc." + aType, aContext, handler);
-      }
-    });
-  },
-
-  // Returns the default device for this aHost. If no aHost is specified, returns a browser wide default
-  getDefaultDevice: function(aType, aHost, aContext) {
-    return this._checkContentPref(aHost, aType, aContext).catch(() => {
-      // If we found nothing for the initial pref, try looking for a global one
-      return this._checkContentPref(null, aType, aContext);
-    });
-  },
-
-  prompt: function (aWindow, aCallID, aAudioRequested, aVideoRequested, aDevices) {
+  prompt: function prompt(aContentWindow, aCallID, aAudioRequested,
+                          aVideoRequested, aDevices) {
     let audioDevices = [];
     let videoDevices = [];
-
-    // Split up all the available aDevices into audio and video categories
     for (let device of aDevices) {
       device = device.QueryInterface(Ci.nsIMediaDevice);
       switch (device.type) {
       case "audio":
-        if (aAudioRequested) {
+        if (aAudioRequested)
           audioDevices.push(device);
-        }
         break;
       case "video":
-        if (aVideoRequested) {
+        if (aVideoRequested)
           videoDevices.push(device);
-        }
         break;
       }
     }
 
-    // Bsaed on the aTypes available, setup the prompt and icon text
     let requestType;
-    if (audioDevices.length && videoDevices.length) {
+    if (audioDevices.length && videoDevices.length)
       requestType = "CameraAndMicrophone";
-    } else if (audioDevices.length) {
+    else if (audioDevices.length)
       requestType = "Microphone";
-    } else if (videoDevices.length) {
+    else if (videoDevices.length)
       requestType = "Camera";
-    } else {
+    else
       return;
-    }
 
-    let host = aWindow.document.documentURIObject.host;
-    // Show the app name if this is a WebRT app, otherwise show the host.
+    let host = aContentWindow.document.documentURIObject.host;
     let requestor = BrowserApp.manifest ? "'" + BrowserApp.manifest.name  + "'" : host;
     let message = Strings.browser.formatStringFromName("getUserMedia.share" + requestType + ".message", [ requestor ], 1);
 
     let options = { inputs: [] };
     // if the users only option would be to select "No Audio" or "No Video"
     // i.e. we're only showing audio or only video and there is only one device for that type
     // don't bother showing a menulist to select from
-    if (videoDevices.length > 0 && audioDevices.length > 0) {
-      videoDevices.push({ name: Strings.browser.GetStringFromName("getUserMedia.videoSource.none") });
-      audioDevices.push({ name: Strings.browser.GetStringFromName("getUserMedia.audioDevice.none") });
+    var extraItems = null;
+    if (videoDevices.length > 1 || audioDevices.length > 0) {
+      // Only show the No Video option if there are also Audio devices to choose from
+      if (audioDevices.length > 0)
+        extraItems = [ Strings.browser.GetStringFromName("getUserMedia.videoSource.none") ];
+      // videoSource is both the string used for l10n lookup and the object that will be returned
+      this._addDevicesToOptions(videoDevices, "videoSource", options, extraItems);
     }
 
-    let loadContext = aWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-                             .getInterface(Ci.nsIWebNavigation)
-                             .QueryInterface(Ci.nsILoadContext);
-    // videoSource is both the string used for l10n lookup and the object that will be returned
-    this._addDevicesToOptions(videoDevices, this.VIDEO_SOURCE, options, host, loadContext).then((aOptions) => {
-      return this._addDevicesToOptions(audioDevices, this.AUDIO_SOURCE, aOptions, host, loadContext);
-    }).catch(Cu.reportError).then((aOptions) => {
-      let buttons = this.getDeviceButtons(audioDevices, videoDevices, aCallID, host);
-      NativeWindow.doorhanger.show(message, "webrtc-request", buttons, BrowserApp.selectedTab.id, aOptions);
-    });
+    if (audioDevices.length > 1 || videoDevices.length > 0) {
+      // Only show the No Audio option if there are also Video devices to choose from
+      if (videoDevices.length > 0)
+        extraItems = [ Strings.browser.GetStringFromName("getUserMedia.audioDevice.none") ];
+      this._addDevicesToOptions(audioDevices, "audioDevice", options, extraItems);
+    }
+
+    let buttons = this.getDeviceButtons(audioDevices, videoDevices, aCallID);
+
+    NativeWindow.doorhanger.show(message, "webrtc-request", buttons, BrowserApp.selectedTab.id, options);
   }
 }
--- a/mobile/android/modules/RokuApp.jsm
+++ b/mobile/android/modules/RokuApp.jsm
@@ -6,16 +6,20 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["RokuApp"];
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 
+const WEBRTC_PLAYER_NAME = "WebRTC Player";
+const MIRROR_PORT = 8011;
+const JSON_MESSAGE_TERMINATOR = "\r\n";
+
 function log(msg) {
   //Services.console.logStringMessage(msg);
 }
 
 const PROTOCOL_VERSION = 1;
 
 /* RokuApp is a wrapper for interacting with a Roku channel.
  * The basic interactions all use a REST API.
@@ -24,36 +28,39 @@ const PROTOCOL_VERSION = 1;
 function RokuApp(service) {
   this.service = service;
   this.resourceURL = this.service.location;
 #ifdef RELEASE_BUILD
   this.app = "Firefox";
 #else
   this.app = "Firefox Nightly";
 #endif
-  this.appID = -1;
+  this.mediaAppID = -1;
+  this.mirrorAppID = -1;
 }
 
 RokuApp.prototype = {
   status: function status(callback) {
     // We have no way to know if the app is running, so just return "unknown"
-    // but we use this call to fetch the appID for the given app name
+    // but we use this call to fetch the mediaAppID for the given app name
     let url = this.resourceURL + "query/apps";
     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
     xhr.open("GET", url, true);
     xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
     xhr.overrideMimeType("text/xml");
 
     xhr.addEventListener("load", (function() {
       if (xhr.status == 200) {
         let doc = xhr.responseXML;
         let apps = doc.querySelectorAll("app");
         for (let app of apps) {
           if (app.textContent == this.app) {
-            this.appID = app.id;
+            this.mediaAppID = app.id;
+          } else if (app.textContent == WEBRTC_PLAYER_NAME) {
+            this.mirrorAppID = app.id
           }
         }
       }
 
       // Since ECP has no way of telling us if an app is running, we always return "unknown"
       if (callback) {
         callback({ state: "unknown" });
       }
@@ -64,33 +71,33 @@ RokuApp.prototype = {
         callback({ state: "unknown" });
       }
     }).bind(this), false);
 
     xhr.send(null);
   },
 
   start: function start(callback) {
-    // We need to make sure we have cached the appID
-    if (this.appID == -1) {
+    // We need to make sure we have cached the mediaAppID
+    if (this.mediaAppID == -1) {
       this.status(function() {
-        // If we found the appID, use it to make a new start call
-        if (this.appID != -1) {
+        // If we found the mediaAppID, use it to make a new start call
+        if (this.mediaAppID != -1) {
           this.start(callback);
         } else {
           // We failed to start the app, so let the caller know
           callback(false);
         }
       }.bind(this));
       return;
     }
 
     // Start a given app with any extra query data. Each app uses it's own data scheme.
     // NOTE: Roku will also pass "source=external-control" as a param
-    let url = this.resourceURL + "launch/" + this.appID + "?version=" + parseInt(PROTOCOL_VERSION);
+    let url = this.resourceURL + "launch/" + this.mediaAppID + "?version=" + parseInt(PROTOCOL_VERSION);
     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
     xhr.open("POST", url, true);
     xhr.overrideMimeType("text/plain");
 
     xhr.addEventListener("load", (function() {
       if (callback) {
         callback(xhr.status === 200);
       }
@@ -124,25 +131,63 @@ RokuApp.prototype = {
         callback(false);
       }
     }).bind(this), false);
 
     xhr.send(null);
   },
 
   remoteMedia: function remoteMedia(callback, listener) {
-    if (this.appID != -1) {
+    if (this.mediaAppID != -1) {
       if (callback) {
         callback(new RemoteMedia(this.resourceURL, listener));
       }
     } else {
       if (callback) {
         callback();
       }
     }
+  },
+
+  mirror: function(callback, win, viewport, mirrorStartedCallback) {
+    if (this.mirrorAppID == -1) {
+      // The status function may not have been called yet if mirrorAppID is -1
+      this.status(this._createRemoteMirror.bind(this, callback, win, viewport, mirrorStartedCallback));
+    } else {
+      this._createRemoteMirror(callback, win, viewport, mirrorStartedCallback);
+    }
+  },
+
+  _createRemoteMirror: function(callback, win, viewport, mirrorStartedCallback) {
+    if (this.mirrorAppID == -1) {
+      // TODO: Inform user to install Roku WebRTC Player Channel.
+      log("RokuApp: Failed to find Mirror App ID.");
+    } else {
+      let url = this.resourceURL + "launch/" + this.mirrorAppID;
+      let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
+      xhr.open("POST", url, true);
+      xhr.overrideMimeType("text/plain");
+
+      xhr.addEventListener("load", (function() {
+        // 204 seems to be returned if the channel is already running
+        if ((xhr.status == 200) || (xhr.status == 204)) {
+          this.remoteMirror = new RemoteMirror(this.resourceURL, win, viewport, mirrorStartedCallback);
+        }
+      }).bind(this), false);
+
+      xhr.addEventListener("error", function() {
+        log("RokuApp: XHR Failed to launch application: " + WEBRTC_PLAYER_NAME);
+      }, false);
+
+      xhr.send(null);
+    }
+
+    if (callback) {
+      callback();
+    }
   }
 }
 
 /* RemoteMedia provides a wrapper for using TCP socket to control Roku apps.
  * The server implementation must be built into the Roku receiver app.
  */
 function RemoteMedia(url, listener) {
   this._url = url;
@@ -220,16 +265,158 @@ RemoteMedia.prototype = {
     // TODO: add position support
     this._sendMsg({ type: "PLAY" });
   },
 
   pause: function pause() {
     this._sendMsg({ type: "STOP" });
   },
 
-  load: function load(aData) {
-    this._sendMsg({ type: "LOAD", title: aData.title, source: aData.source, poster: aData.poster });
+  load: function load(data) {
+    this._sendMsg({ type: "LOAD", title: data.title, source: data.source, poster: data.poster });
   },
 
   get status() {
     return this._status;
   }
 }
+
+function RemoteMirror(url, win, viewport, mirrorStartedCallback) {
+  this._serverURI = Services.io.newURI(url , null, null);
+  this._window = win;
+  this._iceCandidates = [];
+  this.mirrorStarted = mirrorStartedCallback;
+
+  // This code insures the generated tab mirror is not wider than 800 nor taller than 600
+  // Better dimensions should be chosen after the Roku Channel is working.
+  let windowId = win.BrowserApp.selectedBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
+  let cWidth =  Math.max(viewport.cssWidth, viewport.width);
+  let cHeight = Math.max(viewport.cssHeight, viewport.height);
+
+  const MAX_WIDTH = 800;
+  const MAX_HEIGHT = 600;
+
+  let tWidth = 0;
+  let tHeight = 0;
+
+  if ((cWidth / MAX_WIDTH) > (cHeight / MAX_HEIGHT)) {
+    tHeight = Math.ceil((MAX_WIDTH / cWidth) * cHeight);
+    tWidth = MAX_WIDTH;
+  } else {
+    tWidth = Math.ceil((MAX_HEIGHT / cHeight) * cWidth);
+    tHeight = MAX_HEIGHT;
+  }
+
+  let constraints = {
+    video: {
+      mediaSource: "browser",
+      browserWindow: windowId,
+      scrollWithPage: true,
+      advanced: [
+        {
+          width: { min: tWidth, max: tWidth },
+          height: { min: tHeight, max: tHeight }
+        },
+        { aspectRatio: cWidth / cHeight }
+      ]
+    }
+  };
+
+  this._window.navigator.mozGetUserMedia(constraints, this._onReceiveGUMStream.bind(this), function() {});
+}
+
+RemoteMirror.prototype = {
+  _sendOffer: function(offer) {
+    if (!this._baseSocket) {
+      this._baseSocket = Cc["@mozilla.org/tcp-socket;1"].createInstance(Ci.nsIDOMTCPSocket);
+    }
+    this._jsonOffer = JSON.stringify(offer);
+    this._socket = this._baseSocket.open(this._serverURI.host, MIRROR_PORT, { useSecureTransport: false, binaryType: "string" });
+    this._socket.onopen = this._onSocketOpen.bind(this);
+    this._socket.ondata = this._onSocketData.bind(this);
+    this._socket.onerror = this._onSocketError.bind(this);
+  },
+
+  _onReceiveGUMStream: function(stream) {
+    this._pc = new this._window.mozRTCPeerConnection;
+    this._pc.addStream(stream);
+    this._pc.onicecandidate = (evt => {
+      // Usually the last candidate is null, expected?
+      if (!evt.candidate) {
+        return;
+      }
+      let jsonCandidate = JSON.stringify(evt.candidate);
+      this._iceCandidates.push(jsonCandidate);
+      this._sendIceCandidates();
+    });
+
+    this._pc.createOffer(offer => {
+      this._pc.setLocalDescription(
+        new this._window.mozRTCSessionDescription(offer),
+        () => this._sendOffer(offer),
+        () => log("RemoteMirror: Failed to set local description."));
+    },
+    () => log("RemoteMirror: Failed to create offer."));
+  },
+
+  _stopMirror: function() {
+    if (this._socket) {
+      this._socket.close();
+      this._socket = null;
+    }
+    if (this._pc) {
+      this._pc.close();
+      this._pc = null;
+    }
+    this._jsonOffer = null;
+    this._iceCandidates = [];
+  },
+
+  _onSocketData: function(response) {
+    if (response.type == "data") {
+      response.data.split(JSON_MESSAGE_TERMINATOR).forEach(data => {
+        if (data) {
+          let parsedData = JSON.parse(data);
+          if (parsedData.type == "answer") {
+            this._pc.setRemoteDescription(
+              new this._window.mozRTCSessionDescription(parsedData),
+              () => this.mirrorStarted(this._stopMirror.bind(this)),
+              () => log("RemoteMirror: Failed to set remote description."));
+          } else {
+            this._pc.addIceCandidate(new this._window.mozRTCIceCandidate(parsedData))
+          }
+        } else {
+          log("RemoteMirror: data is null");
+        }
+      });
+    } else if (response.type == "error") {
+      log("RemoteMirror: Got socket error.");
+      this._stopMirror();
+    } else {
+      log("RemoteMirror: Got unhandled socket event: " + response.type);
+    }
+  },
+
+  _onSocketError: function(err) {
+    log("RemoteMirror: Error socket.onerror: " + (err.data ? err.data : "NO DATA"));
+    this._stopMirror();
+  },
+
+  _onSocketOpen: function() {
+    this._open = true;
+    if (this._jsonOffer) {
+      let jsonOffer = this._jsonOffer + JSON_MESSAGE_TERMINATOR;
+      this._socket.send(jsonOffer, jsonOffer.length);
+      this._jsonOffer = null;
+      this._sendIceCandidates();
+    }
+  },
+
+  _sendIceCandidates: function() {
+    if (this._socket && this._open) {
+      this._iceCandidates.forEach(value => {
+        value = value + JSON_MESSAGE_TERMINATOR;
+        this._socket.send(value, value.length);
+      });
+      this._iceCandidates = [];
+    }
+  }
+};
--- a/mobile/android/modules/SimpleServiceDiscovery.jsm
+++ b/mobile/android/modules/SimpleServiceDiscovery.jsm
@@ -404,16 +404,20 @@ var SimpleServiceDiscovery = {
   _addService: function(service) {
     // Filter out services that do not match the device filter
     if (!this._filterService(service)) {
       return;
     }
 
     // Only add and notify if we don't already know about this service
     if (!this._services.has(service.uuid)) {
+      let device = this._devices.get(service.target);
+      if (device && device.mirror) {
+        service.mirror = true;
+      }
       this._services.set(service.uuid, service);
       Services.obs.notifyObservers(null, EVENT_SERVICE_FOUND, service.uuid);
     }
 
     // Make sure we remember this service is not stale
     this._services.get(service.uuid).lastPing = this._searchTimestamp;
   }
 }
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -0,0 +1,295 @@
+/* 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/. */
+
+"use strict";
+
+/**
+ * This module provides an asynchronous API for managing bookmarks.
+ *
+ * Bookmarks are organized in a tree structure, and can be bookmarked URIs,
+ * folders or separators.  Multiple bookmarks for the same URI are allowed.
+ *
+ * Note that if you are handling bookmarks operations in the UI, you should
+ * not use this API directly, but rather use PlacesTransactions.jsm, so that
+ * any operation is undo/redo-able.
+ *
+ * Each bookmarked item is represented by an object having the following
+ * properties:
+ *
+ *  - guid (string)
+ *      The globally unique identifier of the item.
+ *  - parentGuid (string)
+ *      The globally unique identifier of the folder containing the item.
+ *      This will be an empty string for the Places root folder.
+ *  - index (number)
+ *      The 0-based position of the item in the parent folder.
+ *  - dateAdded (number, microseconds from the epoch)
+ *      The time at which the item was added.  This is a PRTime (microseconds).
+ *  - lastModified (number, microseconds from the epoch)
+ *      The time at which the item was last modified. This is a PRTime (microseconds).
+ *  - type (number)
+ *      The item's type, either TYPE_BOOKMARK, TYPE_FOLDER or TYPE_SEPARATOR.
+ *
+ *  The following properties are only valid for bookmarks or folders.
+ *
+ *  - title (string)
+ *      The item's title, if any.  Empty titles and null titles are considered
+ *      the same and the property is unset on retrieval in such a case.
+ *
+ *  The following properties are only valid for bookmarks:
+ *
+ *  - uri (nsIURI)
+ *      The item's URI.
+ *  - keyword (string)
+ *      The associated keyword, if any.
+ *
+ * Each successful operation notifies through the nsINavBookmarksObserver
+ * interface.  To listen to such notifications you must register using
+ * nsINavBookmarksService addObserver and removeObserver methods.
+ * Note that bookmark addition or order changes won't notify onItemMoved for
+ * items that have their indexes changed.
+ * Similarly, lastModified changes not done explicitly (like changing another
+ * property) won't fire an onItemChanged notification for the lastModified
+ * property.
+ * @see nsINavBookmarkObserver
+ *
+ * @note livemarks are implemented as empty folders.
+ *       @see mozIAsyncLivemarks.idl
+ */
+
+this.EXPORTED_SYMBOLS = [ "Bookmarks" ];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+                                  "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+                                  "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+                                  "resource://gre/modules/Sqlite.jsm");
+
+const URI_LENGTH_MAX = 65536;
+const TITLE_LENGTH_MAX = 4096;
+
+let Bookmarks = Object.freeze({
+  /**
+   * Item's type constants.
+   * These should stay consistent with nsINavBookmarksService.idl
+   */
+  TYPE_BOOKMARK: 1,
+  TYPE_FOLDER: 2,
+  TYPE_SEPARATOR: 3,
+
+  /**
+   * Creates or updates a bookmarked item.
+   *
+   * If the given guid is found the corresponding item is updated, otherwise,
+   * if no guid is provided, a bookmark is created and a new guid is assigned
+   * to it.
+   *
+   * In the creation case, a minimum set of properties must be provided:
+   *  - type
+   *  - parentGuid
+   *  - URI, only for bookmarks
+   * If an index is not specified, it defaults to appending.
+   * It's also possible to pass a non-existent guid to force creation of an
+   * item with the given guid, but unless you have a very sound reason, such as
+   * an undo manager implementation or synchronization, you should not do that.
+   *
+   * In the update case, you should only set the properties which should be
+   * changed, undefined properties won't be taken into account for the update.
+   * Moreover, the item's type and the guid are ignored, since they are
+   * immutable after creation.  Note that if the passed in values are not
+   * coherent with the known values, this rejects.
+   * Passing null or an empty string as keyword clears any keyword
+   * associated with this bookmark.
+   *
+   * Note that any known property that doesn't apply to the specific item type
+   * causes rejection.
+   *
+   * @param info
+   *        object representing a bookmarked item, as defined above.
+   *
+   * @return {Promise} resolved when the update is complete.
+   * @resolves to the input object, updated with relevant information.
+   * @rejects JavaScript exception.
+   *
+   * @note title is truncated to TITLE_LENGTH_MAX and URI is rejected if
+   *       greater than URI_LENGTH_MAX.
+   */
+  // XXX WIP XXX Will replace functionality from these methods:
+  // long long insertBookmark(in long long aParentId, in nsIURI aURI, in long aIndex, in AUTF8String aTitle, [optional] in ACString aGUID);
+  // long long createFolder(in long long aParentFolder, in AUTF8String name, in long index, [optional] in ACString aGUID);
+  // void moveItem(in long long aItemId, in long long aNewParentId, in long aIndex);
+  // long long insertSeparator(in long long aParentId, in long aIndex, [optional] in ACString aGUID);
+  // void setItemTitle(in long long aItemId, in AUTF8String aTitle);
+  // void setItemDateAdded(in long long aItemId, in PRTime aDateAdded);
+  // void setItemLastModified(in long long aItemId, in PRTime aLastModified);
+  // void changeBookmarkURI(in long long aItemId, in nsIURI aNewURI);
+  // void setKeywordForBookmark(in long long aItemId, in AString aKeyword);
+  update: Task.async(function* (info) {
+    throw new Error("Not yet implemented");
+  }),
+
+  /**
+   * Removes a bookmarked item.
+   *
+   * Input can either be a guid or an object with one of the following
+   * properties set:
+   *  - guid: if set, only the corresponding item is removed.
+   *  - parentGuid: if it's set and is a folder, any children of that folder is
+   *                removed, but not the folder itself.
+   *  - URI: if set, any bookmark for that URI is removed.
+   * If multiple of these properties are set, the method rejects.
+   *
+   * Any other property is ignored, known properties may be overwritten.
+   *
+   * @param guidOrInfo
+   *        The globally unique identifier of the item to remove, or an
+   *        object representing it, as defined above.
+   *
+   * @return {Promise} resolved when the removal is complete.
+   * @resolves to the removed object or an array of them.
+   * @rejects JavaScript exception.
+   */
+  // XXX WIP XXX Will replace functionality from these methods:
+  // removeItem(in long long aItemId);
+  // removeFolderChildren(in long long aItemId);
+  remove: Task.async(function* (guidOrInfo) {
+    throw new Error("Not yet implemented");
+  }),
+
+  /**
+   * Fetches information about a bookmarked item.
+   *
+   * Input can be either a guid or an object with one, and only one, of these
+   * filtering properties set:
+   *  - guid
+   *      retrieves the item with the specified guid
+   *  - parentGuid and index
+   *      retrieves the item by its position
+   *  - URI
+   *      retrieves all items having the given URI.
+   *  - keyword
+   *      retrieves all items having the given keyword.
+   *
+   * Any other property is ignored.  Known properties may be overwritten.
+   *
+   * @param guidOrInfo
+   *        The globally unique identifier of the item to fetch, or an
+   *        object representing it, as defined above.
+   *
+   * @return {Promise} resolved when the fetch is complete.
+   * @resolves to an object representing the found item, as described above, or
+   *           an array of such objects.  if no item is found, the returned
+   *           promise is resolved to null.
+   * @rejects JavaScript exception.
+   */
+  // XXX WIP XXX Will replace functionality from these methods:
+  // long long getIdForItemAt(in long long aParentId, in long aIndex);
+  // AUTF8String getItemTitle(in long long aItemId);
+  // PRTime getItemDateAdded(in long long aItemId);
+  // PRTime getItemLastModified(in long long aItemId);
+  // nsIURI getBookmarkURI(in long long aItemId);
+  // long getItemIndex(in long long aItemId);
+  // unsigned short getItemType(in long long aItemId);
+  // boolean isBookmarked(in nsIURI aURI);
+  // long long getFolderIdForItem(in long long aItemId);
+  // void getBookmarkIdsForURI(in nsIURI aURI, [optional] out unsigned long count, [array, retval, size_is(count)] out long long bookmarks);
+  // AString getKeywordForURI(in nsIURI aURI);
+  // AString getKeywordForBookmark(in long long aItemId);
+  // nsIURI getURIForKeyword(in AString keyword);
+  fetch: Task.async(function* (guidOrInfo) {
+    throw new Error("Not yet implemented");
+  }),
+
+  /**
+   * Retrieves an object representation of a bookmarked item, along with all of
+   * its descendants, if any.
+   *
+   * Each node in the tree is an object that extends
+   * the item representation described above with some additional properties:
+   *
+   *  - [deprecated] id (number)
+   *      the item's id.  Defined only if aOptions.includeItemIds is set.
+   *  - annos (array)
+   *      the item's annotations.  This is not set if there are no annotations
+   *      set for the item.
+   *
+   * The root object of the tree also has the following properties set:
+   *  - itemsCount (number, not enumerable)
+   *      the number of items, including the root item itself, which are
+   *      represented in the resolved object.
+   *
+   * Bookmarked URIs may also have the following properties:
+   *  - tags (string)
+   *      csv string of the bookmark's tags, if any.
+   *  - charset (string)
+   *      the last known charset of the bookmark, if any.
+   *  - iconuri (string)
+   *      the bookmark's favicon URL, if any.
+   *
+   * Folders may also have the following properties:
+   *  - children (array)
+   *      the folder's children information, each of them having the same set of
+   *      properties as above.
+   *
+   * @param [optional] guid
+   *        the topmost item to be queried.  If it's not passed, the Places
+   *        root folder is queried: that is, you get a representation of the
+   *        entire bookmarks hierarchy.
+   * @param [optional] options
+   *        Options for customizing the query behavior, in the form of an
+   *        object with any of the following properties:
+   *         - excludeItemsCallback: a function for excluding items, along with
+   *           their descendants.  Given an item object (that has everything set
+   *           apart its potential children data), it should return true if the
+   *           item should be excluded.  Once an item is excluded, the function
+   *           isn't called for any of its descendants.  This isn't called for
+   *           the root item.
+   *           WARNING: since the function may be called for each item, using
+   *           this option can slow down the process significantly if the
+   *           callback does anything that's not relatively trivial.  It is
+   *           highly recommended to avoid any synchronous I/O or DB queries.
+   *         - includeItemIds: opt-in to include the deprecated id property.
+   *           Use it if you must. It'll be removed once the switch to guids is
+   *           complete.
+   *
+   * @return {Promise} resolved when the fetch is complete.
+   * @resolves to an object that represents either a single item or a
+   *           bookmarks tree.  if guid points to a non-existent item, the
+   *           returned promise is resolved to null.
+   * @rejects JavaScript exception.
+   */
+  // XXX WIP XXX: will replace functionality for these methods:
+  // PlacesUtils.promiseBookmarksTree()
+  fetchTree: Task.async(function* (guid = "", options = {}) {
+    throw new Error("Not yet implemented");
+  }),
+
+  /**
+   * Reorders contents of a folder based on a provided array of GUIDs.
+   *
+   * @param parentGuid
+   *        The globally unique identifier of the folder whose contents should
+   *        be reordered.
+   * @param orderedChildrenGuids
+   *        Ordered array of the children's GUIDs.  If this list contains
+   *        non-existing entries they will be ignored.  If the list is
+   *        incomplete, missing entries will be appended.
+   *
+   * @return {Promise} resolved when reordering is complete.
+   * @rejects JavaScript exception.
+   */
+  // XXX WIP XXX Will replace functionality from these methods:
+  // void setItemIndex(in long long aItemId, in long aNewIndex);
+  reorder: Task.async(function* (parentGuid, orderedChildrenGuids) {
+    throw new Error("Not yet implemented");
+  })
+});
--- a/toolkit/components/places/PlacesUtils.jsm
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -20,43 +20,35 @@ this.EXPORTED_SYMBOLS = [
 , "PlacesEditBookmarkPostDataTransaction"
 , "PlacesEditItemDateAddedTransaction"
 , "PlacesEditItemLastModifiedTransaction"
 , "PlacesSortFolderByNameTransaction"
 , "PlacesTagURITransaction"
 , "PlacesUntagURITransaction"
 ];
 
-const Ci = Components.interfaces;
-const Cc = Components.classes;
-const Cr = Components.results;
-const Cu = Components.utils;
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
-
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
-
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
-
 XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
                                   "resource://gre/modules/Sqlite.jsm");
-
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
-
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
                                   "resource://gre/modules/Promise.jsm");
-
 XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
                                   "resource://gre/modules/Deprecated.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Bookmarks",
+                                  "resource://gre/modules/Bookmarks.jsm");
 
 // The minimum amount of transactions before starting a batch. Usually we do
 // do incremental updates, a batch will cause views to completely
 // refresh instead.
 const MIN_TRANSACTIONS_FOR_BATCH = 5;
 
 #ifdef XP_MACOSX
 // On Mac OSX, the transferable system converts "\r\n" to "\n\n", where we
@@ -1829,19 +1821,24 @@ XPCOMUtils.defineLazyServiceGetter(Place
 XPCOMUtils.defineLazyGetter(PlacesUtils, "bhistory", function() {
   return PlacesUtils.history;
 });
 
 XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "favicons",
                                    "@mozilla.org/browser/favicon-service;1",
                                    "mozIAsyncFavicons");
 
-XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "bookmarks",
-                                   "@mozilla.org/browser/nav-bookmarks-service;1",
-                                   "nsINavBookmarksService");
+XPCOMUtils.defineLazyGetter(PlacesUtils, "bookmarks", () => {
+  let bm = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]
+             .getService(Ci.nsINavBookmarksService);
+  return Object.freeze(new Proxy(bm, {
+    get: (target, name) => target.hasOwnProperty(name) ? target[name]
+                                                       : Bookmarks[name]
+  }));
+});
 
 XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "annotations",
                                    "@mozilla.org/browser/annotation-service;1",
                                    "nsIAnnotationService");
 
 XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "tagging",
                                    "@mozilla.org/browser/tagging-service;1",
                                    "nsITaggingService");
--- a/toolkit/components/places/moz.build
+++ b/toolkit/components/places/moz.build
@@ -57,16 +57,17 @@ if CONFIG['MOZ_PLACES']:
 
     LOCAL_INCLUDES += [
         '../build',
     ]
 
     EXTRA_JS_MODULES += [
         'BookmarkHTMLUtils.jsm',
         'BookmarkJSONUtils.jsm',
+        'Bookmarks.jsm',
         'ClusterLib.js',
         'ColorAnalyzer_worker.js',
         'ColorConversion.js',
         'PlacesBackups.jsm',
         'PlacesDBUtils.jsm',
         'PlacesSearchAutocompleteProvider.jsm',
         'PlacesTransactions.jsm',
     ]
--- a/toolkit/devtools/apps/tests/debugger-protocol-helper.js
+++ b/toolkit/devtools/apps/tests/debugger-protocol-helper.js
@@ -2,16 +2,19 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 
 const { DebuggerServer } = Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
 const { DebuggerClient } = Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
+const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+const {require} = devtools;
+
 
 const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm");
 const { Services } = Cu.import("resource://gre/modules/Services.jsm");
 
 let gClient, gActor;
 
 function connect(onDone) {
 
@@ -109,14 +112,51 @@ addMessageListener("install", function (
     webappActorRequest(request, function (aResponse) {
       sendAsyncMessage("installed", aResponse);
     });
   } catch(e) {
     dump("installTestApp exception: " + e + "\n");
   }
 });
 
+addMessageListener("getAppActor", function (aMessage) {
+  let { manifestURL } = aMessage;
+  let request = {type: "getAppActor", manifestURL: manifestURL};
+  webappActorRequest(request, function (aResponse) {
+    sendAsyncMessage("appActor", aResponse);
+  });
+});
+
+let Frames = [];
+addMessageListener("addFrame", function (aMessage) {
+  let win = Services.wm.getMostRecentWindow("navigator:browser");
+  let doc = win.document;
+  let frame = doc.createElementNS("http://www.w3.org/1999/xhtml", "iframe");
+  frame.setAttribute("mozbrowser", "true");
+  if (aMessage.mozapp) {
+    frame.setAttribute("mozapp", aMessage.mozapp);
+  }
+  if (aMessage.remote) {
+    frame.setAttribute("remote", aMessage.remote);
+  }
+  if (aMessage.src) {
+    frame.setAttribute("src", aMessage.src);
+  }
+  doc.documentElement.appendChild(frame);
+  Frames.push(frame);
+  sendAsyncMessage("frameAdded");
+});
+
 addMessageListener("cleanup", function () {
   webappActorRequest({type: "unwatchApps"}, function () {
     gClient.close();
   });
 });
 
+let AppFramesMock = {
+  list: function () {
+    return Frames;
+  },
+  addObserver: function () {},
+  removeObserver: function () {}
+};
+
+require("devtools/server/actors/webapps").setAppFramesMock(AppFramesMock);
--- a/toolkit/devtools/apps/tests/test_webapps_actor.html
+++ b/toolkit/devtools/apps/tests/test_webapps_actor.html
@@ -220,16 +220,31 @@ var steps = [
       manifestURL: CERTIFIED_APP_MANIFEST
     }, true);
   },
   function() {
     info("== SETUP == Disable certified app access");
     SpecialPowers.popPrefEnv(next);
   },
   function() {
+    info("== TEST == Get packaged app actor");
+    addFrame(
+      { mozapp: PACKAGED_APP_MANIFEST, remote: true },
+      function () {
+        getAppActor(PACKAGED_APP_MANIFEST, function (response) {
+          let tabActor = response.actor;
+          ok(!!tabActor, "TabActor is correctly instanciated in child.js");
+          ok("actor" in tabActor, "Tab actor is available in child");
+          ok("consoleActor" in tabActor, "Console actor is available in child");
+          next();
+        });
+      });
+
+  },
+  function() {
     info("== TEST == Uninstall packaged app");
     uninstall(PACKAGED_APP_MANIFEST);
   },
   function() {
     info("== TEST == Uninstall certified app");
     uninstall(CERTIFIED_APP_MANIFEST);
   },
   function() {
@@ -304,12 +319,28 @@ function uninstall(manifestURL) {
   });
 
   mm.sendAsyncMessage("appActorRequest", {
     type: "uninstall",
     manifestURL: manifestURL
   });
 }
 
+function getAppActor(manifestURL, callback) {
+  mm.addMessageListener("appActor", function onAppActor(aResponse) {
+    mm.removeMessageListener("appActor", onAppActor);
+    callback(aResponse);
+  });
+  mm.sendAsyncMessage("getAppActor", { manifestURL: manifestURL });
+}
+
+function addFrame(options, callback) {
+  mm.addMessageListener("frameAdded", function onFrameAdded() {
+    mm.removeMessageListener("frameAdded", onFrameAdded);
+    callback();
+  });
+  mm.sendAsyncMessage("addFrame", options);
+}
+
 </script>
 </pre>
 </body>
 </html>
--- a/toolkit/devtools/server/actors/webapps.js
+++ b/toolkit/devtools/server/actors/webapps.js
@@ -1,27 +1,39 @@
 /* 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/. */
 
 "use strict";
 
-let Cu = Components.utils;
-let Cc = Components.classes;
-let Ci = Components.interfaces;
-let CC = Components.Constructor;
+let {Cu, Cc, Ci} = require("chrome");
 
 Cu.import("resource://gre/modules/NetUtil.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/osfile.jsm");
 Cu.import("resource://gre/modules/FileUtils.jsm");
 
 let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
 
+let DevToolsUtils = require("devtools/toolkit/DevToolsUtils");
+let { ActorPool } = require("devtools/server/actors/common");
+let { DebuggerServer } = require("devtools/server/main");
+let Services = require("Services");
+
+let AppFramesMock = null;
+
+exports.setAppFramesMock = function (mock) {
+  AppFramesMock = mock;
+}
+
 DevToolsUtils.defineLazyGetter(this, "AppFrames", () => {
+  // Offer a way for unit test to provide a mock
+  if (AppFramesMock) {
+    return AppFramesMock;
+  }
   try {
     return Cu.import("resource://gre/modules/AppFrames.jsm", {}).AppFrames;
   } catch(e) {}
   return null;
 });
 
 function debug(aMsg) {
   /*
@@ -513,21 +525,21 @@ WebappsActor.prototype = {
           jar.append("application.zip");
           Services.obs.notifyObservers(jar, "flush-cache-entry", null);
 
           // And then in app content process
           // This function will be evaluated in the scope of the content process
           // frame script. That will flush the jar cache for this app and allow
           // loading fresh updated resources if we reload its document.
           let FlushFrameScript = function (path) {
-            let jar = Components.classes["@mozilla.org/file/local;1"]
-                                .createInstance(Components.interfaces.nsILocalFile);
+            let jar = Cc["@mozilla.org/file/local;1"]
+                        .createInstance(Ci.nsILocalFile);
             jar.initWithPath(path);
-            let obs = Components.classes["@mozilla.org/observer-service;1"]
-                                .getService(Components.interfaces.nsIObserverService);
+            let obs = Cc["@mozilla.org/observer-service;1"]
+                        .getService(Ci.nsIObserverService);
             obs.notifyObservers(jar, "flush-cache-entry", null);
           };
           for each (let frame in self._appFrames()) {
             if (frame.getAttribute("mozapp") == manifestURL) {
               let mm = frame.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader.messageManager;
               mm.loadFrameScript("data:," +
                 encodeURIComponent("(" + FlushFrameScript.toString() + ")" +
                                    "('" + jar.path + "')"), false);
@@ -1033,9 +1045,9 @@ WebappsActor.prototype.requestTypes = {
   "uninstall": WebappsActor.prototype.uninstall,
   "listRunningApps": WebappsActor.prototype.listRunningApps,
   "getAppActor": WebappsActor.prototype.getAppActor,
   "watchApps": WebappsActor.prototype.watchApps,
   "unwatchApps": WebappsActor.prototype.unwatchApps,
   "getIconAsDataURL": WebappsActor.prototype.getIconAsDataURL
 };
 
-DebuggerServer.addGlobalActor(WebappsActor, "webappsActor");
+exports.WebappsActor = WebappsActor;
--- a/toolkit/devtools/server/child.js
+++ b/toolkit/devtools/server/child.js
@@ -1,14 +1,16 @@
 /* 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/. */
 
 "use strict";
 
+try {
+
 let chromeGlobal = this;
 
 // Encapsulate in its own scope to allows loading this frame script
 // more than once.
 (function () {
   let Cu = Components.utils;
   let { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
   const DevToolsUtils = devtools.require("devtools/toolkit/DevToolsUtils.js");
@@ -59,8 +61,12 @@ let chromeGlobal = this;
     let conn = connections.get(childID);
     if (conn) {
       conn.close();
       connections.delete(childID);
     }
   });
   addMessageListener("debug:disconnect", onDisconnect);
 })();
+
+} catch(e) {
+  dump("Exception in app child process: " + e + "\n");
+}
--- a/toolkit/devtools/server/main.js
+++ b/toolkit/devtools/server/main.js
@@ -437,17 +437,21 @@ var DebuggerServer = {
       this.addGlobalActor(ChromeDebuggerActor, "chromeDebugger");
       this.registerModule("devtools/server/actors/preference", {
         prefix: "preference",
         constructor: "PreferenceActor",
         type: { global: true }
       });
     }
 
-    this.addActors("resource://gre/modules/devtools/server/actors/webapps.js");
+    this.registerModule("devtools/server/actors/webapps", {
+      prefix: "webapps",
+      constructor: "WebappsActor",
+      type: { global: true }
+    });
     this.registerModule("devtools/server/actors/device", {
       prefix: "device",
       constructor: "DeviceActor",
       type: { global: true }
     });
   },
 
   /**
--- a/toolkit/modules/Sqlite.jsm
+++ b/toolkit/modules/Sqlite.jsm
@@ -32,16 +32,22 @@ XPCOMUtils.defineLazyServiceGetter(this,
                                    "@mozilla.org/toolkit/finalizationwitness;1",
                                    "nsIFinalizationWitnessService");
 
 
 // Counts the number of created connections per database basename(). This is
 // used for logging to distinguish connection instances.
 let connectionCounters = new Map();
 
+// Tracks identifiers of wrapped connections, that are Storage connections
+// opened through mozStorage and then wrapped by Sqlite.jsm to use its syntactic
+// sugar API.  Since these connections have an unknown origin, we use this set
+// to differentiate their behavior.
+let wrappedConnections = new Set();
+
 /**
  * Once `true`, reject any attempt to open or close a database.
  */
 let isClosed = false;
 
 let Debugging = {
   // Tests should fail if a connection auto closes.  The exception is
   // when finalization itself is tested, in which case this flag
@@ -62,16 +68,30 @@ function logScriptError(message) {
   // flag can be used to suppress this for tests that explicitly
   // test auto closes.
   if (Debugging.failTestsOnAutoClose) {
     Promise.reject(new Error(message));
   }
 }
 
 /**
+ * Gets connection identifier from its database file path.
+ *
+ * @param path
+ *        A file string path pointing to a database file.
+ * @return the connection identifier.
+ */
+function getIdentifierByPath(path) {
+  let basename = OS.Path.basename(path);
+  let number = connectionCounters.get(basename) || 0;
+  connectionCounters.set(basename, number + 1);
+  return basename + "#" + number;
+}
+
+/**
  * Barriers used to ensure that Sqlite.jsm is shutdown after all
  * its clients.
  */
 XPCOMUtils.defineLazyGetter(this, "Barriers", () => {
   let Barriers = {
     /**
      * Public barrier that clients may use to add blockers to the
      * shutdown of Sqlite.jsm. Triggered by profile-before-change.
@@ -90,27 +110,27 @@ XPCOMUtils.defineLazyGetter(this, "Barri
 
   /**
    * Observer for the event which is broadcasted when the finalization
    * witness `_witness` of `OpenedConnection` is garbage collected.
    *
    * The observer is passed the connection identifier of the database
    * connection that is being finalized.
    */
-  let finalizationObserver = function (subject, topic, connectionIdentifier) {
-    let connectionData = ConnectionData.byId.get(connectionIdentifier);
+  let finalizationObserver = function (subject, topic, identifier) {
+    let connectionData = ConnectionData.byId.get(identifier);
 
     if (connectionData === undefined) {
       logScriptError("Error: Attempt to finalize unknown Sqlite connection: " +
-                     connectionIdentifier + "\n");
+                     identifier + "\n");
       return;
     }
 
-    ConnectionData.byId.delete(connectionIdentifier);
-    logScriptError("Warning: Sqlite connection '" + connectionIdentifier +
+    ConnectionData.byId.delete(identifier);
+    logScriptError("Warning: Sqlite connection '" + identifier +
                    "' was not properly closed. Auto-close triggered by garbage collection.\n");
     connectionData.close();
   };
   Services.obs.addObserver(finalizationObserver, "sqlite-finalization-witness", false);
 
   /**
    * Ensure that Sqlite.jsm:
    * - informs its clients before shutting down;
@@ -165,23 +185,27 @@ XPCOMUtils.defineLazyGetter(this, "Barri
  * a garbage collection of a finalization witness in
  * OpenedConnection. When the witness detects a garbage collection,
  * this object can be used to close the connection.
  *
  * This object contains more methods than just `close`.  When
  * OpenedConnection needs to use the methods in this object, it will
  * dispatch its method calls here.
  */
-function ConnectionData(connection, basename, number, options) {
-  this._log = Log.repository.getLoggerWithMessagePrefix("Sqlite.Connection." + basename,
-                                                        "Conn #" + number + ": ");
+function ConnectionData(connection, identifier, options={}) {
+  this._log = Log.repository.getLoggerWithMessagePrefix("Sqlite.Connection." +
+                                                        identifier + ": ");
   this._log.info("Opened");
 
   this._dbConn = connection;
-  this._connectionIdentifier = basename + " Conn #" + number;
+
+  // This is a unique identifier for the connection, generated through
+  // getIdentifierByPath.  It may be used for logging or as a key in Maps.
+  this._identifier = identifier;
+
   this._open = true;
 
   this._cachedStatements = new Map();
   this._anonymousStatements = new Map();
   this._anonymousCounter = 0;
 
   // A map from statement index to mozIStoragePendingStatement, to allow for
   // canceling prior to finalizing the mozIStorageStatements.
@@ -199,37 +223,37 @@ function ConnectionData(connection, base
     // We wait for the first statement execute to start the timer because
     // shrinking now would not do anything.
   }
 
   this._deferredClose = Promise.defer();
   this._closeRequested = false;
 
   Barriers.connections.client.addBlocker(
-    this._connectionIdentifier + ": waiting for shutdown",
+    this._identifier + ": waiting for shutdown",
     this._deferredClose.promise,
     () =>  ({
-      identifier: this._connectionIdentifier,
+      identifier: this._identifier,
       isCloseRequested: this._closeRequested,
       hasDbConn: !!this._dbConn,
       hasInProgressTransaction: !!this._inProgressTransaction,
       pendingStatements: this._pendingStatements.size,
       statementCounter: this._statementCounter,
     })
   );
 }
 
 /**
  * Map of connection identifiers to ConnectionData objects
  *
  * The connection identifier is a human-readable name of the
  * database. Used by finalization witnesses to be able to close opened
  * connections on garbage collection.
  *
- * Key: _connectionIdentifier of ConnectionData
+ * Key: _identifier of ConnectionData
  * Value: ConnectionData object
  */
 ConnectionData.byId = new Map();
 
 ConnectionData.prototype = Object.freeze({
   close: function () {
     this._closeRequested = true;
 
@@ -299,25 +323,33 @@ ConnectionData.prototype = Object.freeze
       statement.finalize();
     }
     this._cachedStatements.clear();
 
     // This guards against operations performed between the call to this
     // function and asyncClose() finishing. See also bug 726990.
     this._open = false;
 
-    this._log.debug("Calling asyncClose().");
-    this._dbConn.asyncClose(() => {
+    // We must always close the connection at the Sqlite.jsm-level, not
+    // necessarily at the mozStorage-level.
+    let markAsClosed = () => {
       this._log.info("Closed");
       this._dbConn = null;
       // Now that the connection is closed, no need to keep
       // a blocker for Barriers.connections.
       Barriers.connections.client.removeBlocker(deferred.promise);
       deferred.resolve();
-    });
+    }
+    if (wrappedConnections.has(this._identifier)) {
+      wrappedConnections.delete(this._identifier);
+      markAsClosed();
+    } else {
+      this._log.debug("Calling asyncClose().");
+      this._dbConn.asyncClose(markAsClosed);
+    }
   },
 
   executeCached: function (sql, params=null, onRow=null) {
     this.ensureOpen();
 
     if (!sql) {
       throw new Error("sql argument is empty.");
     }
@@ -717,22 +749,17 @@ function openConnection(options) {
                       "Got: " + options.shrinkMemoryOnConnectionIdleMS);
     }
 
     openedOptions.shrinkMemoryOnConnectionIdleMS =
       options.shrinkMemoryOnConnectionIdleMS;
   }
 
   let file = FileUtils.File(path);
-
-  let basename = OS.Path.basename(path);
-  let number = connectionCounters.get(basename) || 0;
-  connectionCounters.set(basename, number + 1);
-
-  let identifier = basename + "#" + number;
+  let identifier = getIdentifierByPath(path);
 
   log.info("Opening database: " + path + " (" + identifier + ")");
   let deferred = Promise.defer();
   let dbOptions = null;
   if (!sharedMemoryCache) {
     dbOptions = Cc["@mozilla.org/hash-property-bag;1"].
       createInstance(Ci.nsIWritablePropertyBag);
     dbOptions.setProperty("shared", false);
@@ -741,30 +768,30 @@ function openConnection(options) {
     if (!connection) {
       log.warn("Could not open connection: " + status);
       deferred.reject(new Error("Could not open connection: " + status));
       return;
     }
     log.info("Connection opened");
     try {
       deferred.resolve(
-        new OpenedConnection(connection.QueryInterface(Ci.mozIStorageAsyncConnection), basename, number,
-        openedOptions));
+        new OpenedConnection(connection.QueryInterface(Ci.mozIStorageAsyncConnection),
+                            identifier, openedOptions));
     } catch (ex) {
       log.warn("Could not open database: " + CommonUtils.exceptionStr(ex));
       deferred.reject(ex);
     }
   });
   return deferred.promise;
 }
 
 /**
  * Creates a clone of an existing and open Storage connection.  The clone has
  * the same underlying characteristics of the original connection and is
- * returned in form of on OpenedConnection handle.
+ * returned in form of an OpenedConnection handle.
  *
  * The following parameters can control the cloned connection:
  *
  *   connection -- (mozIStorageAsyncConnection) The original Storage connection
  *       to clone.  It's not possible to clone connections to memory databases.
  *
  *   readOnly -- (boolean) - If true the clone will be read-only.  If the
  *       original connection is already read-only, the clone will be, regardless
@@ -807,43 +834,88 @@ function cloneStorageConnection(options)
       throw new TypeError("shrinkMemoryOnConnectionIdleMS must be an integer. " +
                           "Got: " + options.shrinkMemoryOnConnectionIdleMS);
     }
     openedOptions.shrinkMemoryOnConnectionIdleMS =
       options.shrinkMemoryOnConnectionIdleMS;
   }
 
   let path = source.databaseFile.path;
-  let basename = OS.Path.basename(path);
-  let number = connectionCounters.get(basename) || 0;
-  connectionCounters.set(basename, number + 1);
-  let identifier = basename + "#" + number;
+  let identifier = getIdentifierByPath(path);
 
   log.info("Cloning database: " + path + " (" + identifier + ")");
   let deferred = Promise.defer();
 
   source.asyncClone(!!options.readOnly, (status, connection) => {
     if (!connection) {
       log.warn("Could not clone connection: " + status);
       deferred.reject(new Error("Could not clone connection: " + status));
     }
     log.info("Connection cloned");
     try {
       let conn = connection.QueryInterface(Ci.mozIStorageAsyncConnection);
-      deferred.resolve(new OpenedConnection(conn, basename, number,
-                                            openedOptions));
+      deferred.resolve(new OpenedConnection(conn, identifier, openedOptions));
     } catch (ex) {
       log.warn("Could not clone database: " + CommonUtils.exceptionStr(ex));
       deferred.reject(ex);
     }
   });
   return deferred.promise;
 }
 
 /**
+ * Wraps an existing and open Storage connection with Sqlite.jsm API.  The
+ * wrapped connection clone has the same underlying characteristics of the
+ * original connection and is returned in form of an OpenedConnection handle.
+ *
+ * Clients are responsible for closing both the Sqlite.jsm wrapper and the
+ * underlying mozStorage connection.
+ *
+ * The following parameters can control the wrapped connection:
+ *
+ *   connection -- (mozIStorageAsyncConnection) The original Storage connection
+ *       to wrap.
+ *
+ * @param options
+ *        (Object) Parameters to control connection and wrap options.
+ *
+ * @return Promise<OpenedConnection>
+ */
+function wrapStorageConnection(options) {
+  let log = Log.repository.getLogger("Sqlite.ConnectionWrapper");
+
+  let connection = options && options.connection;
+  if (!connection || !(connection instanceof Ci.mozIStorageAsyncConnection)) {
+    throw new TypeError("connection not specified or invalid.");
+  }
+
+  if (isClosed) {
+    throw new Error("Sqlite.jsm has been shutdown. Cannot wrap connection to: " + connection.database.path);
+  }
+
+  let path = connection.databaseFile.path;
+  let identifier = getIdentifierByPath(path);
+
+  log.info("Wrapping database: " + path + " (" + identifier + ")");
+  return new Promise(resolve => {
+    try {
+      let conn = connection.QueryInterface(Ci.mozIStorageAsyncConnection);
+      let wrapper = new OpenedConnection(conn, identifier);
+      // We must not handle shutdown of a wrapped connection, since that is
+      // already handled by the opener.
+      wrappedConnections.add(identifier);
+      resolve(wrapper);
+    } catch (ex) {
+      log.warn("Could not wrap database: " + CommonUtils.exceptionStr(ex));
+      throw ex;
+    }
+  });
+}
+
+/**
  * Handle on an opened SQLite database.
  *
  * This is essentially a glorified wrapper around mozIStorageConnection.
  * However, it offers some compelling advantages.
  *
  * The main functions on this type are `execute` and `executeCached`. These are
  * ultimately how all SQL statements are executed. It's worth explaining their
  * differences.
@@ -872,44 +944,43 @@ function cloneStorageConnection(options)
  *   Ability to enqueue operations. Currently there can be race conditions,
  *   especially as far as transactions are concerned. It would be nice to have
  *   an enqueueOperation(func) API that serially executes passed functions.
  *
  *   Support for SAVEPOINT (named/nested transactions) might be useful.
  *
  * @param connection
  *        (mozIStorageConnection) Underlying SQLite connection.
- * @param basename
- *        (string) The basename of this database name. Used for logging.
- * @param number
- *        (Number) The connection number to this database.
- * @param options
+ * @param identifier
+ *        (string) The unique identifier of this database. It may be used for
+ *        logging or as a key in Maps.
+ * @param options [optional]
  *        (object) Options to control behavior of connection. See
  *        `openConnection`.
  */
-function OpenedConnection(connection, basename, number, options) {
+function OpenedConnection(connection, identifier, options={}) {
   // Store all connection data in a field distinct from the
   // witness. This enables us to store an additional reference to this
   // field without preventing garbage collection of
   // OpenedConnection. On garbage collection, we will still be able to
   // close the database using this extra reference.
-  this._connectionData = new ConnectionData(connection, basename, number, options);
+  this._connectionData = new ConnectionData(connection, identifier, options);
 
   // Store the extra reference in a map with connection identifier as
   // key.
-  ConnectionData.byId.set(this._connectionData._connectionIdentifier,
+  ConnectionData.byId.set(this._connectionData._identifier,
                           this._connectionData);
 
   // Make a finalization witness. If this object is garbage collected
   // before its `forget` method has been called, an event with topic
   // "sqlite-finalization-witness" is broadcasted along with the
   // connection identifier string of the database.
   this._witness = FinalizationWitnessService.make(
     "sqlite-finalization-witness",
-    this._connectionData._connectionIdentifier);
+    this._connectionData._identifier);
 }
 
 OpenedConnection.prototype = Object.freeze({
   TRANSACTION_DEFERRED: "DEFERRED",
   TRANSACTION_IMMEDIATE: "IMMEDIATE",
   TRANSACTION_EXCLUSIVE: "EXCLUSIVE",
 
   TRANSACTION_TYPES: ["DEFERRED", "IMMEDIATE", "EXCLUSIVE"],
@@ -959,18 +1030,18 @@ OpenedConnection.prototype = Object.free
    * reopened.
    *
    * @return Promise<>
    */
   close: function () {
     // Unless cleanup has already been done by a previous call to
     // `close`, delete the database entry from map and tell the
     // finalization witness to forget.
-    if (ConnectionData.byId.has(this._connectionData._connectionIdentifier)) {
-      ConnectionData.byId.delete(this._connectionData._connectionIdentifier);
+    if (ConnectionData.byId.has(this._connectionData._identifier)) {
+      ConnectionData.byId.delete(this._connectionData._identifier);
       this._witness.forget();
     }
     return this._connectionData.close();
   },
 
   /**
    * Clones this connection to a new Sqlite one.
    *
@@ -1170,16 +1241,17 @@ OpenedConnection.prototype = Object.free
   discardCachedStatements: function () {
     return this._connectionData.discardCachedStatements();
   },
 });
 
 this.Sqlite = {
   openConnection: openConnection,
   cloneStorageConnection: cloneStorageConnection,
+  wrapStorageConnection: wrapStorageConnection,
   /**
    * Shutdown barrier client. May be used by clients to perform last-minute
    * cleanup prior to the shutdown of this module.
    *
    * See the documentation of AsyncShutdown.Barrier.prototype.client.
    */
   get shutdown() {
     return Barriers.shutdown.client;
--- a/toolkit/modules/tests/xpcshell/test_sqlite.js
+++ b/toolkit/modules/tests/xpcshell/test_sqlite.js
@@ -840,22 +840,22 @@ add_task(function test_direct() {
 });
 
 /**
  * Test Sqlite.cloneStorageConnection.
  */
 add_task(function* test_cloneStorageConnection() {
   let file = new FileUtils.File(OS.Path.join(OS.Constants.Path.profileDir,
                                              "test_cloneStorageConnection.sqlite"));
-  let c = yield new Promise((success, failure) => {
+  let c = yield new Promise((resolve, reject) => {
     Services.storage.openAsyncDatabase(file, null, (status, db) => {
       if (Components.isSuccessCode(status)) {
-        success(db.QueryInterface(Ci.mozIStorageAsyncConnection));
+        resolve(db.QueryInterface(Ci.mozIStorageAsyncConnection));
       } else {
-        failure(new Error(status));
+        reject(new Error(status));
       }
     });
   });
 
   let clone = yield Sqlite.cloneStorageConnection({ connection: c, readOnly: true });
   // Just check that it works.
   yield clone.execute("SELECT 1");
 
@@ -909,51 +909,78 @@ add_task(function* test_readOnly_clone()
     do_throw(new Error("Should not be able to write to a read-only clone."));
   } catch (ex) {}
   // Closing order should not matter.
   yield c.close();
   yield clone.close();
 });
 
 /**
+ * Test Sqlite.wrapStorageConnection.
+ */
+add_task(function* test_wrapStorageConnection() {
+  let file = new FileUtils.File(OS.Path.join(OS.Constants.Path.profileDir,
+                                             "test_wrapStorageConnection.sqlite"));
+  let c = yield new Promise((resolve, reject) => {
+    Services.storage.openAsyncDatabase(file, null, (status, db) => {
+      if (Components.isSuccessCode(status)) {
+        resolve(db.QueryInterface(Ci.mozIStorageAsyncConnection));
+      } else {
+        reject(new Error(status));
+      }
+    });
+  });
+
+  let wrapper = yield Sqlite.wrapStorageConnection({ connection: c });
+  // Just check that it works.
+  yield wrapper.execute("SELECT 1");
+  yield wrapper.executeCached("SELECT 1");
+
+  // Closing the wrapper should just finalize statements but not close the
+  // database.
+  yield wrapper.close();
+  yield c.asyncClose();
+});
+
+/**
  * Test finalization
  */
 add_task(function* test_closed_by_witness() {
   failTestsOnAutoClose(false);
   let c = yield getDummyDatabase("closed_by_witness");
 
   Services.obs.notifyObservers(null, "sqlite-finalization-witness",
-                               c._connectionData._connectionIdentifier);
+                               c._connectionData._identifier);
   // Since we triggered finalization ourselves, tell the witness to
   // forget the connection so it does not trigger a finalization again
   c._witness.forget();
   yield c._connectionData._deferredClose.promise;
   do_check_false(c._connectionData._open);
   failTestsOnAutoClose(true);
 });
 
 add_task(function* test_warning_message_on_finalization() {
   failTestsOnAutoClose(false);
   let c = yield getDummyDatabase("warning_message_on_finalization");
-  let connectionIdentifier = c._connectionData._connectionIdentifier;
+  let identifier = c._connectionData._identifier;
   let deferred = Promise.defer();
 
   let listener = {
     observe: function(msg) {
       let messageText = msg.message;
       // Make sure the message starts with a warning containing the
       // connection identifier
-      if (messageText.indexOf("Warning: Sqlite connection '" + connectionIdentifier + "'") !== -1) {
+      if (messageText.indexOf("Warning: Sqlite connection '" + identifier + "'") !== -1) {
         deferred.resolve();
       }
     }
   };
   Services.console.registerListener(listener);
 
-  Services.obs.notifyObservers(null, "sqlite-finalization-witness", connectionIdentifier);
+  Services.obs.notifyObservers(null, "sqlite-finalization-witness", identifier);
   // Since we triggered finalization ourselves, tell the witness to
   // forget the connection so it does not trigger a finalization again
   c._witness.forget();
 
   yield deferred.promise;
   Services.console.unregisterListener(listener);
   failTestsOnAutoClose(true);
 });
--- a/widget/cocoa/nsMenuBarX.h
+++ b/widget/cocoa/nsMenuBarX.h
@@ -98,17 +98,16 @@ public:
 
   static NativeMenuItemTarget* sNativeEventTarget;
   static nsMenuBarX*           sLastGeckoMenuBarPainted;
   static nsMenuBarX*           sCurrentPaintDelayedMenuBar;
 
   // The following content nodes have been removed from the menu system.
   // We save them here for use in command handling.
   nsCOMPtr<nsIContent> mAboutItemContent;
-  nsCOMPtr<nsIContent> mUpdateItemContent;
   nsCOMPtr<nsIContent> mPrefItemContent;
   nsCOMPtr<nsIContent> mQuitItemContent;
 
   // nsChangeObserver
   NS_DECL_CHANGEOBSERVER
 
   // nsMenuObjectX
   void*             NativeData()     {return (void*)mNativeMenu;}
--- a/widget/cocoa/nsMenuBarX.mm
+++ b/widget/cocoa/nsMenuBarX.mm
@@ -32,17 +32,16 @@ nsMenuBarX* nsMenuBarX::sCurrentPaintDel
 NSMenu* sApplicationMenu = nil;
 BOOL gSomeMenuBarPainted = NO;
 
 // We keep references to the first quit and pref item content nodes we find, which
 // will be from the hidden window. We use these when the document for the current
 // window does not have a quit or pref item. We don't need strong refs here because
 // these items are always strong ref'd by their owning menu bar (instance variable).
 static nsIContent* sAboutItemContent  = nullptr;
-static nsIContent* sUpdateItemContent = nullptr;
 static nsIContent* sPrefItemContent   = nullptr;
 static nsIContent* sQuitItemContent   = nullptr;
 
 NS_IMPL_ISUPPORTS(nsNativeMenuServiceX, nsINativeMenuService)
 
 NS_IMETHODIMP nsNativeMenuServiceX::CreateNativeMenuBar(nsIWidget* aParent, nsIContent* aMenuBarNode)
 {
   NS_ASSERTION(NS_IsMainThread(), "Attempting to create native menu bar on wrong thread!");
@@ -70,18 +69,16 @@ nsMenuBarX::~nsMenuBarX()
 
   if (nsMenuBarX::sLastGeckoMenuBarPainted == this)
     nsMenuBarX::sLastGeckoMenuBarPainted = nullptr;
 
   // the quit/pref items of a random window might have been used if there was no
   // hidden window, thus we need to invalidate the weak references.
   if (sAboutItemContent == mAboutItemContent)
     sAboutItemContent = nullptr;
-  if (sUpdateItemContent == mUpdateItemContent)
-    sUpdateItemContent = nullptr;
   if (sQuitItemContent == mQuitItemContent)
     sQuitItemContent = nullptr;
   if (sPrefItemContent == mPrefItemContent)
     sPrefItemContent = nullptr;
 
   // make sure we unregister ourselves as a content observer
   UnregisterForContentChanges(mContent);
 
@@ -473,23 +470,16 @@ void nsMenuBarX::AquifyMenuBar()
   nsCOMPtr<nsIDOMDocument> domDoc(do_QueryInterface(mContent->GetComposedDoc()));
   if (domDoc) {
     // remove the "About..." item and its separator
     HideItem(domDoc, NS_LITERAL_STRING("aboutSeparator"), nullptr);
     HideItem(domDoc, NS_LITERAL_STRING("aboutName"), getter_AddRefs(mAboutItemContent));
     if (!sAboutItemContent)
       sAboutItemContent = mAboutItemContent;
 
-    // Hide the software update menu item, since it belongs in the application
-    // menu on Mac OS X.
-    HideItem(domDoc, NS_LITERAL_STRING("updateSeparator"), nullptr);
-    HideItem(domDoc, NS_LITERAL_STRING("checkForUpdates"), getter_AddRefs(mUpdateItemContent));
-    if (!sUpdateItemContent)
-      sUpdateItemContent = mUpdateItemContent;
-
     // remove quit item and its separator
     HideItem(domDoc, NS_LITERAL_STRING("menu_FileQuitSeparator"), nullptr);
     HideItem(domDoc, NS_LITERAL_STRING("menu_FileQuitItem"), getter_AddRefs(mQuitItemContent));
     if (!sQuitItemContent)
       sQuitItemContent = mQuitItemContent;
     
     // remove prefs item and its separator, but save off the pref content node
     // so we can invoke its command later.
@@ -595,17 +585,16 @@ nsresult nsMenuBarX::CreateApplicationMe
   
 /*
   We support the following menu items here:
 
   Menu Item                DOM Node ID             Notes
   
   ========================
   = About This App       = <- aboutName
-  = Check for Updates... = <- checkForUpdates
   ========================
   = Preferences...       = <- menu_preferences
   ========================
   = Services     >       = <- menu_mac_services    <- (do not define key equivalent)
   ======================== 
   = Hide App             = <- menu_mac_hide_app
   = Hide Others          = <- menu_mac_hide_others
   = Show All             = <- menu_mac_show_all
@@ -639,27 +628,16 @@ nsresult nsMenuBarX::CreateApplicationMe
     if (itemBeingAdded) {
       [sApplicationMenu addItem:itemBeingAdded];
       [itemBeingAdded release];
       itemBeingAdded = nil;
 
       addAboutSeparator = TRUE;
     }
 
-    // Add the software update menu item
-    itemBeingAdded = CreateNativeAppMenuItem(inMenu, NS_LITERAL_STRING("checkForUpdates"), @selector(menuItemHit:),
-                                             eCommand_ID_Update, nsMenuBarX::sNativeEventTarget);
-    if (itemBeingAdded) {
-      [sApplicationMenu addItem:itemBeingAdded];
-      [itemBeingAdded release];
-      itemBeingAdded = nil;
-
-      addAboutSeparator = TRUE;
-    }
-
     // Add separator if either the About item or software update item exists
     if (addAboutSeparator)
       [sApplicationMenu addItem:[NSMenuItem separatorItem]];
 
     // Add the Preferences menu item
     itemBeingAdded = CreateNativeAppMenuItem(inMenu, NS_LITERAL_STRING("menu_preferences"), @selector(menuItemHit:),
                                              eCommand_ID_Prefs, nsMenuBarX::sNativeEventTarget);
     if (itemBeingAdded) {
@@ -945,22 +923,16 @@ static BOOL gMenuItemsExecuteCommands = 
   // Do special processing if this is for an app-global command.
   if (tag == eCommand_ID_About) {
     nsIContent* mostSpecificContent = sAboutItemContent;
     if (menuBar && menuBar->mAboutItemContent)
       mostSpecificContent = menuBar->mAboutItemContent;
     nsMenuUtilsX::DispatchCommandTo(mostSpecificContent);
     return;
   }
-  else if (tag == eCommand_ID_Update) {
-    nsIContent* mostSpecificContent = sUpdateItemContent;
-    if (menuBar && menuBar->mUpdateItemContent)
-      mostSpecificContent = menuBar->mUpdateItemContent;
-    nsMenuUtilsX::DispatchCommandTo(mostSpecificContent);
-  }
   else if (tag == eCommand_ID_Prefs) {
     nsIContent* mostSpecificContent = sPrefItemContent;
     if (menuBar && menuBar->mPrefItemContent)
       mostSpecificContent = menuBar->mPrefItemContent;
     nsMenuUtilsX::DispatchCommandTo(mostSpecificContent);
     return;
   }
   else if (tag == eCommand_ID_HideApp) {