Bug 1022594 - Part 2: Desktop client needs ability to decline an incoming call - set up a basic websocket protocol and use for both desktop and standalone UI. r=dmose, a=lmandel
authorMark Banner <standard8@mozilla.com>
Fri, 15 Aug 2014 13:33:51 +0100
changeset 216679 062929c9ff5d
parent 216678 e0ad01b2e26e
child 216680 be539410c211
push id3872
push userryanvm@gmail.com
push date2014-09-08 19:43 +0000
treeherdermozilla-beta@d820ef3b256d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdmose, lmandel
bugs1022594
milestone33.0
Bug 1022594 - Part 2: Desktop client needs ability to decline an incoming call - set up a basic websocket protocol and use for both desktop and standalone UI. r=dmose, a=lmandel
browser/app/profile/firefox.js
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/conversation.html
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/shared/js/models.js
browser/components/loop/content/shared/js/websocket.js
browser/components/loop/jar.mn
browser/components/loop/standalone/content/index.html
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/desktop-local/index.html
browser/components/loop/test/mochitest/browser.ini
browser/components/loop/test/mochitest/browser_mozLoop_charPref.js
browser/components/loop/test/mochitest/browser_mozLoop_prefs.js
browser/components/loop/test/shared/index.html
browser/components/loop/test/shared/models_test.js
browser/components/loop/test/shared/websocket_test.js
browser/components/loop/test/standalone/index.html
browser/components/loop/test/standalone/webapp_test.js
browser/components/loop/test/xpcshell/test_loopservice_get_loop_char_pref.js
browser/components/loop/test/xpcshell/test_loopservice_loop_prefs.js
browser/components/loop/test/xpcshell/test_loopservice_set_loop_char_pref.js
browser/components/loop/test/xpcshell/xpcshell.ini
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1569,16 +1569,17 @@ pref("loop.enabled", false);
 #endif
 
 pref("loop.server", "https://loop.services.mozilla.com");
 pref("loop.seenToS", "unseen");
 pref("loop.do_not_disturb", false);
 pref("loop.ringtone", "chrome://browser/content/loop/shared/sounds/Firefox-Long.ogg");
 pref("loop.retry_delay.start", 60000);
 pref("loop.retry_delay.limit", 300000);
