Bug 1008990 - Loop standalone UI should hand off to FxOS Loop client. r=dmose
authorFernando Jiménez <ferjmoreno@gmail.com>
Thu, 25 Sep 2014 14:48:09 +0200
changeset 218098 a01b2985cfe6b7e788cb4cab35a77ba179380475
parent 218097 97132a8f56dd65338129062167ac0d99395259ec
child 218099 cb0ec049ccd1e1c5c8763e89667ebaca61168f29
push idunknown
push userunknown
push dateunknown
reviewersdmose
bugs1008990
milestone34.0a2
Bug 1008990 - Loop standalone UI should hand off to FxOS Loop client. r=dmose
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/standalone/Makefile
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/standalone/content/l10n/loop.en-US.properties
browser/components/loop/standalone/server.js
browser/components/loop/test/shared/models_test.js
browser/components/loop/test/shared/utils_test.js
browser/components/loop/test/shared/views_test.js
browser/components/loop/test/standalone/webapp_test.js
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -361,22 +361,25 @@ loop.shared.models = (function(l10n) {
      *
      * @return {String} message
      */
     error: function(message) {
       this.add({level: "error", message: message});
     },
 
     /**
-     * Adds a l10n rror notification to the stack and renders it.
+     * Adds a l10n error notification to the stack and renders it.
      *
      * @param  {String} messageId L10n message id
+     * @param  {Object} [l10nProps] An object with variables to be interpolated
+     *                  into the translation. All members' values must be
+     *                  strings or numbers.
      */
-    errorL10n: function(messageId) {
-      this.error(l10n.get(messageId));
+    errorL10n: function(messageId, l10nProps) {
+      this.error(l10n.get(messageId, l10nProps));
     }
   });
 
   return {
     ConversationModel: ConversationModel,
     NotificationCollection: NotificationCollection,
     NotificationModel: NotificationModel
   };
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -53,16 +53,25 @@ loop.shared.utils = (function() {
     this._iOSRegex = /^(iPad|iPhone|iPod)/;
   }
 
   Helper.prototype = {
     isFirefox: function(platform) {
       return platform.indexOf("Firefox") !== -1;
     },
 
+    isFirefoxOS: function(platform) {
+      // So far WebActivities are exposed only in FxOS, but they may be
+      // exposed in Firefox Desktop soon, so we check for its existence
+      // and also check if the UA belongs to a mobile platform.
+      // XXX WebActivities are also exposed in WebRT on Firefox for Android,
+      //     so we need a better check. Bug 1065403.
+      return !!window.MozActivity && /mobi/i.test(platform);
+    },
+
     isIOS: function(platform) {
       return this._iOSRegex.test(platform);
     },
 
     locationHash: function() {
       return window.location.hash;
     }
   };
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -68,8 +68,11 @@ remove_old_config:
 # 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.feedbackApiUrl = '`echo $(LOOP_FEEDBACK_API_URL)`';" >> content/config.js
 	@echo "loop.config.feedbackProductName = '`echo $(LOOP_FEEDBACK_PRODUCT_NAME)`';" >> content/config.js
+	@echo "loop.config.fxosApp = loop.config.fxosApp || {};" >> content/config.js
+	@echo "loop.config.fxosApp.name = 'Loop';" >> content/config.js
+	@echo "loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';" >> content/config.js
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -1,15 +1,15 @@
 /** @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 */
+/* global loop:true, React, MozActivity */
 /* 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";
@@ -117,16 +117,105 @@ loop.webapp = (function($, _, OT, mozL10
       return (
         React.DOM.h1({className: "standalone-header-title"}, 
           React.DOM.strong(null, mozL10n.get("brandShortname")), " ", mozL10n.get("clientShortname")
         )
       );
     }
   });
 
+  /**
+   * The Firefox Marketplace exposes a web page that contains a postMesssage
+   * based API that wraps a small set of functionality from the WebApps API
+   * that allow us to request the installation of apps given their manifest
+   * URL. We will be embedding the content of this web page within an hidden
+   * iframe in case that we need to request the installation of the FxOS Loop
+   * client.
+   */
+  var FxOSHiddenMarketplace = React.createClass({displayName: 'FxOSHiddenMarketplace',
+    render: function() {
+      return React.DOM.iframe({id: "marketplace", src: this.props.marketplaceSrc, hidden: true});
+    },
+
+    componentDidUpdate: function() {
+      // This happens only once when we change the 'src' property of the iframe.
+      if (this.props.onMarketplaceMessage) {
+        // The reason for listening on the global window instead of on the
+        // iframe content window is because the Marketplace is doing a
+        // window.top.postMessage.
+        window.addEventListener("message", this.props.onMarketplaceMessage);
+      }
+    }
+  });
+
+  var FxOSConversationModel = Backbone.Model.extend({
+    setupOutgoingCall: function() {
+      // The FxOS Loop client exposes a "loop-call" activity. If we get the
+      // activity onerror callback it means that there is no "loop-call"
+      // activity handler available and so no FxOS Loop client installed.
+      var request = new MozActivity({
+        name: "loop-call",
+        data: {
+          type: "loop/token",
+          token: this.get("loopToken"),
+          callerId: this.get("callerId"),
+          callType: this.get("callType")
+        }
+      });
+
+      request.onsuccess = function() {};
+
+      request.onerror = (function(event) {
+        if (event.target.error.name !== "NO_PROVIDER") {
+          console.error ("Unexpected " + event.target.error.name);
+          this.trigger("session:error", "fxos_app_needed", {
+            fxosAppName: loop.config.fxosApp.name
+          });
+          return;
+        }
+        this.trigger("fxos:app-needed");
+      }).bind(this);
+    },
+
+    onMarketplaceMessage: function(event) {
+      var message = event.data;
+      switch (message.name) {
+        case "loaded":
+          var marketplace = window.document.getElementById("marketplace");
+          // Once we have it loaded, we request the installation of the FxOS
+          // Loop client app. We will be receiving the result of this action
+          // via postMessage from the child iframe.
+          marketplace.contentWindow.postMessage({
+            "name": "install-package",
+            "data": {
+              "product": {
+                "name": loop.config.fxosApp.name,
+                "manifest_url": loop.config.fxosApp.manifestUrl,
+                "is_packaged": true
+              }
+            }
+          }, "*");
+          break;
+        case "install-package":
+          window.removeEventListener("message", this.onMarketplaceMessage);
+          if (message.error) {
+            console.error(message.error.error);
+            this.trigger("session:error", "fxos_app_needed", {
+              fxosAppName: loop.config.fxosApp.name
+            });
+            return;
+          }
+          // We installed the FxOS app \o/, so we can continue with the call
+          // process.
+          this.setupOutgoingCall();
+          break;
+      }
+    }
+  });
+
   var ConversationHeader = React.createClass({displayName: 'ConversationHeader',
     render: function() {
       var cx = React.addons.classSet;
       var conversationUrl = location.href;
 
       var urlCreationDateClasses = cx({
         "light-color-font": true,
         "call-url-date": true, /* Used as a handler in the tests */
@@ -228,18 +317,20 @@ loop.webapp = (function($, _, OT, mozL10
    * as a `model` property.
    *
    * Required properties:
    * - {loop.shared.models.ConversationModel}    model    Conversation model.
    * - {loop.shared.models.NotificationCollection} notifications
    */
   var StartConversationView = React.createClass({displayName: 'StartConversationView',
     propTypes: {
-      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                                       .isRequired,
+      model: React.PropTypes.oneOfType([
+               React.PropTypes.instanceOf(sharedModels.ConversationModel),
+               React.PropTypes.instanceOf(FxOSConversationModel)
+             ]).isRequired,
       // XXX Check more tightly here when we start injecting window.loop.*
       notifications: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired
     },
 
     getDefaultProps: function() {
       return {showCallOptionsMenu: false};
     },
@@ -252,25 +343,39 @@ loop.webapp = (function($, _, OT, mozL10
       };
     },
 
     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.model.listenTo(this.props.model, "fxos:app-needed",
+                                this._onFxOSAppNeeded);
       this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
                                            this._setConversationTimestamp);
     },
 
-    _onSessionError: function(error) {
-      console.error(error);
-      this.props.notifications.errorL10n("unable_retrieve_call_info");
+    _onSessionError: function(error, l10nProps) {
+      var errorL10n = error || "unable_retrieve_call_info";
+      this.props.notifications.errorL10n(errorL10n, l10nProps);
+      console.error(errorL10n);
     },
 
+    _onFxOSAppNeeded: function() {
+      this.setState({
+        marketplaceSrc: loop.config.marketplaceUrl
+      });
+      this.setState({
+        onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
+          this.props.model
+        )
+      });
+     },
+
     /**
      * Initiates the call.
      * Takes in a call type parameter "audio" or "audio-video" and returns
      * a function that initiates the call. React click handler requires a function
      * to be called when that event happenes.
      *
      * @param {string} User call type choice "audio" or "audio-video"
      */
@@ -325,16 +430,20 @@ loop.webapp = (function($, _, OT, mozL10
         "native-dropdown-large-parent": true,
         "standalone-dropdown-menu": true,
         "visually-hidden": !this.state.showCallOptionsMenu
       });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
+      var chevronClasses = React.addons.classSet({
+        "btn-chevron": true,
+        "disabled": this.state.disableCallButton
+      });
 
       return (
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
 
             ConversationHeader({
               urlCreationDateString: this.state.urlCreationDateString}), 
 
@@ -355,17 +464,17 @@ loop.webapp = (function($, _, OT, mozL10
                             disabled: this.state.disableCallButton, 
                             title: mozL10n.get("initiate_audio_video_call_tooltip2")}, 
                       React.DOM.span({className: "standalone-call-btn-text"}, 
                         mozL10n.get("initiate_audio_video_call_button2")
                       ), 
                       React.DOM.span({className: "standalone-call-btn-video-icon"})
                     ), 
 
-                    React.DOM.div({className: "btn-chevron", 
+                    React.DOM.div({className: chevronClasses, 
                          onClick: this._toggleCallOptionsMenu}
                     )
 
                   ), 
 
                   React.DOM.ul({className: dropdownMenuClasses}, 
                     React.DOM.li(null, 
                       /*
@@ -383,16 +492,20 @@ loop.webapp = (function($, _, OT, mozL10
               ), 
               React.DOM.div({className: "flex-padding-1"})
             ), 
 
             React.DOM.p({className: tosClasses, 
                dangerouslySetInnerHTML: {__html: tosHTML}})
           ), 
 
+          FxOSHiddenMarketplace({
+            marketplaceSrc: this.state.marketplaceSrc, 
+            onMarketplaceMessage: this.state.onMarketplaceMessage}), 
+
           ConversationFooter(null)
         )
       );
     }
   });
 
   /**
    * Ended conversation view.
@@ -429,18 +542,20 @@ loop.webapp = (function($, _, OT, mozL10
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
-      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                         .isRequired,
+      conversation: React.PropTypes.oneOfType([
+        React.PropTypes.instanceOf(sharedModels.ConversationModel),
+        React.PropTypes.instanceOf(FxOSConversationModel)
+      ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
@@ -684,18 +799,20 @@ loop.webapp = (function($, _, OT, mozL10
 
   /**
    * Webapp Root View. This is the main, single, view that controls the display
    * of the webapp page.
    */
   var WebappRootView = React.createClass({displayName: 'WebappRootView',
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
-      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                         .isRequired,
+      conversation: React.PropTypes.oneOfType([
+        React.PropTypes.instanceOf(sharedModels.ConversationModel),
+        React.PropTypes.instanceOf(FxOSConversationModel)
+      ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
@@ -731,19 +848,25 @@ loop.webapp = (function($, _, OT, mozL10
    * App initialization.
    */
   function init() {
     var helper = new sharedUtils.Helper();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
     var notifications = new sharedModels.NotificationCollection();
-    var conversation = new sharedModels.ConversationModel({}, {
-      sdk: OT
-    });
+    var conversation
+    if (helper.isFirefoxOS(navigator.userAgent)) {
+      conversation = new FxOSConversationModel();
+    } else {
+      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
@@ -772,11 +895,12 @@ loop.webapp = (function($, _, OT, mozL10
     StartConversationView: StartConversationView,
     OutgoingConversationView: OutgoingConversationView,
     EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
-    WebappRootView: WebappRootView
+    WebappRootView: WebappRootView,
+    FxOSConversationModel: FxOSConversationModel
   };
 })(jQuery, _, window.OT, navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -1,15 +1,15 @@
 /** @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 */
+/* global loop:true, React, MozActivity */
 /* 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";
@@ -117,16 +117,105 @@ loop.webapp = (function($, _, OT, mozL10
       return (
         <h1 className="standalone-header-title">
           <strong>{mozL10n.get("brandShortname")}</strong> {mozL10n.get("clientShortname")}
         </h1>
       );
     }
   });
 
+  /**
+   * The Firefox Marketplace exposes a web page that contains a postMesssage
+   * based API that wraps a small set of functionality from the WebApps API
+   * that allow us to request the installation of apps given their manifest
+   * URL. We will be embedding the content of this web page within an hidden
+   * iframe in case that we need to request the installation of the FxOS Loop
+   * client.
+   */
+  var FxOSHiddenMarketplace = React.createClass({
+    render: function() {
+      return <iframe id="marketplace" src={this.props.marketplaceSrc} hidden/>;
+    },
+
+    componentDidUpdate: function() {
+      // This happens only once when we change the 'src' property of the iframe.
+      if (this.props.onMarketplaceMessage) {
+        // The reason for listening on the global window instead of on the
+        // iframe content window is because the Marketplace is doing a
+        // window.top.postMessage.
+        window.addEventListener("message", this.props.onMarketplaceMessage);
+      }
+    }
+  });
+
+  var FxOSConversationModel = Backbone.Model.extend({
+    setupOutgoingCall: function() {
+      // The FxOS Loop client exposes a "loop-call" activity. If we get the
+      // activity onerror callback it means that there is no "loop-call"
+      // activity handler available and so no FxOS Loop client installed.
+      var request = new MozActivity({
+        name: "loop-call",
+        data: {
+          type: "loop/token",
+          token: this.get("loopToken"),
+          callerId: this.get("callerId"),
+          callType: this.get("callType")
+        }
+      });
+
+      request.onsuccess = function() {};
+
+      request.onerror = (function(event) {
+        if (event.target.error.name !== "NO_PROVIDER") {
+          console.error ("Unexpected " + event.target.error.name);
+          this.trigger("session:error", "fxos_app_needed", {
+            fxosAppName: loop.config.fxosApp.name
+          });
+          return;
+        }
+        this.trigger("fxos:app-needed");
+      }).bind(this);
+    },
+
+    onMarketplaceMessage: function(event) {
+      var message = event.data;
+      switch (message.name) {
+        case "loaded":
+          var marketplace = window.document.getElementById("marketplace");
+          // Once we have it loaded, we request the installation of the FxOS
+          // Loop client app. We will be receiving the result of this action
+          // via postMessage from the child iframe.
+          marketplace.contentWindow.postMessage({
+            "name": "install-package",
+            "data": {
+              "product": {
+                "name": loop.config.fxosApp.name,
+                "manifest_url": loop.config.fxosApp.manifestUrl,
+                "is_packaged": true
+              }
+            }
+          }, "*");
+          break;
+        case "install-package":
+          window.removeEventListener("message", this.onMarketplaceMessage);
+          if (message.error) {
+            console.error(message.error.error);
+            this.trigger("session:error", "fxos_app_needed", {
+              fxosAppName: loop.config.fxosApp.name
+            });
+            return;
+          }
+          // We installed the FxOS app \o/, so we can continue with the call
+          // process.
+          this.setupOutgoingCall();
+          break;
+      }
+    }
+  });
+
   var ConversationHeader = React.createClass({
     render: function() {
       var cx = React.addons.classSet;
       var conversationUrl = location.href;
 
       var urlCreationDateClasses = cx({
         "light-color-font": true,
         "call-url-date": true, /* Used as a handler in the tests */
@@ -228,18 +317,20 @@ loop.webapp = (function($, _, OT, mozL10
    * as a `model` property.
    *
    * Required properties:
    * - {loop.shared.models.ConversationModel}    model    Conversation model.
    * - {loop.shared.models.NotificationCollection} notifications
    */
   var StartConversationView = React.createClass({
     propTypes: {
-      model: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                                       .isRequired,
+      model: React.PropTypes.oneOfType([
+               React.PropTypes.instanceOf(sharedModels.ConversationModel),
+               React.PropTypes.instanceOf(FxOSConversationModel)
+             ]).isRequired,
       // XXX Check more tightly here when we start injecting window.loop.*
       notifications: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired
     },
 
     getDefaultProps: function() {
       return {showCallOptionsMenu: false};
     },
@@ -252,25 +343,39 @@ loop.webapp = (function($, _, OT, mozL10
       };
     },
 
     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.model.listenTo(this.props.model, "fxos:app-needed",
+                                this._onFxOSAppNeeded);
       this.props.client.requestCallUrlInfo(this.props.model.get("loopToken"),
                                            this._setConversationTimestamp);
     },
 
-    _onSessionError: function(error) {
-      console.error(error);
-      this.props.notifications.errorL10n("unable_retrieve_call_info");
+    _onSessionError: function(error, l10nProps) {
+      var errorL10n = error || "unable_retrieve_call_info";
+      this.props.notifications.errorL10n(errorL10n, l10nProps);
+      console.error(errorL10n);
     },
 
+    _onFxOSAppNeeded: function() {
+      this.setState({
+        marketplaceSrc: loop.config.marketplaceUrl
+      });
+      this.setState({
+        onMarketplaceMessage: this.props.model.onMarketplaceMessage.bind(
+          this.props.model
+        )
+      });
+     },
+
     /**
      * Initiates the call.
      * Takes in a call type parameter "audio" or "audio-video" and returns
      * a function that initiates the call. React click handler requires a function
      * to be called when that event happenes.
      *
      * @param {string} User call type choice "audio" or "audio-video"
      */
@@ -325,16 +430,20 @@ loop.webapp = (function($, _, OT, mozL10
         "native-dropdown-large-parent": true,
         "standalone-dropdown-menu": true,
         "visually-hidden": !this.state.showCallOptionsMenu
       });
       var tosClasses = React.addons.classSet({
         "terms-service": true,
         hide: (localStorage.getItem("has-seen-tos") === "true")
       });
+      var chevronClasses = React.addons.classSet({
+        "btn-chevron": true,
+        "disabled": this.state.disableCallButton
+      });
 
       return (
         <div className="container">
           <div className="container-box">
 
             <ConversationHeader
               urlCreationDateString={this.state.urlCreationDateString} />
 
@@ -355,17 +464,17 @@ loop.webapp = (function($, _, OT, mozL10
                             disabled={this.state.disableCallButton}
                             title={mozL10n.get("initiate_audio_video_call_tooltip2")} >
                       <span className="standalone-call-btn-text">
                         {mozL10n.get("initiate_audio_video_call_button2")}
                       </span>
                       <span className="standalone-call-btn-video-icon"></span>
                     </button>
 
-                    <div className="btn-chevron"
+                    <div className={chevronClasses}
                          onClick={this._toggleCallOptionsMenu}>
                     </div>
 
                   </div>
 
                   <ul className={dropdownMenuClasses}>
                     <li>
                       {/*
@@ -383,16 +492,20 @@ loop.webapp = (function($, _, OT, mozL10
               </div>
               <div className="flex-padding-1"></div>
             </div>
 
             <p className={tosClasses}
                dangerouslySetInnerHTML={{__html: tosHTML}}></p>
           </div>
 
+          <FxOSHiddenMarketplace
+            marketplaceSrc={this.state.marketplaceSrc}
+            onMarketplaceMessage= {this.state.onMarketplaceMessage} />
+
           <ConversationFooter />
         </div>
       );
     }
   });
 
   /**
    * Ended conversation view.
@@ -429,18 +542,20 @@ loop.webapp = (function($, _, OT, mozL10
    * This view manages the outgoing conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var OutgoingConversationView = React.createClass({
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
-      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                         .isRequired,
+      conversation: React.PropTypes.oneOfType([
+        React.PropTypes.instanceOf(sharedModels.ConversationModel),
+        React.PropTypes.instanceOf(FxOSConversationModel)
+      ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
@@ -684,18 +799,20 @@ loop.webapp = (function($, _, OT, mozL10
 
   /**
    * Webapp Root View. This is the main, single, view that controls the display
    * of the webapp page.
    */
   var WebappRootView = React.createClass({
     propTypes: {
       client: React.PropTypes.instanceOf(loop.StandaloneClient).isRequired,
-      conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
-                         .isRequired,
+      conversation: React.PropTypes.oneOfType([
+        React.PropTypes.instanceOf(sharedModels.ConversationModel),
+        React.PropTypes.instanceOf(FxOSConversationModel)
+      ]).isRequired,
       helper: React.PropTypes.instanceOf(sharedUtils.Helper).isRequired,
       notifications: React.PropTypes.instanceOf(sharedModels.NotificationCollection)
                           .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired
     },
 
     getInitialState: function() {
@@ -731,19 +848,25 @@ loop.webapp = (function($, _, OT, mozL10
    * App initialization.
    */
   function init() {
     var helper = new sharedUtils.Helper();
     var client = new loop.StandaloneClient({
       baseServerUrl: loop.config.serverUrl
     });
     var notifications = new sharedModels.NotificationCollection();
-    var conversation = new sharedModels.ConversationModel({}, {
-      sdk: OT
-    });
+    var conversation
+    if (helper.isFirefoxOS(navigator.userAgent)) {
+      conversation = new FxOSConversationModel();
+    } else {
+      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
@@ -772,11 +895,12 @@ loop.webapp = (function($, _, OT, mozL10
     StartConversationView: StartConversationView,
     OutgoingConversationView: OutgoingConversationView,
     EndedConversationView: EndedConversationView,
     HomeView: HomeView,
     UnsupportedBrowserView: UnsupportedBrowserView,
     UnsupportedDeviceView: UnsupportedDeviceView,
     init: init,
     PromoteFirefoxView: PromoteFirefoxView,
-    WebappRootView: WebappRootView
+    WebappRootView: WebappRootView,
+    FxOSConversationModel: FxOSConversationModel
   };
 })(jQuery, _, window.OT, navigator.mozL10n);
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -41,16 +41,17 @@ 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…
+fxos_app_needed=Please install the {{fxosAppName}} app from the Firefox Marketplace.
 
 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
--- a/browser/components/loop/standalone/server.js
+++ b/browser/components/loop/standalone/server.js
@@ -16,16 +16,22 @@ function getConfigFile(req, res) {
 
   res.set('Content-Type', 'text/javascript');
   res.send([
     "var loop = loop || {};",
     "loop.config = loop.config || {};",
     "loop.config.serverUrl = 'http://localhost:" + loopServerPort + "';",
     "loop.config.feedbackApiUrl = '" + feedbackApiUrl + "';",
     "loop.config.feedbackProductName = '" + feedbackProductName + "';",
+    // XXX Update with the real marketplace url once the FxOS Loop app is
+    //     uploaded to the marketplace bug 1053424
+    "loop.config.marketplaceUrl = 'http://fake-market.herokuapp.com/iframe-install.html'",
+    "loop.config.fxosApp = loop.config.fxosApp || {};",
+    "loop.config.fxosApp.name = 'Loop';",
+    "loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';"
   ].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 + '/../'));
 
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -350,18 +350,18 @@ describe("loop.shared.models", function(
     });
   });
 
   describe("NotificationCollection", function() {
     var collection, notifData, testNotif;
 
     beforeEach(function() {
       collection = new sharedModels.NotificationCollection();
-      sandbox.stub(l10n, "get", function(x) {
-        return "translated:" + x;
+      sandbox.stub(l10n, "get", function(x, y) {
+        return "translated:" + x + (y ? ':' + y : '');
       });
       notifData = {level: "error", message: "plop"};
       testNotif = new sharedModels.NotificationModel(notifData);
     });
 
     describe("#warn", function() {
       it("should add a warning notification to the stack", function() {
         collection.warn("watch out");
@@ -395,12 +395,21 @@ describe("loop.shared.models", function(
     describe("#errorL10n", function() {
       it("should notify an error using a l10n string id", function() {
         collection.errorL10n("fakeId");
 
         expect(collection).to.have.length.of(1);
         expect(collection.at(0).get("level")).eql("error");
         expect(collection.at(0).get("message")).eql("translated:fakeId");
       });
+
+      it("should notify an error using a l10n string id + l10n properties",
+        function() {
+          collection.errorL10n("fakeId", "fakeProp");
+
+          expect(collection).to.have.length.of(1);
+          expect(collection.at(0).get("level")).eql("error");
+          expect(collection.at(0).get("message")).eql("translated:fakeId:fakeProp");
+      });
     });
 
   });
 });
--- a/browser/components/loop/test/shared/utils_test.js
+++ b/browser/components/loop/test/shared/utils_test.js
@@ -48,16 +48,49 @@ describe("loop.shared.utils", function()
         expect(helper.isFirefox("Firefox/Gecko")).eql(true);
         expect(helper.isFirefox("Gecko/Firefox/Chuck Norris")).eql(true);
       });
 
       it("shouldn't detect Firefox with other platforms", function() {
         expect(helper.isFirefox("Opera")).eql(false);
       });
     });
+
+    describe("#isFirefoxOS", function() {
+      describe("without mozActivities", function() {
+        it("shouldn't detect FirefoxOS on mobile platform", function() {
+          expect(helper.isFirefoxOS("mobi")).eql(false);
+        });
+
+        it("shouldn't detect FirefoxOS on non mobile platform", function() {
+          expect(helper.isFirefoxOS("whatever")).eql(false);
+        });
+      });
+
+      describe("with mozActivities", function() {
+        var realMozActivity;
+
+        before(function() {
+          realMozActivity = window.MozActivity;
+          window.MozActivity = {};
+        });
+
+        after(function() {
+          window.MozActivity = realMozActivity;
+        });
+
+        it("should detect FirefoxOS on mobile platform", function() {
+          expect(helper.isFirefoxOS("mobi")).eql(true);
+        });
+
+        it("shouldn't detect FirefoxOS on non mobile platform", function() {
+          expect(helper.isFirefoxOS("whatever")).eql(false);
+        });
+      });
+    });
   });
 
   describe("#getBoolPreference", function() {
     afterEach(function() {
       navigator.mozLoop = undefined;
       localStorage.removeItem("test.true");
     });
 
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -15,16 +15,19 @@ describe("loop.shared.views", function()
   var sharedModels = loop.shared.models,
       sharedViews = loop.shared.views,
       getReactElementByClass = TestUtils.findRenderedDOMComponentWithClass,
       sandbox;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     sandbox.useFakeTimers(); // exposes sandbox.clock as a fake timer
+    sandbox.stub(l10n, "get", function(x) {
+      return "translated:" + x;
+    });
   });
 
   afterEach(function() {
     $("#fixtures").empty();
     sandbox.restore();
   });
 
   describe("MediaControlButton", function() {
@@ -419,19 +422,16 @@ describe("loop.shared.views", function()
       });
     });
   });
 
   describe("FeedbackView", function() {
     var comp, fakeFeedbackApiClient;
 
     beforeEach(function() {
-      sandbox.stub(l10n, "get", function(x) {
-        return x;
-      });
       fakeFeedbackApiClient = {send: sandbox.stub()};
       comp = TestUtils.renderIntoDocument(sharedViews.FeedbackView({
         feedbackApiClient: fakeFeedbackApiClient
       }));
     });
 
     // local test helpers
     function clickHappyFace(comp) {
@@ -596,19 +596,16 @@ describe("loop.shared.views", function()
   describe("NotificationListView", function() {
     var coll, view, testNotif;
 
     function mountTestComponent(props) {
       return TestUtils.renderIntoDocument(sharedViews.NotificationListView(props));
     }
 
     beforeEach(function() {
-      sandbox.stub(l10n, "get", function(x) {
-        return "translated:" + x;
-      });
       coll = new sharedModels.NotificationCollection();
       view = mountTestComponent({notifications: coll});
       testNotif = {level: "warning", message: "foo"};
       sinon.spy(view, "render");
     });
 
     afterEach(function() {
       view.render.restore();
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -379,17 +379,17 @@ describe("loop.webapp", function() {
         describe("subscribedStream", function() {
           it("should not notify the websocket if only one stream is up",
             function() {
               conversation.set("subscribedStream", true);
 
               sinon.assert.notCalled(ocView._websocket.mediaUp);
             });
 
-          it("should notify the websocket that media is up if both streams" +
+          it("should notify tloadhe websocket that media is up if both streams" +
              "are connected", function() {
               conversation.set("publishedStream", true);
               conversation.set("subscribedStream", true);
 
               sinon.assert.calledOnce(ocView._websocket.mediaUp);
             });
         });
       });
@@ -686,50 +686,79 @@ describe("loop.webapp", function() {
 
       beforeEach(function() {
         conversation = new sharedModels.ConversationModel({
           loopToken: "fake"
         }, {
           sdk: {}
         });
 
-        sandbox.spy(conversation, "listenTo");
+        conversation.onMarketplaceMessage = function() {};
+        sandbox.stub(notifications, "errorL10n");
         requestCallUrlInfo = sandbox.stub();
 
         view = React.addons.TestUtils.renderIntoDocument(
             loop.webapp.StartConversationView({
               model: conversation,
               notifications: notifications,
               client: {requestCallUrlInfo: requestCallUrlInfo}
             })
           );
+
+        loop.config.marketplaceUrl = "http://market/";
       });
 
       it("should call requestCallUrlInfo", function() {
         sinon.assert.calledOnce(requestCallUrlInfo);
         sinon.assert.calledWithExactly(requestCallUrlInfo,
                                        sinon.match.string,
                                        sinon.match.func);
       });
 
-      it("should listen for session:error events", function() {
-        sinon.assert.calledOnce(conversation.listenTo);
-        sinon.assert.calledWithExactly(conversation.listenTo, conversation,
-                                       "session:error", sinon.match.func);
+      it("should add a notification when a session:error model event is " +
+         " received without an argument", function() {
+        conversation.trigger("session:error");
+
+        sinon.assert.calledOnce(notifications.errorL10n);
+        sinon.assert.calledWithExactly(notifications.errorL10n,
+          sinon.match.string, undefined);
       });
 
-      it("should trigger a notication when a session:error model event is " +
-         " received", function() {
-        sandbox.stub(notifications, "errorL10n");
-        conversation.trigger("session:error", "tech error");
+      it("should add a notification with the custom message id when a " +
+         "session:error event is fired with an argument", function() {
+        conversation.trigger("session:error", "tech_error");
 
         sinon.assert.calledOnce(notifications.errorL10n);
         sinon.assert.calledWithExactly(notifications.errorL10n,
-                                       "unable_retrieve_call_info");
+                                       "tech_error", undefined);
+      });
+
+      it("should add a notification with the custom message id when a " +
+         "session:error event is fired with an argument and parameters",
+         function() {
+          conversation.trigger("session:error", "tech_error", {param: "value"});
+
+          sinon.assert.calledOnce(notifications.errorL10n);
+          sinon.assert.calledWithExactly(notifications.errorL10n,
+                                         "tech_error", { param: "value" });
       });
+
+      it("should set marketplace hidden iframe src when fxos:app-needed is " +
+         "triggered", function(done) {
+        var marketplace = view.getDOMNode().querySelector("#marketplace");
+        expect(marketplace.src).to.be.equal("");
+
+        conversation.trigger("fxos:app-needed");
+
+        view.forceUpdate(function() {
+          expect(marketplace.src).to.be.equal(loop.config.marketplaceUrl);
+          done();
+        });
+      });
+
     });
 
     describe("#render", function() {
       var conversation, view, requestCallUrlInfo, oldLocalStorageValue;
 
       beforeEach(function() {
         oldLocalStorageValue = localStorage.getItem("has-seen-tos");
         localStorage.removeItem("has-seen-tos");
@@ -821,9 +850,203 @@ describe("loop.webapp", function() {
         var comp = TestUtils.renderIntoDocument(loop.webapp.PromoteFirefoxView({
           helper: {isFirefox: function() { return false; }}
         }));
 
         expect(comp.getDOMNode().querySelectorAll("h3").length).eql(1);
       });
     });
   });
+
+  describe("Firefox OS", function() {
+    var conversation, client;
+
+    before(function() {
+      client = new loop.StandaloneClient({
+        baseServerUrl: "http://fake.example.com"
+      });
+      sandbox.stub(client, "requestCallInfo");
+      conversation = new sharedModels.ConversationModel({}, {
+        sdk: {},
+        pendingCallTimeout: 1000
+      });
+    });
+
+    describe("Setup call", function() {
+      var conversation, setupOutgoingCall, view, requestCallUrlInfo;
+
+      beforeEach(function() {
+        conversation = new loop.webapp.FxOSConversationModel({
+          loopToken: "fakeToken"
+        });
+        setupOutgoingCall = sandbox.stub(conversation, "setupOutgoingCall");
+
+        var standaloneClientStub = {
+          requestCallUrlInfo: function(token, cb) {
+            cb(null, {urlCreationDate: 0});
+          },
+          settings: {baseServerUrl: loop.webapp.baseServerUrl}
+        };
+
+        view = React.addons.TestUtils.renderIntoDocument(
+            loop.webapp.StartConversationView({
+              model: conversation,
+              notifications: notifications,
+              client: standaloneClientStub
+            })
+        );
+      });
+
+      it("should start the conversation establishment process", function() {
+        var button = view.getDOMNode().querySelector("button");
+        React.addons.TestUtils.Simulate.click(button);
+
+        sinon.assert.calledOnce(setupOutgoingCall);
+      });
+    });
+
+    describe("FxOSConversationModel", function() {
+      var model, realMozActivity;
+
+      before(function() {
+        model = new loop.webapp.FxOSConversationModel({
+          loopToken: "fakeToken",
+          callerId: "callerId",
+          callType: "callType"
+        });
+
+        realMozActivity = window.MozActivity;
+
+        loop.config.fxosApp = {
+          name: "Firefox Hello"
+        };
+      });
+
+      after(function() {
+        window.MozActivity = realMozActivity;
+      });
+
+      describe("setupOutgoingCall", function() {
+        var _activityProps, _onerror, trigger;
+
+        function fireError(errorName) {
+          _onerror({
+            target: {
+              error: {
+                name: errorName
+              }
+            }
+          });
+        }
+
+        before(function() {
+          window.MozActivity = function(activityProps) {
+            _activityProps = activityProps;
+            return {
+              set onerror(callback) {
+                _onerror = callback;
+              }
+            };
+          };
+        });
+
+        after(function() {
+          window.MozActivity = realMozActivity;
+        });
+
+        beforeEach(function() {
+          trigger = sandbox.stub(model, "trigger");
+        });
+
+        afterEach(function() {
+          trigger.restore();
+        });
+
+        it("Activity properties", function() {
+          expect(_activityProps).to.not.exist;
+          model.setupOutgoingCall();
+          expect(_activityProps).to.exist;
+          expect(_activityProps).eql({
+            name: "loop-call",
+            data: {
+              type: "loop/token",
+              token: "fakeToken",
+              callerId: "callerId",
+              callType: "callType"
+            }
+          });
+        });
+
+        it("NO_PROVIDER activity error should trigger fxos:app-needed",
+          function() {
+            sinon.assert.notCalled(trigger);
+            model.setupOutgoingCall();
+            fireError("NO_PROVIDER");
+            sinon.assert.calledOnce(trigger);
+            sinon.assert.calledWithExactly(trigger, "fxos:app-needed");
+          }
+        );
+
+        it("Other activity error should trigger session:error",
+          function() {
+            sinon.assert.notCalled(trigger);
+            model.setupOutgoingCall();
+            fireError("whatever");
+            sinon.assert.calledOnce(trigger);
+            sinon.assert.calledWithExactly(trigger, "session:error",
+              "fxos_app_needed", { fxosAppName: loop.config.fxosApp.name });
+          }
+        );
+      });
+
+      describe("onMarketplaceMessage", function() {
+        var view, setupOutgoingCall, trigger;
+
+        before(function() {
+          view = React.addons.TestUtils.renderIntoDocument(
+            loop.webapp.StartConversationView({
+              model: model,
+              notifications: notifications,
+              client: {requestCallUrlInfo: sandbox.stub()}
+            })
+          );
+        });
+
+        beforeEach(function() {
+          setupOutgoingCall = sandbox.stub(model, "setupOutgoingCall");
+          trigger = sandbox.stub(model, "trigger");
+        });
+
+        afterEach(function() {
+          setupOutgoingCall.restore();
+          trigger.restore();
+        });
+
+        it("We should call trigger a FxOS outgoing call if we get " +
+           "install-package message without error", function() {
+          sinon.assert.notCalled(setupOutgoingCall);
+          model.onMarketplaceMessage({
+            data: {
+              name: "install-package"
+            }
+          });
+          sinon.assert.calledOnce(setupOutgoingCall);
+        });
+
+        it("We should trigger a session:error event if we get " +
+           "install-package message with an error", function() {
+          sinon.assert.notCalled(trigger);
+          sinon.assert.notCalled(setupOutgoingCall);
+          model.onMarketplaceMessage({
+            data: {
+              name: "install-package",
+              error: "error"
+            }
+          });
+          sinon.assert.notCalled(setupOutgoingCall);
+          sinon.assert.calledOnce(trigger);
+          sinon.assert.calledWithExactly(trigger, "session:error",
+            "fxos_app_needed", { fxosAppName: loop.config.fxosApp.name });
+        });
+      });
+    });
+  });
 });