+pref("loop.debug.websocket", false);
 
 // serverURL to be assigned by services team
 pref("services.push.serverURL", "wss://push.services.mozilla.com/");
 
 pref("social.sidebar.unload_timeout_ms", 10000);
 
 pref("dom.identity.enabled", false);
 
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -155,16 +155,37 @@ function injectLoopAPI(targetWindow) {
       configurable: true,
       writable: true,
       value: function(prefName) {
         return MozLoopService.getLoopCharPref(prefName);
       }
     },
 
     /**
+     * Return any preference under "loop." that's coercible to a boolean
+     * preference.
+     *
+     * @param {String} prefName The name of the pref without the preceding
+     * "loop."
+     *
+     * Any errors thrown by the Mozilla pref API are logged to the console
+     * and cause null to be returned. This includes the case of the preference
+     * not being found.
+     *
+     * @return {String} on success, null on error
+     */
+    getLoopBoolPref: {
+      enumerable: true,
+      writable: true,
+      value: function(prefName) {
+        return MozLoopService.getLoopBoolPref(prefName);
+      }
+    },
+
+    /**
      * Starts alerting the user about an incoming call
      */
     startAlerting: {
       enumerable: true,
       configurable: true,
       writable: true,
       value: function() {
         let chromeWindow = getChromeWindow(targetWindow);
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -602,16 +602,39 @@ this.MozLoopService = {
     } catch (ex) {
       console.log("getLoopCharPref had trouble getting " + prefName +
         "; exception: " + ex);
       return null;
     }
   },
 
   /**
+   * Return any preference under "loop." that's coercible to a character
+   * preference.
+   *
+   * @param {String} prefName The name of the pref without the preceding
+   * "loop."
+   *
+   * Any errors thrown by the Mozilla pref API are logged to the console
+   * and cause null to be returned. This includes the case of the preference
+   * not being found.
+   *
+   * @return {String} on success, null on error
+   */
+  getLoopBoolPref: function(prefName) {
+    try {
+      return Services.prefs.getBoolPref("loop." + prefName);
+    } catch (ex) {
+      console.log("getLoopBoolPref had trouble getting " + prefName +
+        "; exception: " + ex);
+      return null;
+    }
+  },
+
+  /**
    * Performs a hawk based request to the loop server.
    *
    * @param {String} path The path to make the request to.
    * @param {String} method The request method, e.g. 'POST', 'GET'.
    * @param {Object} payloadObj An object which is converted to JSON and
    *                            transmitted with the request.
    * @returns {Promise}
    *        Returns a promise that resolves to the response of the API call,
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -29,13 +29,14 @@
     <script type="text/javascript" src="loop/shared/libs/react-0.10.0.js"></script>
     <script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
     <script type="text/javascript" src="loop/shared/js/router.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
+    <script type="text/javascript" src="loop/shared/js/websocket.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
     <script type="text/javascript" src="loop/js/desktopRouter.js"></script>
     <script type="text/javascript" src="loop/js/conversation.js"></script>
   </body>
 </html>
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -148,69 +148,110 @@ loop.conversation = (function(OT, mozL10
       this._conversation.once("decline", () => {
         this.navigate("call/decline", {trigger: true});
       });
       this._conversation.once("call:incoming", this.startCall, this);
       this._client.requestCallsInfo(loopVersion, (err, sessionData) => {
         if (err) {
           console.error("Failed to get the sessionData", err);
           // XXX Not the ideal response, but bug 1047410 will be replacing
-          //this by better "call failed" UI.
+          // this by better "call failed" UI.
           this._notifier.errorL10n("cannot_start_call_session_not_ready");
           return;
         }
         // XXX For incoming calls we might have more than one call queued.
         // For now, we'll just assume the first call is the right information.
         // We'll probably really want to be getting this data from the
         // background worker on the desktop client.
         // Bug 1032700 should fix this.
         this._conversation.setSessionData(sessionData[0]);
+
+        this._setupWebSocketAndCallView();
+      });
+    },
+
+    /**
+     * Used to set up the web socket connection and navigate to the
+     * call view if appropriate.
+     */
+    _setupWebSocketAndCallView: function() {
+      this._websocket = new loop.CallConnectionWebSocket({
+        url: this._conversation.get("progressURL"),
+        websocketToken: this._conversation.get("websocketToken"),
+        callId: this._conversation.get("callId"),
+      });
+      this._websocket.promiseConnect().then(function() {
         this.loadReactComponent(loop.conversation.IncomingCallView({
           model: this._conversation
         }));
-      });
+      }.bind(this), function() {
+        this._handleSessionError();
+        return;
+      }.bind(this));
     },
 
     /**
      * Accepts an incoming call.
      */
     accept: function() {
       window.navigator.mozLoop.stopAlerting();
       this._conversation.incoming();
     },
 
     /**
+     * Declines a call and handles closing of the window.
+     */
+    _declineCall: function() {
+      this._websocket.decline();
+      // XXX Don't close the window straight away, but let any sends happen
+      // first. Ideally we'd wait to close the window until after we have a
+      // response from the server, to know that everything has completed
+      // successfully. However, that's quite difficult to ensure at the
+      // moment so we'll add it later.
+      setTimeout(window.close, 0);
+    },
+
+    /**
      * Declines an incoming call.
      */
     decline: function() {
       window.navigator.mozLoop.stopAlerting();
       // XXX For now, we just close the window
-      window.close();
+      this._declineCall();
     },
 
     /**
      * conversation is the route when the conversation is active. The start
      * route should be navigated to first.
      */
     conversation: function() {
       if (!this._conversation.isSessionReady()) {
         console.error("Error: navigated to conversation route without " +
           "the start route to initialise the call first");
-        this._notifier.errorL10n("cannot_start_call_session_not_ready");
+        this._handleSessionError();
         return;
       }
 
       /*jshint newcap:false*/
       this.loadReactComponent(sharedViews.ConversationView({
         sdk: OT,
         model: this._conversation
       }));
     },
 
     /**
+     * Handles a error starting the session
+     */
+    _handleSessionError: function() {
+      // XXX Not the ideal response, but bug 1047410 will be replacing
+      // this by better "call failed" UI.
+      this._notifier.errorL10n("cannot_start_call_session_not_ready");
+    },
+
+    /**
      * XXX: load a view with a close button for now?
      */
     ended: function() {
       this.loadView(new EndedCallView());
     }
   });
 
   /**
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -148,69 +148,110 @@ loop.conversation = (function(OT, mozL10
       this._conversation.once("decline", () => {
         this.navigate("call/decline", {trigger: true});
       });
       this._conversation.once("call:incoming", this.startCall, this);
       this._client.requestCallsInfo(loopVersion, (err, sessionData) => {
         if (err) {
           console.error("Failed to get the sessionData", err);
           // XXX Not the ideal response, but bug 1047410 will be replacing
-          //this by better "call failed" UI.
+          // this by better "call failed" UI.
           this._notifier.errorL10n("cannot_start_call_session_not_ready");
           return;
         }
         // XXX For incoming calls we might have more than one call queued.
         // For now, we'll just assume the first call is the right information.
         // We'll probably really want to be getting this data from the
         // background worker on the desktop client.
         // Bug 1032700 should fix this.
         this._conversation.setSessionData(sessionData[0]);
+
+        this._setupWebSocketAndCallView();
+      });
+    },
+
+    /**
+     * Used to set up the web socket connection and navigate to the
+     * call view if appropriate.
+     */
+    _setupWebSocketAndCallView: function() {
+      this._websocket = new loop.CallConnectionWebSocket({
+        url: this._conversation.get("progressURL"),
+        websocketToken: this._conversation.get("websocketToken"),
+        callId: this._conversation.get("callId"),
+      });
+      this._websocket.promiseConnect().then(function() {
         this.loadReactComponent(loop.conversation.IncomingCallView({
           model: this._conversation
         }));
-      });
+      }.bind(this), function() {
+        this._handleSessionError();
+        return;
+      }.bind(this));
     },
 
     /**
      * Accepts an incoming call.
      */
     accept: function() {
       window.navigator.mozLoop.stopAlerting();
       this._conversation.incoming();
     },
 
     /**
+     * Declines a call and handles closing of the window.
+     */
+    _declineCall: function() {
+      this._websocket.decline();
+      // XXX Don't close the window straight away, but let any sends happen
+      // first. Ideally we'd wait to close the window until after we have a
+      // response from the server, to know that everything has completed
+      // successfully. However, that's quite difficult to ensure at the
+      // moment so we'll add it later.
+      setTimeout(window.close, 0);
+    },
+
+    /**
      * Declines an incoming call.
      */
     decline: function() {
       window.navigator.mozLoop.stopAlerting();
       // XXX For now, we just close the window
-      window.close();
+      this._declineCall();
     },
 
     /**
      * conversation is the route when the conversation is active. The start
      * route should be navigated to first.
      */
     conversation: function() {
       if (!this._conversation.isSessionReady()) {
         console.error("Error: navigated to conversation route without " +
           "the start route to initialise the call first");
-        this._notifier.errorL10n("cannot_start_call_session_not_ready");
+        this._handleSessionError();
         return;
       }
 
       /*jshint newcap:false*/
       this.loadReactComponent(sharedViews.ConversationView({
         sdk: OT,
         model: this._conversation
       }));
     },
 
     /**
+     * Handles a error starting the session
+     */
+    _handleSessionError: function() {
+      // XXX Not the ideal response, but bug 1047410 will be replacing
+      // this by better "call failed" UI.
+      this._notifier.errorL10n("cannot_start_call_session_not_ready");
+    },
+
+    /**
      * XXX: load a view with a close button for now?
      */
     ended: function() {
       this.loadView(new EndedCallView());
     }
   });
 
   /**
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -19,17 +19,22 @@ loop.shared.models = (function() {
       callerId:     undefined, // Loop caller id
       loopToken:    undefined, // Loop conversation token
       loopVersion:  undefined, // Loop version for /calls/ information. This
                                // is the version received from the push
                                // notification and is used by the server to
                                // determine the pending calls
       sessionId:    undefined, // OT session id
       sessionToken: undefined, // OT session token
-      apiKey:       undefined  // OT api key
+      apiKey:       undefined,  // OT api key
+      callId:       undefined,     // The callId on the server
+      progressURL:  undefined,     // The websocket url to use for progress
+      websocketToken: undefined    // The token to use for websocket auth, this is
+                                   // stored as a hex string which is what the server
+                                   // requires.
     },
 
     /**
      * SDK object.
      * @type {OT}
      */
     sdk: undefined,
 
@@ -130,19 +135,22 @@ loop.shared.models = (function() {
     /**
      * Sets session information.
      *
      * @param {Object} sessionData Conversation session information.
      */
     setSessionData: function(sessionData) {
       // Explicit property assignment to prevent later "surprises"
       this.set({
-        sessionId:    sessionData.sessionId,
-        sessionToken: sessionData.sessionToken,
-        apiKey:       sessionData.apiKey
+        sessionId:      sessionData.sessionId,
+        sessionToken:   sessionData.sessionToken,
+        apiKey:         sessionData.apiKey,
+        callId:         sessionData.callId,
+        progressURL:    sessionData.progressURL,
+        websocketToken: sessionData.websocketToken.toString(16)
       });
     },
 
     /**
      * Starts a SDK session and subscribe to call events.
      */
     startSession: function() {
       if (!this.isSessionReady()) {
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/websocket.js
@@ -0,0 +1,237 @@
+/* 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 */
+
+var loop = loop || {};
+loop.CallConnectionWebSocket = (function() {
+  "use strict";
+
+  // Response timeout is 5 seconds as per API.
+  var kResponseTimeout = 5000;
+
+  /**
+   * Handles a websocket specifically for a call connection.
+   *
+   * There should be one of these created for each call connection.
+   *
+   * options items:
+   * - url             The url of the websocket to connect to.
+   * - callId          The call id for the call
+   * - websocketToken  The authentication token for the websocket
+   *
+   * @param {Object} options The options for this websocket.
+   */
+  function CallConnectionWebSocket(options) {
+    this.options = options || {};
+
+    if (!this.options.url) {
+      throw new Error("No url in options");
+    }
+    if (!this.options.callId) {
+      throw new Error("No callId in options");
+    }
+    if (!this.options.websocketToken) {
+      throw new Error("No websocketToken in options");
+    }
+
+    // Save the debug pref now, to avoid getting it each time.
+    if (navigator.mozLoop) {
+      this._debugWebSocket =
+        navigator.mozLoop.getLoopBoolPref("debug.websocket");
+    }
+
+    _.extend(this, Backbone.Events);
+  };
+
+  CallConnectionWebSocket.prototype = {
+    /**
+     * Start the connection to the websocket.
+     *
+     * @return {Promise} A promise that resolves when the websocket
+     *                   server connection is open and "hello"s have been
+     *                   exchanged. It is rejected if there is a failure in
+     *                   connection or the initial exchange of "hello"s.
+     */
+    promiseConnect: function() {
+      var promise = new Promise(
+        function(resolve, reject) {
+          this.socket = new WebSocket(this.options.url);
+          this.socket.onopen = this._onopen.bind(this);
+          this.socket.onmessage = this._onmessage.bind(this);
+          this.socket.onerror = this._onerror.bind(this);
+          this.socket.onclose = this._onclose.bind(this);
+
+          var timeout = setTimeout(function() {
+            if (this.connectDetails && this.connectDetails.reject) {
+              this.connectDetails.reject("timeout");
+              this._clearConnectionFlags();
+            }
+          }.bind(this), kResponseTimeout);
+          this.connectDetails = {
+            resolve: resolve,
+            reject: reject,
+            timeout: timeout
+          };
+        }.bind(this));
+
+      return promise;
+    },
+
+    _clearConnectionFlags: function() {
+      clearTimeout(this.connectDetails.timeout);
+      delete this.connectDetails;
+    },
+
+    /**
+     * Internal function called to resolve the connection promise.
+     *
+     * It will log an error if no promise is found.
+     */
+    _completeConnection: function() {
+      if (this.connectDetails && this.connectDetails.resolve) {
+        this.connectDetails.resolve();
+        this._clearConnectionFlags();
+        return;
+      }
+
+      console.error("Failed to complete connection promise - no promise available");
+    },
+
+    /**
+     * Checks if the websocket is connecting, and rejects the connection
+     * promise if appropriate.
+     *
+     * @param {Object} event The event to reject the promise with if
+     *                       appropriate.
+     */
+    _checkConnectionFailed: function(event) {
+      if (this.connectDetails && this.connectDetails.reject) {
+        this.connectDetails.reject(event);
+        this._clearConnectionFlags();
+        return true;
+      }
+
+      return false;
+    },
+
+    /**
+     * Notifies the server that the user has declined the call.
+     */
+    decline: function() {
+      this._send({
+        messageType: "action",
+        event: "terminate",
+        reason: "reject"
+      });
+    },
+
+    /**
+     * Sends data on the websocket.
+     *
+     * @param {Object} data The data to send.
+     */
+    _send: function(data) {
+      this._log("WS Sending", data);
+
+      this.socket.send(JSON.stringify(data));
+    },
+
+    /**
+     * Used to determine if the server state is in a completed state, i.e.
+     * the server has determined the connection is terminated or connected.
+     *
+     * @return True if the last received state is terminated or connected.
+     */
+    get _stateIsCompleted() {
+      return this._lastServerState === "terminated" ||
+             this._lastServerState === "connected";
+    },
+
+    /**
+     * Called when the socket is open. Automatically sends a "hello"
+     * message to the server.
+     */
+    _onopen: function() {
+      // Auto-register with the server.
+      this._send({
+        messageType: "hello",
+        callId: this.options.callId,
+        auth: this.options.websocketToken
+      });
+    },
+
+    /**
+     * Called when a message is received from the server.
+     *
+     * @param {Object} event The websocket onmessage event.
+     */
+    _onmessage: function(event) {
+      var msg;
+      try {
+        msg = JSON.parse(event.data);
+      } catch (x) {
+        console.error("Error parsing received message:", x);
+        return;
+      }
+
+      this._log("WS Receiving", event.data);
+
+      this._lastServerState = msg.state;
+
+      switch(msg.messageType) {
+        case "hello":
+          this._completeConnection();
+          break;
+        case "progress":
+          this.trigger("progress", msg);
+          break;
+      }
+    },
+
+    /**
+     * Called when there is an error on the websocket.
+     *
+     * @param {Object} event A simple error event.
+     */
+    _onerror: function(event) {
+      this._log("WS Error", event);
+
+      if (!this._stateIsCompleted &&
+          !this._checkConnectionFailed(event)) {
+        this.trigger("error", event);
+      }
+    },
+
+    /**
+     * Called when the websocket is closed.
+     *
+     * @param {CloseEvent} event The details of the websocket closing.
+     */
+    _onclose: function(event) {
+      this._log("WS Close", event);
+
+      // If the websocket goes away when we're not in a completed state
+      // then its an error. So we either pass it back via the connection
+      // promise, or trigger the closed event.
+      if (!this._stateIsCompleted &&
+          !this._checkConnectionFailed(event)) {
+        this.trigger("closed", event);
+      }
+    },
+
+    /**
+     * Logs debug to the console.
+     *
+     * Parameters: same as console.log
+     */
+    _log: function() {
+      if (this._debugWebSocket) {
+        console.log.apply(console, arguments);
+      }
+    }
+  };
+
+  return CallConnectionWebSocket;
+})();
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -32,19 +32,20 @@ browser.jar:
   content/browser/loop/shared/img/hangup-inverse-14x14.png      (content/shared/img/hangup-inverse-14x14.png)
   content/browser/loop/shared/img/hangup-inverse-14x14@2x.png   (content/shared/img/hangup-inverse-14x14@2x.png)
   content/browser/loop/shared/img/mute-inverse-14x14.png        (content/shared/img/mute-inverse-14x14.png)
   content/browser/loop/shared/img/mute-inverse-14x14@2x.png     (content/shared/img/mute-inverse-14x14@2x.png)
   content/browser/loop/shared/img/video-inverse-14x14.png       (content/shared/img/video-inverse-14x14.png)
   content/browser/loop/shared/img/video-inverse-14x14@2x.png    (content/shared/img/video-inverse-14x14@2x.png)
 
   # Shared scripts
-  content/browser/loop/shared/js/models.js  (content/shared/js/models.js)
-  content/browser/loop/shared/js/router.js  (content/shared/js/router.js)
-  content/browser/loop/shared/js/views.js   (content/shared/js/views.js)
+  content/browser/loop/shared/js/models.js    (content/shared/js/models.js)
+  content/browser/loop/shared/js/router.js    (content/shared/js/router.js)
+  content/browser/loop/shared/js/views.js     (content/shared/js/views.js)
+  content/browser/loop/shared/js/websocket.js (content/shared/js/websocket.js)
 
   # Shared libs
   content/browser/loop/shared/libs/react-0.10.0.js    (content/shared/libs/react-0.10.0.js)
   content/browser/loop/shared/libs/lodash-2.4.1.js    (content/shared/libs/lodash-2.4.1.js)
   content/browser/loop/shared/libs/jquery-2.1.0.js    (content/shared/libs/jquery-2.1.0.js)
   content/browser/loop/shared/libs/backbone-1.1.2.js  (content/shared/libs/backbone-1.1.2.js)
 
   # Shared sounds
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -29,16 +29,17 @@
     <script type="text/javascript" src="shared/libs/lodash-2.4.1.js"></script>
     <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/models.js"></script>
     <script type="text/javascript" src="shared/js/views.js"></script>
     <script type="text/javascript" src="shared/js/router.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>
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     window.addEventListener('localized', function() {
       document.documentElement.lang = document.webL10n.getLanguage();
       document.documentElement.dir = document.webL10n.getDirection();
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -155,23 +155,78 @@ loop.webapp = (function($, _, OT) {
      * Actually starts the call.
      */
     startCall: function() {
       var loopToken = this._conversation.get("loopToken");
       if (!loopToken) {
         this._notifier.errorL10n("missing_conversation_info");
         this.navigate("home", {trigger: true});
       } else {
+        this._setupWebSocketAndCallView(loopToken);
+      }
+    },
+
+    /**
+     * Used to set up the web socket connection and navigate to the
+     * call view if appropriate.
+     *
+     * @param {string} loopToken The session token to use.
+     */
+    _setupWebSocketAndCallView: function(loopToken) {
+      this._websocket = new loop.CallConnectionWebSocket({
+        url: this._conversation.get("progressURL"),
+        websocketToken: this._conversation.get("websocketToken"),
+        callId: this._conversation.get("callId"),
+      });
+      this._websocket.promiseConnect().then(function() {
         this.navigate("call/ongoing/" + loopToken, {
           trigger: true
         });
+      }.bind(this), function() {
+        // XXX Not the ideal response, but bug 1047410 will be replacing
+        // this by better "call failed" UI.
+        this._notifier.errorL10n("cannot_start_call_session_not_ready");
+        return;
+      }.bind(this));
+
+      this._websocket.on("progress", this._handleWebSocketProgress, this);
+    },
+
+    /**
+     * Used to receive websocket progress and to determine how to handle
+     * it if appropraite.
+     */
+    _handleWebSocketProgress: function(progressData) {
+      if (progressData.state === "terminated") {
+        // XXX Before adding more states here, the basic protocol messages to the
+        // server need implementing on both the standalone and desktop side.
+        // These are covered by bug 1045643, but also check the dependencies on
+        // bug 1034041.
+        //
+        // Failure to do this will break desktop - standalone call setup. We're
+        // ok to handle reject, as that is a specific message from the destkop via
+        // the server.
+        switch (progressData.reason) {
+          case "reject":
+            this._handleCallRejected();
+        }
       }
     },
 
     /**
+     * Handles call rejection.
+     * XXX This should really display the call failed view - bug 1046959
+     * will implement this.
+     */
+    _handleCallRejected: function() {
+      this.endCall();
+      this._notifier.errorL10n("call_timeout_notification_text");
+    },
+
+    /**
      * @override {loop.shared.router.BaseConversationRouter.endCall}
      */
     endCall: function() {
       var route = "home";
       if (this._conversation.get("loopToken")) {
         route = "call/" + this._conversation.get("loopToken");
       }
       this.navigate(route, {trigger: true});
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -10,16 +10,17 @@ describe("loop.conversation", function()
   "use strict";
 
   var ConversationRouter = loop.conversation.ConversationRouter,
       sandbox,
       notifier;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
+    sandbox.useFakeTimers();
     notifier = {
       notify: sandbox.spy(),
       warn: sandbox.spy(),
       warnL10n: sandbox.spy(),
       error: sandbox.spy(),
       errorL10n: sandbox.spy()
     };
 
@@ -106,17 +107,16 @@ describe("loop.conversation", function()
 
     beforeEach(function() {
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {},
         pendingCallTimeout: 1000,
       });
       sandbox.stub(client, "requestCallsInfo");
-      sandbox.stub(conversation, "setSessionData");
     });
 
     describe("Routes", function() {
       var router;
 
       beforeEach(function() {
         router = new ConversationRouter({
           client: client,
@@ -173,48 +173,134 @@ describe("loop.conversation", function()
             client.requestCallsInfo.callsArgWith(1, "failed");
 
             router.incoming(42);
 
             sinon.assert.calledOnce(notifier.errorL10n);
           });
 
         describe("requestCallsInfo successful", function() {
-          var fakeSessionData;
+          var fakeSessionData, resolvePromise, rejectPromise;
 
           beforeEach(function() {
             fakeSessionData  = {
-              sessionId:    "sessionId",
-              sessionToken: "sessionToken",
-              apiKey:       "apiKey"
+              sessionId:      "sessionId",
+              sessionToken:   "sessionToken",
+              apiKey:         "apiKey",
+              callId:         "Hello",
+              progressURL:    "http://progress.example.com",
+              websocketToken: 123
             };
 
+            sandbox.stub(router, "_setupWebSocketAndCallView");
+            sandbox.stub(conversation, "setSessionData");
+
             client.requestCallsInfo.callsArgWith(1, null, [fakeSessionData]);
           });
 
           it("should store the session data", function() {
-            router.incoming(42);
+            router.incoming("fakeVersion");
 
             sinon.assert.calledOnce(conversation.setSessionData);
             sinon.assert.calledWithExactly(conversation.setSessionData,
                                            fakeSessionData);
           });
 
-          it("should display the incoming call view", function() {
+          it("should call #_setupWebSocketAndCallView", function() {
             router.incoming("fakeVersion");
 
-            sinon.assert.calledOnce(loop.conversation.IncomingCallView);
-            sinon.assert.calledWithExactly(loop.conversation.IncomingCallView,
-                                           {model: conversation});
-            sinon.assert.calledOnce(router.loadReactComponent);
-            sinon.assert.calledWith(router.loadReactComponent,
+            sinon.assert.calledOnce(router._setupWebSocketAndCallView);
+            sinon.assert.calledWithExactly(router._setupWebSocketAndCallView);
+          });
+        });
+
+        describe("#_setupWebSocketAndCallView", function() {
+          beforeEach(function() {
+            conversation.setSessionData({
+              sessionId:      "sessionId",
+              sessionToken:   "sessionToken",
+              apiKey:         "apiKey",
+              callId:         "Hello",
+              progressURL:    "http://progress.example.com",
+              websocketToken: 123
+            });
+          });
+
+          describe("Websocket connection successful", function() {
+            var promise;
+
+            beforeEach(function() {
+              sandbox.stub(loop, "CallConnectionWebSocket").returns({
+                promiseConnect: function() {
+                  promise = new Promise(function(resolve, reject) {
+                    resolve();
+                  });
+                  return promise;
+                }
+              });
+            });
+
+            it("should create a CallConnectionWebSocket", function(done) {
+              router._setupWebSocketAndCallView();
+
+              promise.then(function () {
+                sinon.assert.calledOnce(loop.CallConnectionWebSocket);
+                sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
+                  callId: "Hello",
+                  url: "http://progress.example.com",
+                  // The websocket token is converted to a hex string.
+                  websocketToken: "7b"
+                });
+                done();
+              });
+            });
+
+            it("should display the incoming call view", function(done) {
+              router._setupWebSocketAndCallView();
+
+              promise.then(function () {
+              sinon.assert.calledOnce(loop.conversation.IncomingCallView);
+              sinon.assert.calledWithExactly(loop.conversation.IncomingCallView,
+                                            {model: conversation});
+              sinon.assert.calledOnce(router.loadReactComponent);
+              sinon.assert.calledWith(router.loadReactComponent,
               sinon.match(function(value) {
                 return TestUtils.isComponentOfType(value,
                   loop.conversation.IncomingCallView);
               }));
+              done();
+            });
+            });
+          });
+
+          describe("Websocket connection failed", function() {
+            var promise;
+
+            beforeEach(function() {
+              sandbox.stub(loop, "CallConnectionWebSocket").returns({
+                promiseConnect: function() {
+                  promise = new Promise(function(resolve, reject) {
+                    reject();
+                  });
+                  return promise;
+                }
+              });
+            });
+
+            it("should display an error", function(done) {
+              router._setupWebSocketAndCallView();
+
+              promise.then(function() {
+              }, function () {
+                sinon.assert.calledOnce(router._notifier.errorL10n);
+                sinon.assert.calledWithExactly(router._notifier.errorL10n,
+                  "cannot_start_call_session_not_ready");
+                done();
+              });
+            });
           });
         });
       });
 
       describe("#accept", function() {
         it("should initiate the conversation", function() {
           router.accept();
 
@@ -262,20 +348,24 @@ describe("loop.conversation", function()
             sinon.assert.calledWithExactly(router._notifier.errorL10n,
               "cannot_start_call_session_not_ready");
         });
       });
 
       describe("#decline", function() {
         beforeEach(function() {
           sandbox.stub(window, "close");
+          router._websocket = {
+            decline: sandbox.spy()
+          };
         });
 
         it("should close the window", function() {
           router.decline();
+          sandbox.clock.tick(1);
 
           sinon.assert.calledOnce(window.close);
         });
 
         it("should stop alerting", function() {
           sandbox.stub(window.navigator.mozLoop, "stopAlerting");
           router.decline();
 
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -30,16 +30,17 @@
     chai.Assertion.includeStack = true;
     mocha.setup('bdd');
   </script>
 
   <!-- App scripts -->
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/router.js"></script>
   <script src="../../content/shared/js/views.js"></script>
+  <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/js/client.js"></script>
   <script src="../../content/js/desktopRouter.js"></script>
   <script src="../../content/js/conversation.js"></script>
   <script src="../../content/js/panel.js"></script>
 
   <!-- Test scripts -->
   <script src="client_test.js"></script>
   <script src="conversation_test.js"></script>
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -1,6 +1,6 @@
 [DEFAULT]
 support-files =
     head.js
 
-[browser_mozLoop_charPref.js]
+[browser_mozLoop_prefs.js]
 [browser_mozLoop_doNotDisturb.js]
rename from browser/components/loop/test/mochitest/browser_mozLoop_charPref.js
rename to browser/components/loop/test/mochitest/browser_mozLoop_prefs.js
--- a/browser/components/loop/test/mochitest/browser_mozLoop_charPref.js
+++ b/browser/components/loop/test/mochitest/browser_mozLoop_prefs.js
@@ -19,8 +19,22 @@ add_task(function* test_mozLoop_charPref
   gMozLoopAPI.setLoopCharPref("test", "foo");
   Assert.equal(Services.prefs.getCharPref("loop.test"), "foo",
                "should set loop pref value correctly");
 
   // Test getLoopCharPref
   Assert.equal(gMozLoopAPI.getLoopCharPref("test"), "foo",
                "should get loop pref value correctly");
 });
+
+add_task(function* test_mozLoop_boolPref() {
+  registerCleanupFunction(function () {
+    Services.prefs.clearUserPref("loop.testBool");
+  });
+
+  Assert.ok(gMozLoopAPI, "mozLoop should exist");
+
+  Services.prefs.setBoolPref("loop.testBool", true);
+
+  // Test getLoopCharPref
+  Assert.equal(gMozLoopAPI.getLoopBoolPref("testBool"), true,
+               "should get loop pref value correctly");
+});
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -31,20 +31,22 @@
     chai.Assertion.includeStack = true;
     mocha.setup('bdd');
   </script>
 
   <!-- App scripts -->
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/router.js"></script>
+  <script src="../../content/shared/js/websocket.js"></script>
 
   <!-- Test scripts -->
   <script src="models_test.js"></script>
   <script src="views_test.js"></script>
   <script src="router_test.js"></script>
+  <script src="websocket_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
   </script>
 </body>
 </html>
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -17,19 +17,20 @@ describe("loop.shared.models", function(
     sandbox.useFakeTimers();
     fakeXHR = sandbox.useFakeXMLHttpRequest();
     requests = [];
     // https://github.com/cjohansen/Sinon.JS/issues/393
     fakeXHR.xhr.onCreate = function(xhr) {
       requests.push(xhr);
     };
     fakeSessionData = {
-      sessionId:    "sessionId",
-      sessionToken: "sessionToken",
-      apiKey:       "apiKey"
+      sessionId:      "sessionId",
+      sessionToken:   "sessionToken",
+      apiKey:         "apiKey",
+      websocketToken: 123
     };
     fakeSession = _.extend({
       connect: function () {},
       endSession: sandbox.stub(),
       set: sandbox.stub(),
       disconnect: sandbox.spy(),
       unpublish: sandbox.spy()
     }, Backbone.Events);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/websocket_test.js
@@ -0,0 +1,224 @@
+/* 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, sinon, it, beforeEach, afterEach, describe */
+
+var expect = chai.expect;
+
+describe("loop.CallConnectionWebSocket", function() {
+  "use strict";
+
+  var sandbox,
+      dummySocket;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+    sandbox.useFakeTimers();
+
+    dummySocket = {
+      send: sinon.spy()
+    };
+    sandbox.stub(window, 'WebSocket').returns(dummySocket);
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#constructor", function() {
+    it("should require a url option", function() {
+      expect(function() {
+        return new loop.CallConnectionWebSocket();
+      }).to.Throw(/No url/);
+    });
+
+    it("should require a callId setting", function() {
+      expect(function() {
+        return new loop.CallConnectionWebSocket({url: "wss://fake/"});
+      }).to.Throw(/No callId/);
+    });
+
+    it("should require a websocketToken setting", function() {
+      expect(function() {
+        return new loop.CallConnectionWebSocket({
+          url: "http://fake/",
+          callId: "hello"
+        });
+      }).to.Throw(/No websocketToken/);
+    });
+  });
+
+  describe("constructed", function() {
+    var callWebSocket, fakeUrl, fakeCallId, fakeWebSocketToken;
+
+    beforeEach(function() {
+      fakeUrl = "wss://fake/";
+      fakeCallId = "callId";
+      fakeWebSocketToken = "7b";
+
+      callWebSocket = new loop.CallConnectionWebSocket({
+        url: fakeUrl,
+        callId: fakeCallId,
+        websocketToken: fakeWebSocketToken
+      });
+    });
+
+    describe("#promiseConnect", function() {
+      it("should create a new websocket connection", function() {
+        callWebSocket.promiseConnect();
+
+        sinon.assert.calledOnce(window.WebSocket);
+        sinon.assert.calledWithExactly(window.WebSocket, fakeUrl);
+      });
+
+      it("should reject the promise if connection is not completed in " +
+         "5 seconds", function(done) {
+        var promise = callWebSocket.promiseConnect();
+
+        sandbox.clock.tick(5101);
+
+        promise.then(function() {}, function(error) {
+          expect(error).to.be.equal("timeout");
+          done();
+        });
+      });
+
+      it("should reject the promise if the connection errors", function(done) {
+        var promise = callWebSocket.promiseConnect();
+
+        dummySocket.onerror("error");
+
+        promise.then(function() {}, function(error) {
+          expect(error).to.be.equal("error");
+          done();
+        });
+      });
+
+      it("should reject the promise if the connection closes", function(done) {
+        var promise = callWebSocket.promiseConnect();
+
+        dummySocket.onclose("close");
+
+        promise.then(function() {}, function(error) {
+          expect(error).to.be.equal("close");
+          done();
+        });
+      });
+
+      it("should send hello when the socket is opened", function() {
+        callWebSocket.promiseConnect();
+
+        dummySocket.onopen();
+
+        sinon.assert.calledOnce(dummySocket.send);
+        sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
+          messageType: "hello",
+          callId: fakeCallId,
+          auth: fakeWebSocketToken
+        }));
+      });
+
+      it("should resolve the promise when the 'hello' is received",
+        function(done) {
+          var promise = callWebSocket.promiseConnect();
+
+          dummySocket.onmessage({
+            data: '{"messageType":"hello", "state":"init"}'
+          });
+
+          promise.then(function() {
+            done();
+          });
+        });
+    });
+
+    describe("#decline", function() {
+      it("should send a terminate message to the server", function() {
+        callWebSocket.promiseConnect();
+
+        callWebSocket.decline();
+
+        sinon.assert.calledOnce(dummySocket.send);
+        sinon.assert.calledWithExactly(dummySocket.send, JSON.stringify({
+          messageType: "action",
+          event: "terminate",
+          reason: "reject"
+        }));
+      });
+    });
+
+    describe("Events", function() {
+      beforeEach(function() {
+        sandbox.stub(callWebSocket, "trigger");
+
+        callWebSocket.promiseConnect();
+      });
+
+      describe("Progress", function() {
+        it("should trigger a progress event on the callWebSocket", function() {
+          var eventData = {
+            messageType: "progress",
+            state: "terminate",
+            reason: "reject"
+          };
+
+          dummySocket.onmessage({
+            data: JSON.stringify(eventData)
+          });
+
+          sinon.assert.calledOnce(callWebSocket.trigger);
+          sinon.assert.calledWithExactly(callWebSocket.trigger, "progress", eventData);
+        });
+      });
+
+      describe("Error", function() {
+        // Handled in constructed -> #promiseConnect:
+        //   should reject the promise if the connection errors
+
+        it("should trigger an error if state is not completed", function() {
+          callWebSocket._clearConnectionFlags();
+
+          dummySocket.onerror("Error");
+
+          sinon.assert.calledOnce(callWebSocket.trigger);
+          sinon.assert.calledWithExactly(callWebSocket.trigger,
+                                         "error", "Error");
+        });
+
+        it("should not trigger an error if state is completed", function() {
+          callWebSocket._clearConnectionFlags();
+          callWebSocket._lastServerState = "connected";
+
+          dummySocket.onerror("Error");
+
+          sinon.assert.notCalled(callWebSocket.trigger);
+        });
+      });
+
+      describe("Close", function() {
+        // Handled in constructed -> #promiseConnect:
+        //   should reject the promise if the connection closes
+
+        it("should trigger a close event if state is not completed", function() {
+          callWebSocket._clearConnectionFlags();
+
+          dummySocket.onclose("Error");
+
+          sinon.assert.calledOnce(callWebSocket.trigger);
+          sinon.assert.calledWithExactly(callWebSocket.trigger,
+                                         "closed", "Error");
+        });
+
+        it("should not trigger an error if state is completed", function() {
+          callWebSocket._clearConnectionFlags();
+          callWebSocket._lastServerState = "terminated";
+
+          dummySocket.onclose("Error");
+
+          sinon.assert.notCalled(callWebSocket.trigger);
+        });
+      });
+    });
+  });
+});
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -29,16 +29,17 @@
   <script>
     chai.Assertion.includeStack = true;
     mocha.setup('bdd');
   </script>
   <!-- App scripts -->
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/router.js"></script>
+  <script src="../../content/shared/js/websocket.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
@@ -88,40 +88,175 @@ describe("loop.webapp", function() {
         notifier: notifier
       });
       sandbox.stub(router, "loadView");
       sandbox.stub(router, "loadReactComponent");
       sandbox.stub(router, "navigate");
     });
 
     describe("#startCall", function() {
+      beforeEach(function() {
+        sandbox.stub(router, "_setupWebSocketAndCallView");
+      });
+
       it("should navigate back home if session token is missing", function() {
         router.startCall();
 
         sinon.assert.calledOnce(router.navigate);
         sinon.assert.calledWithMatch(router.navigate, "home");
       });
 
       it("should notify the user if session token is missing", function() {
         router.startCall();
 
         sinon.assert.calledOnce(notifier.errorL10n);
         sinon.assert.calledWithExactly(notifier.errorL10n,
                                        "missing_conversation_info");
       });
 
-      it("should navigate to call/ongoing/:token if session token is available",
-        function() {
-          conversation.set("loopToken", "fake");
+      it("should setup the websocket if session token is available", function() {
+        conversation.set("loopToken", "fake");
+
+        router.startCall();
+
+        sinon.assert.calledOnce(router._setupWebSocketAndCallView);
+        sinon.assert.calledWithExactly(router._setupWebSocketAndCallView, "fake");
+      });
+    });
+
+    describe("#_setupWebSocketAndCallView", function() {
+      beforeEach(function() {
+        conversation.setOutgoingSessionData({
+          sessionId:      "sessionId",
+          sessionToken:   "sessionToken",
+          apiKey:         "apiKey",
+          callId:         "Hello",
+          progressURL:    "http://progress.example.com",
+          websocketToken: 123
+        });
+      });
+
+      describe("Websocket connection successful", function() {
+        var promise;
+
+        beforeEach(function() {
+          sandbox.stub(loop, "CallConnectionWebSocket").returns({
+            promiseConnect: function() {
+              promise = new Promise(function(resolve, reject) {
+                resolve();
+              });
+              return promise;
+            },
+
+            on: sandbox.spy()
+          });
+        });
+
+        it("should create a CallConnectionWebSocket", function(done) {
+          router._setupWebSocketAndCallView("fake");
+
+          promise.then(function () {
+            sinon.assert.calledOnce(loop.CallConnectionWebSocket);
+            sinon.assert.calledWithExactly(loop.CallConnectionWebSocket, {
+              callId: "Hello",
+              url: "http://progress.example.com",
+              // The websocket token is converted to a hex string.
+              websocketToken: "7b"
+            });
+            done();
+          });
+        });
+
+        it("should navigate to call/ongoing/:token", function(done) {
+          router._setupWebSocketAndCallView("fake");
+
+          promise.then(function () {
+            sinon.assert.calledOnce(router.navigate);
+            sinon.assert.calledWithMatch(router.navigate, "call/ongoing/fake");
+            done();
+          });
+        });
+      });
+
+      describe("Websocket connection failed", function() {
+        var promise;
 
-          router.startCall();
+        beforeEach(function() {
+          sandbox.stub(loop, "CallConnectionWebSocket").returns({
+            promiseConnect: function() {
+              promise = new Promise(function(resolve, reject) {
+                reject();
+              });
+              return promise;
+            },
+
+            on: sandbox.spy()
+          });
+        });
+
+        it("should display an error", function() {
+          router._setupWebSocketAndCallView();
+
+          promise.then(function() {
+          }, function () {
+            sinon.assert.calledOnce(router._notifier.errorL10n);
+            sinon.assert.calledWithExactly(router._notifier.errorL10n,
+              "cannot_start_call_session_not_ready");
+            done();
+          });
+        });
+      });
+
+      describe("Websocket Events", function() {
+        beforeEach(function() {
+          conversation.setOutgoingSessionData({
+            sessionId:      "sessionId",
+            sessionToken:   "sessionToken",
+            apiKey:         "apiKey",
+            callId:         "Hello",
+            progressURL:    "http://progress.example.com",
+            websocketToken: 123
+          });
 
-          sinon.assert.calledOnce(router.navigate);
-          sinon.assert.calledWithMatch(router.navigate, "call/ongoing/fake");
+          sandbox.stub(loop.CallConnectionWebSocket.prototype,
+                       "promiseConnect").returns({
+            then: sandbox.spy()
+          });
+
+          router._setupWebSocketAndCallView();
         });
+
+        describe("Progress", function() {
+          describe("state: terminate, reason: reject", function() {
+            beforeEach(function() {
+              sandbox.stub(router, "endCall");
+            });
+
+            it("should end the call", function() {
+              router._websocket.trigger("progress", {
+                state: "terminated",
+                reason: "reject"
+              });
+
+              sinon.assert.calledOnce(router.endCall);
+            });
+
+            it("should display an error message", function() {
+              router._websocket.trigger("progress", {
+                state: "terminated",
+                reason: "reject"
+              });
+
+              sinon.assert.calledOnce(router._notifier.errorL10n);
+              sinon.assert.calledWithExactly(router._notifier.errorL10n,
+                "call_timeout_notification_text");
+            });
+          });
+        });
+      });
     });
 
     describe("#endCall", function() {
       it("should navigate to home if session token is unset", function() {
         router.endCall();
 
         sinon.assert.calledOnce(router.navigate);
         sinon.assert.calledWithMatch(router.navigate, "home");
@@ -218,30 +353,31 @@ describe("loop.webapp", function() {
       });
     });
 
     describe("Events", function() {
       var fakeSessionData;
 
       beforeEach(function() {
         fakeSessionData = {
-          sessionId:    "sessionId",
-          sessionToken: "sessionToken",
-          apiKey:       "apiKey"
+          sessionId:      "sessionId",
+          sessionToken:   "sessionToken",
+          apiKey:         "apiKey",
+          websocketToken: 123
         };
         conversation.set("loopToken", "fakeToken");
+        sandbox.stub(router, "startCall");
       });
 
-      it("should navigate to call/ongoing/:token once call session is ready",
+      it("should attempt to start the call once call session is ready",
         function() {
           router.setupOutgoingCall();
           conversation.outgoing(fakeSessionData);
 
-          sinon.assert.calledOnce(router.navigate);
-          sinon.assert.calledWith(router.navigate, "call/ongoing/fakeToken");
+          sinon.assert.calledOnce(router.startCall);
         });
 
       it("should navigate to call/{token} when conversation ended", function() {
         conversation.trigger("session:ended");
 
         sinon.assert.calledOnce(router.navigate);
         sinon.assert.calledWithMatch(router.navigate, "call/fakeToken");
       });
rename from browser/components/loop/test/xpcshell/test_loopservice_get_loop_char_pref.js
rename to browser/components/loop/test/xpcshell/test_loopservice_loop_prefs.js
--- a/browser/components/loop/test/xpcshell/test_loopservice_get_loop_char_pref.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_loop_prefs.js
@@ -1,47 +1,106 @@
 /* Any copyright is dedicated to the Public Domain.
  http://creativecommons.org/publicdomain/zero/1.0/ */
 /*global XPCOMUtils, Services, Assert */
 
-var fakePrefName = "color";
+var fakeCharPrefName = "color";
+var fakeBoolPrefName = "boolean";
 var fakePrefValue = "green";
 
 function test_getLoopCharPref()
 {
-  Services.prefs.setCharPref("loop." + fakePrefName, fakePrefValue);
+  Services.prefs.setCharPref("loop." + fakeCharPrefName, fakePrefValue);
 
-  var returnedPref = MozLoopService.getLoopCharPref(fakePrefName);
+  var returnedPref = MozLoopService.getLoopCharPref(fakeCharPrefName);
 
   Assert.equal(returnedPref, fakePrefValue,
     "Should return a char pref under the loop. branch");
-  Services.prefs.clearUserPref("loop." + fakePrefName);
+  Services.prefs.clearUserPref("loop." + fakeCharPrefName);
 }
 
 function test_getLoopCharPref_not_found()
 {
-  var returnedPref = MozLoopService.getLoopCharPref(fakePrefName);
+  var returnedPref = MozLoopService.getLoopCharPref(fakeCharPrefName);
 
   Assert.equal(returnedPref, null,
     "Should return null if a preference is not found");
 }
 
 function test_getLoopCharPref_non_coercible_type()
 {
-  Services.prefs.setBoolPref("loop." + fakePrefName, false );
+  Services.prefs.setBoolPref("loop." + fakeCharPrefName, false);
 
-  var returnedPref = MozLoopService.getLoopCharPref(fakePrefName);
+  var returnedPref = MozLoopService.getLoopCharPref(fakeCharPrefName);
 
   Assert.equal(returnedPref, null,
     "Should return null if the preference exists & is of a non-coercible type");
 }
 
+function test_setLoopCharPref()
+{
+  Services.prefs.setCharPref("loop." + fakeCharPrefName, "red");
+  MozLoopService.setLoopCharPref(fakeCharPrefName, fakePrefValue);
+
+  var returnedPref = Services.prefs.getCharPref("loop." + fakeCharPrefName);
+
+  Assert.equal(returnedPref, fakePrefValue,
+    "Should set a char pref under the loop. branch");
+  Services.prefs.clearUserPref("loop." + fakeCharPrefName);
+}
+
+function test_setLoopCharPref_new()
+{
+  Services.prefs.clearUserPref("loop." + fakeCharPrefName);
+  MozLoopService.setLoopCharPref(fakeCharPrefName, fakePrefValue);
+
+  var returnedPref = Services.prefs.getCharPref("loop." + fakeCharPrefName);
+
+  Assert.equal(returnedPref, fakePrefValue,
+               "Should set a new char pref under the loop. branch");
+  Services.prefs.clearUserPref("loop." + fakeCharPrefName);
+}
+
+function test_setLoopCharPref_non_coercible_type()
+{
+  MozLoopService.setLoopCharPref(fakeCharPrefName, true);
+
+  ok(true, "Setting non-coercible type should not fail");
+}
+
+
+function test_getLoopBoolPref()
+{
+  Services.prefs.setBoolPref("loop." + fakeBoolPrefName, true);
+
+  var returnedPref = MozLoopService.getLoopBoolPref(fakeBoolPrefName);
+
+  Assert.equal(returnedPref, true,
+    "Should return a bool pref under the loop. branch");
+  Services.prefs.clearUserPref("loop." + fakeBoolPrefName);
+}
+
+function test_getLoopBoolPref_not_found()
+{
+  var returnedPref = MozLoopService.getLoopBoolPref(fakeBoolPrefName);
+
+  Assert.equal(returnedPref, null,
+    "Should return null if a preference is not found");
+}
+
 
 function run_test()
 {
   test_getLoopCharPref();
   test_getLoopCharPref_not_found();
   test_getLoopCharPref_non_coercible_type();
+  test_setLoopCharPref();
+  test_setLoopCharPref_new();
+  test_setLoopCharPref_non_coercible_type();
+
+  test_getLoopBoolPref();
+  test_getLoopBoolPref_not_found();
 
   do_register_cleanup(function() {
-    Services.prefs.clearUserPref("loop." + fakePrefName);
+    Services.prefs.clearUserPref("loop." + fakeCharPrefName);
+    Services.prefs.clearUserPref("loop." + fakeBoolPrefName);
   });
 }
deleted file mode 100644
--- a/browser/components/loop/test/xpcshell/test_loopservice_set_loop_char_pref.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
- http://creativecommons.org/publicdomain/zero/1.0/ */
-/*global XPCOMUtils, Services, Assert */
-
-var fakePrefName = "color";
-var fakePrefValue = "green";
-
-function test_setLoopCharPref()
-{
-  Services.prefs.setCharPref("loop." + fakePrefName, "red");
-  MozLoopService.setLoopCharPref(fakePrefName, fakePrefValue);
-
-  var returnedPref = Services.prefs.getCharPref("loop." + fakePrefName);
-
-  Assert.equal(returnedPref, fakePrefValue,
-    "Should set a char pref under the loop. branch");
-  Services.prefs.clearUserPref("loop." + fakePrefName);
-}
-
-function test_setLoopCharPref_new()
-{
-  Services.prefs.clearUserPref("loop." + fakePrefName);
-  MozLoopService.setLoopCharPref(fakePrefName, fakePrefValue);
-
-  var returnedPref = Services.prefs.getCharPref("loop." + fakePrefName);
-
-  Assert.equal(returnedPref, fakePrefValue,
-               "Should set a new char pref under the loop. branch");
-  Services.prefs.clearUserPref("loop." + fakePrefName);
-}
-
-function test_setLoopCharPref_non_coercible_type()
-{
-  MozLoopService.setLoopCharPref(fakePrefName, true);
-
-  ok(true, "Setting non-coercible type should not fail");
-}
-
-
-function run_test()
-{
-  test_setLoopCharPref();
-  test_setLoopCharPref_new();
-  test_setLoopCharPref_non_coercible_type();
-
-  do_register_cleanup(function() {
-    Services.prefs.clearUserPref("loop." + fakePrefName);
-  });
-}
--- a/browser/components/loop/test/xpcshell/xpcshell.ini
+++ b/browser/components/loop/test/xpcshell/xpcshell.ini
@@ -1,17 +1,16 @@
 [DEFAULT]
 head = head.js
 tail =
 firefox-appdir = browser
 
 [test_looppush_initialize.js]
 [test_loopservice_dnd.js]
 [test_loopservice_expiry.js]
-[test_loopservice_get_loop_char_pref.js]
-[test_loopservice_set_loop_char_pref.js]
+[test_loopservice_loop_prefs.js]
 [test_loopservice_initialize.js]
 [test_loopservice_locales.js]
 [test_loopservice_registration.js]
 [test_loopservice_token_invalid.js]
 [test_loopservice_token_save.js]
 [test_loopservice_token_send.js]
 [test_loopservice_token_validation.js]