Bug 1074663: Register with PushServer for updates to rooms, r=MattN a=loop-only
authorPaul Kerr <paulrkerr@gmail.com>
Thu, 23 Oct 2014 09:50:12 -0700
changeset 233768 358002151c878ed41602770f68faa4a43ae050c6
parent 233767 3dc36b88d62338996cc0b24b40f7d62ef52efd54
child 233769 dbdc0ec506f279db19f7005174cd149a5d0b1c0a
push id4187
push userbhearsum@mozilla.com
push dateFri, 28 Nov 2014 15:29:12 +0000
treeherdermozilla-beta@f23cc6a30c11 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN, loop-only
bugs1074663
milestone35.0a2
Bug 1074663: Register with PushServer for updates to rooms, r=MattN a=loop-only
browser/components/loop/LoopCalls.jsm
browser/components/loop/LoopRooms.jsm
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/MozLoopPushHandler.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/moz.build
browser/components/loop/test/mochitest/browser_fxa_login.js
browser/components/loop/test/mochitest/head.js
browser/components/loop/test/mochitest/loop_fxa.sjs
browser/components/loop/test/xpcshell/head.js
browser/components/loop/test/xpcshell/test_looppush_initialize.js
browser/components/loop/test/xpcshell/test_loopservice_busy.js
browser/components/loop/test/xpcshell/test_loopservice_directcall.js
browser/components/loop/test/xpcshell/test_loopservice_dnd.js
browser/components/loop/test/xpcshell/test_loopservice_notification.js
browser/components/loop/test/xpcshell/test_loopservice_registration.js
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/LoopCalls.jsm
@@ -0,0 +1,383 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+this.EXPORTED_SYMBOLS = ["LoopCalls"];
+
+XPCOMUtils.defineLazyModuleGetter(this, "MozLoopService",
+                                  "resource:///modules/loop/MozLoopService.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "LOOP_SESSION_TYPE",
+                                  "resource:///modules/loop/MozLoopService.jsm");
+
+ /**
+ * Attempts to open a websocket.
+ *
+ * A new websocket interface is used each time. If an onStop callback
+ * was received, calling asyncOpen() on the same interface will
+ * trigger a "alreay open socket" exception even though the channel
+ * is logically closed.
+ */
+function CallProgressSocket(progressUrl, callId, token) {
+  if (!progressUrl || !callId || !token) {
+    throw new Error("missing required arguments");
+  }
+
+  this._progressUrl = progressUrl;
+  this._callId = callId;
+  this._token = token;
+}
+
+CallProgressSocket.prototype = {
+  /**
+   * Open websocket and run hello exchange.
+   * Sends a hello message to the server.
+   *
+   * @param {function} Callback used after a successful handshake
+   *                   over the progressUrl.
+   * @param {function} Callback used if an error is encountered
+   */
+  connect: function(onSuccess, onError) {
+    this._onSuccess = onSuccess;
+    this._onError = onError ||
+      (reason => {MozLoopService.logwarn("LoopCalls::callProgessSocket - ", reason);});
+
+    if (!onSuccess) {
+      this._onError("missing onSuccess argument");
+      return;
+    }
+
+    if (Services.io.offline) {
+      this._onError("IO offline");
+      return;
+    }
+
+    let uri = Services.io.newURI(this._progressUrl, null, null);
+
+    // Allow _websocket to be set for testing.
+    this._websocket = this._websocket ||
+      Cc["@mozilla.org/network/protocol;1?name=" + uri.scheme]
+        .createInstance(Ci.nsIWebSocketChannel);
+
+    this._websocket.asyncOpen(uri, this._progressUrl, this, null);
+  },
+
+  /**
+   * Listener method, handles the start of the websocket stream.
+   * Sends a hello message to the server.
+   *
+   * @param {nsISupports} aContext Not used
+   */
+  onStart: function() {
+    let helloMsg = {
+      messageType: "hello",
+      callId: this._callId,
+      auth: this._token,
+    };
+    try { // in case websocket has closed before this handler is run
+      this._websocket.sendMsg(JSON.stringify(helloMsg));
+    }
+    catch (error) {
+      this._onError(error);
+    }
+  },
+
+  /**
+   * Listener method, called when the websocket is closed.
+   *
+   * @param {nsISupports} aContext Not used
+   * @param {nsresult} aStatusCode Reason for stopping (NS_OK = successful)
+   */
+  onStop: function(aContext, aStatusCode) {
+    if (!this._handshakeComplete) {
+      this._onError("[" + aStatusCode + "]");
+    }
+  },
+
+  /**
+   * Listener method, called when the websocket is closed by the server.
+   * If there are errors, onStop may be called without ever calling this
+   * method.
+   *
+   * @param {nsISupports} aContext Not used
+   * @param {integer} aCode the websocket closing handshake close code
+   * @param {String} aReason the websocket closing handshake close reason
+   */
+  onServerClose: function(aContext, aCode, aReason) {
+    if (!this._handshakeComplete) {
+      this._onError("[" + aCode + "]" + aReason);
+    }
+  },
+
+  /**
+   * Listener method, called when the websocket receives a message.
+   *
+   * @param {nsISupports} aContext Not used
+   * @param {String} aMsg The message data
+   */
+  onMessageAvailable: function(aContext, aMsg) {
+    let msg = {};
+    try {
+      msg = JSON.parse(aMsg);
+    }
+    catch (error) {
+      MozLoopService.logerror("LoopCalls: error parsing progress message - ", error);
+      return;
+    }
+
+    if (msg.messageType && msg.messageType === 'hello') {
+      this._handshakeComplete = true;
+      this._onSuccess();
+    }
+  },
+
+
+  /**
+   * Create a JSON message payload and send on websocket.
+   *
+   * @param {Object} aMsg Message to send.
+   */
+  _send: function(aMsg) {
+    if (!this._handshakeComplete) {
+      MozLoopService.logwarn("LoopCalls::_send error - handshake not complete");
+      return;
+    }
+
+    try {
+      this._websocket.sendMsg(JSON.stringify(aMsg));
+    }
+    catch (error) {
+      this._onError(error);
+    }
+  },
+
+  /**
+   * Notifies the server that the user has declined the call
+   * with a reason of busy.
+   */
+  sendBusy: function() {
+    this._send({
+      messageType: "action",
+      event: "terminate",
+      reason: "busy"
+    });
+  },
+};
+
+/**
+ * Internal helper methods and state
+ *
+ * The registration is a two-part process. First we need to connect to
+ * and register with the push server. Then we need to take the result of that
+ * and register with the Loop server.
+ */
+let LoopCallsInternal = {
+  callsData: {inUse: false},
+  _mocks: {webSocket: undefined},
+
+  /**
+   * Callback from MozLoopPushHandler - A push notification has been received from
+   * the server.
+   *
+   * @param {String} version The version information from the server.
+   */
+  onNotification: function(version, channelID) {
+    if (MozLoopService.doNotDisturb) {
+      return;
+    }
+
+    // We set this here as it is assumed that once the user receives an incoming
+    // call, they'll have had enough time to see the terms of service. See
+    // bug 1046039 for background.
+    Services.prefs.setCharPref("loop.seenToS", "seen");
+
+    // Request the information on the new call(s) associated with this version.
+    // The registered FxA session is checked first, then the anonymous session.
+    // Make the call to get the GUEST session regardless of whether the FXA
+    // request fails.
+
+    if (channelID == LoopCalls.channelIDs.FxA && MozLoopService.userProfile) {
+      this._getCalls(LOOP_SESSION_TYPE.FXA, version);
+    } else {
+      this._getCalls(LOOP_SESSION_TYPE.GUEST, version);
+    }
+  },
+
+  /**
+   * Make a hawkRequest to GET/calls?=version for this session type.
+   *
+   * @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
+   *        for the GET operation.
+   * @param {Object} version - LoopPushService notification version
+   *
+   * @returns {Promise}
+   *
+   */
+
+  _getCalls: function(sessionType, version) {
+    return MozLoopService.hawkRequest(sessionType, "/calls?version=" + version, "GET").then(
+      response => {this._processCalls(response, sessionType);}
+    );
+  },
+
+  /**
+   * Process the calls array returned from a GET/calls?version request.
+   * Only one active call is permitted at this time.
+   *
+   * @param {Object} response - response payload from GET
+   *
+   * @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
+   *        for the GET operation.
+   *
+   */
+
+  _processCalls: function(response, sessionType) {
+    try {
+      let respData = JSON.parse(response.body);
+      if (respData.calls && Array.isArray(respData.calls)) {
+        respData.calls.forEach((callData) => {
+          if (!this.callsData.inUse) {
+            callData.sessionType = sessionType;
+            this._startCall(callData, "incoming");
+          } else {
+            this._returnBusy(callData);
+          }
+        });
+      } else {
+        MozLoopService.logwarn("Error: missing calls[] in response");
+      }
+    } catch (err) {
+      MozLoopService.logwarn("Error parsing calls info", err);
+    }
+  },
+
+  /**
+   * Starts a call, saves the call data, and opens a chat window.
+   *
+   * @param {Object} callData The data associated with the call including an id.
+   * @param {Boolean} conversationType Whether or not the call is "incoming"
+   *                                   or "outgoing"
+   */
+  _startCall: function(callData, conversationType) {
+    this.callsData.inUse = true;
+    this.callsData.data = callData;
+    MozLoopService.openChatWindow(
+      null,
+      // No title, let the page set that, to avoid flickering.
+      "",
+      "about:loopconversation#" + conversationType + "/" + callData.callId);
+  },
+
+  /**
+   * Starts a direct call to the contact addresses.
+   *
+   * @param {Object} contact The contact to call
+   * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
+   * @return true if the call is opened, false if it is not opened (i.e. busy)
+   */
+  startDirectCall: function(contact, callType) {
+    if (this.callsData.inUse)
+      return false;
+
+    var callData = {
+      contact: contact,
+      callType: callType,
+      callId: Math.floor((Math.random() * 10))
+    };
+
+    this._startCall(callData, "outgoing");
+    return true;
+  },
+
+   /**
+   * Open call progress websocket and terminate with a reason of busy
+   * the server.
+   *
+   * @param {callData} Must contain the progressURL, callId and websocketToken
+   *                   returned by the LoopService.
+   */
+  _returnBusy: function(callData) {
+    let callProgress = new CallProgressSocket(
+      callData.progressURL,
+      callData.callId,
+      callData.websocketToken);
+    callProgress._websocket = this._mocks.webSocket;
+    // This instance of CallProgressSocket should stay alive until the underlying
+    // websocket is closed since it is passed to the websocket as the nsIWebSocketListener.
+    callProgress.connect(() => {callProgress.sendBusy();});
+  }
+};
+Object.freeze(LoopCallsInternal);
+
+/**
+ * Public API
+ */
+this.LoopCalls = {
+  // Channel ids that will be registered with the PushServer for notifications
+  channelIDs: {
+    FxA: "25389583-921f-4169-a426-a4673658944b",
+    Guest: "801f754b-686b-43ec-bd83-1419bbf58388",
+  },
+
+  /**
+   * Callback from MozLoopPushHandler - A push notification has been received from
+   * the server.
+   *
+   * @param {String} version The version information from the server.
+   */
+  onNotification: function(version, channelID) {
+    LoopCallsInternal.onNotification(version, channelID);
+  },
+
+  /**
+   * Returns the callData for a specific loopCallId
+   *
+   * The data was retrieved from the LoopServer via a GET/calls/<version> request
+   * triggered by an incoming message from the LoopPushServer.
+   *
+   * @param {int} loopCallId
+   * @return {callData} The callData or undefined if error.
+   */
+  getCallData: function(loopCallId) {
+    if (LoopCallsInternal.callsData.data &&
+        LoopCallsInternal.callsData.data.callId == loopCallId) {
+      return LoopCallsInternal.callsData.data;
+    } else {
+      return undefined;
+    }
+  },
+
+  /**
+   * Releases the callData for a specific loopCallId
+   *
+   * The result of this call will be a free call session slot.
+   *
+   * @param {int} loopCallId
+   */
+  releaseCallData: function(loopCallId) {
+    if (LoopCallsInternal.callsData.data &&
+        LoopCallsInternal.callsData.data.callId == loopCallId) {
+      LoopCallsInternal.callsData.data = undefined;
+      LoopCallsInternal.callsData.inUse = false;
+    }
+  },
+
+    /**
+     * Starts a direct call to the contact addresses.
+     *
+     * @param {Object} contact The contact to call
+     * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
+     * @return true if the call is opened, false if it is not opened (i.e. busy)
+     */
+  startDirectCall: function(contact, callType) {
+    LoopCallsInternal.startDirectCall(contact, callType);
+  }
+};
+Object.freeze(LoopCalls);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/LoopRooms.jsm
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+this.EXPORTED_SYMBOLS = ["LoopRooms"];
+
+/**
+ * Public Loop Rooms API
+ */
+this.LoopRooms = Object.freeze({
+// Channel ids that will be registered with the PushServer for notifications
+  channelIDs: {
+    FxA: "6add272a-d316-477c-8335-f00f73dfde71",
+    Guest: "19d3f799-a8f3-4328-9822-b7cd02765832",
+  },
+
+  onNotification: function(version, channelID) {
+    return;
+  },
+});
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -4,16 +4,17 @@
 
 "use strict";
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://services-common/utils.js");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource:///modules/loop/LoopCalls.jsm");
 Cu.import("resource:///modules/loop/MozLoopService.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "LoopContacts",
                                         "resource:///modules/loop/LoopContacts.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
                                         "resource:///modules/loop/LoopStorage.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "hookWindowCloseForPanelClose",
                                         "resource://gre/modules/MozSocialAPI.jsm");
@@ -42,17 +43,17 @@ this.EXPORTED_SYMBOLS = ["injectLoopAPI"
  */
 const cloneErrorObject = function(error, targetWindow) {
   let obj = new targetWindow.Error();
   for (let prop of Object.getOwnPropertyNames(error)) {
     let value = error[prop];
     if (typeof value != "string" && typeof value != "number") {
       value = String(value);
     }
-
+    
     Object.defineProperty(Cu.waiveXrays(obj), prop, {
       configurable: false,
       enumerable: true,
       value: value,
       writable: false
     });
   }
   return obj;
@@ -199,32 +200,32 @@ function injectLoopAPI(targetWindow) {
      *
      * @param {int} loopCallId
      * @returns {callData} The callData or undefined if error.
      */
     getCallData: {
       enumerable: true,
       writable: true,
       value: function(loopCallId) {
-        return Cu.cloneInto(MozLoopService.getCallData(loopCallId), targetWindow);
+        return Cu.cloneInto(LoopCalls.getCallData(loopCallId), targetWindow);
       }
     },
 
     /**
      * Releases the callData for a specific loopCallId
      *
      * The result of this call will be a free call session slot.
      *
      * @param {int} loopCallId
      */
     releaseCallData: {
       enumerable: true,
       writable: true,
       value: function(loopCallId) {
-        MozLoopService.releaseCallData(loopCallId);
+        LoopCalls.releaseCallData(loopCallId);
       }
     },
 
     /**
      * Returns the contacts API.
      *
      * @returns {Object} The contacts API object
      */
@@ -648,17 +649,17 @@ function injectLoopAPI(targetWindow) {
      * @param {Object} contact The contact to call
      * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
      * @return true if the call is opened, false if it is not opened (i.e. busy)
      */
     startDirectCall: {
       enumerable: true,
       writable: true,
       value: function(contact, callType) {
-        MozLoopService.startDirectCall(contact, callType);
+        LoopCalls.startDirectCall(contact, callType);
       }
     },
   };
 
   function onStatusChanged(aSubject, aTopic, aData) {
     let event = new targetWindow.CustomEvent("LoopStatusChanged");
     targetWindow.dispatchEvent(event);
   };
--- a/browser/components/loop/MozLoopPushHandler.jsm
+++ b/browser/components/loop/MozLoopPushHandler.jsm
@@ -4,37 +4,39 @@
 
 "use strict";
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
 
 this.EXPORTED_SYMBOLS = ["MozLoopPushHandler"];
 
 XPCOMUtils.defineLazyModuleGetter(this, "console",
                                   "resource://gre/modules/devtools/Console.jsm");
 
 /**
  * We don't have push notifications on desktop currently, so this is a
  * workaround to get them going for us.
  */
 let MozLoopPushHandler = {
   // This is the uri of the push server.
   pushServerUri: undefined,
-  // This is the channel id we're using for notifications
-  channelID: "8b1081ce-9b35-42b5-b8f5-3ff8cb813a50",
+  // Records containing the registration and notification callbacks indexed by channelID.
+  // Each channel will be registered with the PushServer.
+  channels: {},
   // This is the UserAgent UUID assigned by the PushServer
   uaID: undefined,
-  // Stores the push url if we're registered and we have one.
-  pushUrl: undefined,
-  // Set to true once the channelID has been registered with the PushServer.
-  registered: false,
+  // Each successfully registered channelID is used as a key to hold its pushEndpoint URL.
+  registeredChannels: {},
+
+  _channelsToRegister: {},
 
   _minRetryDelay_ms: (() => {
     try {
       return Services.prefs.getIntPref("loop.retry_delay.start")
     }
     catch (e) {
       return 60000 // 1 minute
     }
@@ -45,60 +47,106 @@ let MozLoopPushHandler = {
       return Services.prefs.getIntPref("loop.retry_delay.limit")
     }
     catch (e) {
       return 300000 // 5 minutes
     }
   })(),
 
    /**
-    * Starts a connection to the push socket server. On
+    * Inializes the PushHandler and opens a socket with the PushServer.
+    * It will automatically say hello and register any channels
+    * that are found in the work queue at that point.
+    *
+    * @param {Object} options Set of configuration options. Currently,
+    *                 the only option is mocketWebSocket which will be
+    *                 used for testing.
+    */
+  initialize: function(options = {}) {
+    if (Services.io.offline) {
+      console.warn("MozLoopPushHandler - IO offline");
+      return false;
+    }
+
+    if (this._initDone) {
+      return true;
+    }
+
+    this._initDone = true;
+
+    if ("mockWebSocket" in options) {
+      this._mockWebSocket = options.mockWebSocket;
+    }
+
+    this._openSocket();
+    return true;
+  },
+
+   /**
+    * Start registration of a PushServer notification channel.
     * connection, it will automatically say hello and register the channel
     * id with the server.
     *
-    * Register callback parameters:
+    * onRegistered callback parameters:
     * - {String|null} err: Encountered error, if any
     * - {String} url: The push url obtained from the server
     *
-    * Callback parameters:
+    * onNotification parameters:
     * - {String} version The version string received from the push server for
     *                    the notification.
+    * - {String} channelID The channelID on which the notification was sent.
     *
-    * @param {Function} registerCallback Callback to be called once we are
+    * @param {String} channelID Channel ID to use in registration.
+    *
+    * @param {Function} onRegistered Callback to be called once we are
     *                     registered.
-    * @param {Function} notificationCallback Callback to be called when a
+    * @param {Function} onNotification Callback to be called when a
     *                     push notification is received (may be called multiple
     *                     times).
-    * @param {Object} mockPushHandler Optional, test-only object, to allow
-    *                                 the websocket to be mocked for tests.
     */
-  initialize: function(registerCallback, notificationCallback, mockPushHandler) {
-    if (mockPushHandler) {
-      this._mockPushHandler = mockPushHandler;
+  register: function(channelID, onRegistered, onNotification) {
+    if (!channelID || !onRegistered || !onNotification) {
+      throw new Error("missing required parameter(s):"
+                      + (channelID ? "" : " channelID")
+                      + (onRegistered ? "" : " onRegistered")
+                      + (onNotification ? "" : " onNotification"));
     }
+    // Only register new channels
+    if (!(channelID in this.channels)) {
+      this.channels[channelID] = {
+        onRegistered: onRegistered,
+        onNotification: onNotification
+      };
 
-    this._registerCallback = registerCallback;
-    this._notificationCallback = notificationCallback;
-    this._openSocket();
+      // If registration is in progress, simply add to the work list.
+      // Else, re-start a registration cycle.
+      if (this._registrationID) {
+        this._channelsToRegister.push(channelID);
+      } else {
+        this._registerChannels();
+      }
+    }
   },
 
   /**
    * Listener method, handles the start of the websocket stream.
    * Sends a hello message to the server.
    *
    * @param {nsISupports} aContext Not used
    */
   onStart: function() {
     this._retryEnd();
     // If a uaID has already been assigned, assume this is a re-connect
     // and send the uaID in order to re-synch with the
     // PushServer. If a registration has been completed, send the channelID.
-    let helloMsg = { messageType: "hello",
-		     uaid: this.uaID,
-		     channelIDs: this.registered ? [this.channelID] :[] };
+    let helloMsg = {
+          messageType: "hello",
+          uaid: this.uaID || "",
+          channelIDs: Object.keys(this.registeredChannels)};
+
     this._retryOperation(() => this.onStart(), this._maxRetryDelay_ms);
     try { // in case websocket has closed before this handler is run
       this._websocket.sendMsg(JSON.stringify(helloMsg));
     }
     catch (e) {console.warn("MozLoopPushHandler::onStart websocket.sendMsg() failure");}
   },
 
   /**
@@ -133,86 +181,96 @@ let MozLoopPushHandler = {
    * @param {String} aMsg The message data
    */
   onMessageAvailable: function(aContext, aMsg) {
     let msg = JSON.parse(aMsg);
 
     switch(msg.messageType) {
       case "hello":
         this._retryEnd();
-	if (this.uaID !== msg.uaid) {
-	  this.uaID = msg.uaid;
-          this._registerChannel();
-	}
+        this._isConnected = true;
+        if (this.uaID !== msg.uaid) {
+          this.uaID = msg.uaid;
+          this.registeredChannels = {};
+          this._registerChannels();
+        }
         break;
 
       case "register":
         this._onRegister(msg);
         break;
 
       case "notification":
         msg.updates.forEach((update) => {
-          if (update.channelID === this.channelID) {
-            this._notificationCallback(update.version);
+          if (update.channelID in this.registeredChannels) {
+            this.channels[update.channelID].onNotification(update.version, update.channelID);
           }
         });
         break;
     }
   },
 
   /**
    * Handles the PushServer registration response.
    *
-   * @param {} msg PushServer to UserAgent registration response (parsed from JSON).
+   * @param {Object} msg PushServer to UserAgent registration response (parsed from JSON).
    */
   _onRegister: function(msg) {
+    let registerNext = () => {
+      this._registrationID = this._channelsToRegister.shift();
+      this._sendRegistration(this._registrationID);
+    }
+
     switch (msg.status) {
       case 200:
-        this._retryEnd(); // reset retry mechanism
-        this.registered = true;
-        if (this.pushUrl !== msg.pushEndpoint) {
-          this.pushUrl = msg.pushEndpoint;
-          this._registerCallback(null, this.pushUrl);
+        if (msg.channelID == this._registrationID) {
+          this._retryEnd(); // reset retry mechanism
+          this.registeredChannels[msg.channelID] = msg.pushEndpoint;
+          this.channels[msg.channelID].onRegistered(null, msg.pushEndpoint, msg.channelID);
+          registerNext();
         }
         break;
 
       case 500:
         // retry the registration request after a suitable delay
-        this._retryOperation(() => this._registerChannel());
+        this._retryOperation(() => this._sendRegistration(msg.channelID));
         break;
 
       case 409:
-        this._registerCallback("error: PushServer ChannelID already in use");
+        this.channels[this._registrationID].onRegistered(
+          "error: PushServer ChannelID already in use: " + msg.channelID);
+        registerNext();
         break;
 
       default:
-        this._registerCallback("error: PushServer registration failure, status = " + msg.status);
+        let id = this._channelsToRegister.shift();
+        this.channels[this._registrationID].onRegistered(
+          "error: PushServer registration failure, status = " + msg.status);
+        registerNext();
         break;
     }
   },
 
   /**
    * Attempts to open a websocket.
    *
    * A new websocket interface is used each time. If an onStop callback
    * was received, calling asyncOpen() on the same interface will
    * trigger a "alreay open socket" exception even though the channel
    * is logically closed.
    */
   _openSocket: function() {
-    if (this._mockPushHandler) {
+    this._isConnected = false;
+
+    if (this._mockWebSocket) {
       // For tests, use the mock instance.
-      this._websocket = this._mockPushHandler;
-    } else if (!Services.io.offline) {
+      this._websocket = this._mockWebSocket;
+    } else {
       this._websocket = Cc["@mozilla.org/network/protocol;1?name=wss"]
                         .createInstance(Ci.nsIWebSocketChannel);
-    } else {
-      this._registerCallback("offline");
-      console.warn("MozLoopPushHandler - IO offline");
-      return;
     }
 
     this._websocket.protocol = "push-notification";
 
     let performOpen = () => {
       let uri = Services.io.newURI(this.pushServerUri, null, null);
       this._websocket.asyncOpen(uri, this.pushServerUri, this, null);
     }
@@ -254,25 +312,49 @@ let MozLoopPushHandler = {
       req.send();
     } else {
       // this.pushServerUri already set -- just open the channel
       performOpen();
     }
   },
 
   /**
-   * Handles registering a service
+   * Begins registering the channelIDs with the PushServer
    */
-  _registerChannel: function() {
-    this.registered = false;
-    try { // in case websocket has closed
-      this._websocket.sendMsg(JSON.stringify({messageType: "register",
-                                              channelID: this.channelID}));
+  _registerChannels: function() {
+    // Hold off registration operation until handshake is complete.
+    if (!this._isConnected) {
+      return;
+    }
+
+    // If a registration is pending, do not generate a work list.
+    // Assume registration is in progress.
+    if (!this._registrationID) {
+      // Generate a list of channelIDs that have not yet been registered.
+      this._channelsToRegister = Object.keys(this.channels).filter((id) => {
+        return !(id in this.registeredChannels);
+      });
+      this._registrationID = this._channelsToRegister.shift();
+      this._sendRegistration(this._registrationID);
     }
-    catch (e) {console.warn("MozLoopPushHandler::_registerChannel websocket.sendMsg() failure");}
+  },
+
+  /**
+   * Handles registering a service
+   *
+   * @param {string} channelID - identification token to use in registration for this channel.
+   */
+  _sendRegistration: function(channelID) {
+    if (channelID) {
+      try { // in case websocket has closed
+        this._websocket.sendMsg(JSON.stringify({messageType: "register",
+                                                channelID: channelID}));
+      }
+      catch (e) {console.warn("MozLoopPushHandler::_registerChannel websocket.sendMsg() failure");}
+    }
   },
 
   /**
    * Method to handle retrying UserAgent to PushServer request following
    * a retry back-off scheme managed by this function.
    *
    * @param {function} delayedOp Function to call after current delay is satisfied
    *
@@ -296,9 +378,8 @@ let MozLoopPushHandler = {
    */
   _retryEnd: function() {
     if (this._retryCount) {
       clearTimeout(this._timeoutID);
       this._retryCount = 0;
     }
   }
 };
-
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -26,16 +26,17 @@ const PREF_LOG_LEVEL = "loop.debug.logle
 const EMAIL_OR_PHONE_RE = /^(:?\S+@\S+|\+\d+)$/;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
+
 Cu.importGlobalProperties(["URL"]);
 
 this.EXPORTED_SYMBOLS = ["MozLoopService", "LOOP_SESSION_TYPE"];
 
 XPCOMUtils.defineLazyModuleGetter(this, "injectLoopAPI",
   "resource:///modules/loop/MozLoopAPI.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "convertToRTCStatsReport",
@@ -59,16 +60,22 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://services-common/hawkrequest.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "LoopContacts",
                                   "resource:///modules/loop/LoopContacts.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
                                   "resource:///modules/loop/LoopStorage.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "LoopCalls",
+                                  "resource:///modules/loop/LoopCalls.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "LoopRooms",
+                                  "resource:///modules/loop/LoopRooms.jsm");
+
 XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
                                   "resource:///modules/loop/MozLoopPushHandler.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
                                    "@mozilla.org/uuid-generator;1",
                                    "nsIUUIDGenerator");
 
 XPCOMUtils.defineLazyServiceGetter(this, "gDNSService",
@@ -103,179 +110,24 @@ let gPushHandler = null;
 let gHawkClient = null;
 let gLocalizedStrings = null;
 let gInitializeTimer = null;
 let gFxAEnabled = true;
 let gFxAOAuthClientPromise = null;
 let gFxAOAuthClient = null;
 let gErrors = new Map();
 
- /**
- * Attempts to open a websocket.
- *
- * A new websocket interface is used each time. If an onStop callback
- * was received, calling asyncOpen() on the same interface will
- * trigger a "alreay open socket" exception even though the channel
- * is logically closed.
- */
-function CallProgressSocket(progressUrl, callId, token) {
-  if (!progressUrl || !callId || !token) {
-    throw new Error("missing required arguments");
-  }
-
-  this._progressUrl = progressUrl;
-  this._callId = callId;
-  this._token = token;
-}
-
-CallProgressSocket.prototype = {
-  /**
-   * Open websocket and run hello exchange.
-   * Sends a hello message to the server.
-   *
-   * @param {function} Callback used after a successful handshake
-   *                   over the progressUrl.
-   * @param {function} Callback used if an error is encountered
-   */
-  connect: function(onSuccess, onError) {
-    this._onSuccess = onSuccess;
-    this._onError = onError ||
-      (reason => {log.warn("MozLoopService::callProgessSocket - ", reason);});
-
-    if (!onSuccess) {
-      this._onError("missing onSuccess argument");
-      return;
-    }
-
-    if (Services.io.offline) {
-      this._onError("IO offline");
-      return;
-    }
-
-    let uri = Services.io.newURI(this._progressUrl, null, null);
-
-    // Allow _websocket to be set for testing.
-    this._websocket = this._websocket ||
-      Cc["@mozilla.org/network/protocol;1?name=" + uri.scheme]
-        .createInstance(Ci.nsIWebSocketChannel);
-
-    this._websocket.asyncOpen(uri, this._progressUrl, this, null);
-  },
-
-  /**
-   * Listener method, handles the start of the websocket stream.
-   * Sends a hello message to the server.
-   *
-   * @param {nsISupports} aContext Not used
-   */
-  onStart: function() {
-    let helloMsg = {
-      messageType: "hello",
-      callId: this._callId,
-      auth: this._token,
-    };
-    try { // in case websocket has closed before this handler is run
-      this._websocket.sendMsg(JSON.stringify(helloMsg));
-    }
-    catch (error) {
-      this._onError(error);
-    }
-  },
-
-  /**
-   * Listener method, called when the websocket is closed.
-   *
-   * @param {nsISupports} aContext Not used
-   * @param {nsresult} aStatusCode Reason for stopping (NS_OK = successful)
-   */
-  onStop: function(aContext, aStatusCode) {
-    if (!this._handshakeComplete) {
-      this._onError("[" + aStatusCode + "]");
-    }
-  },
-
-  /**
-   * Listener method, called when the websocket is closed by the server.
-   * If there are errors, onStop may be called without ever calling this
-   * method.
-   *
-   * @param {nsISupports} aContext Not used
-   * @param {integer} aCode the websocket closing handshake close code
-   * @param {String} aReason the websocket closing handshake close reason
-   */
-  onServerClose: function(aContext, aCode, aReason) {
-    if (!this._handshakeComplete) {
-      this._onError("[" + aCode + "]" + aReason);
-    }
-  },
-
-  /**
-   * Listener method, called when the websocket receives a message.
-   *
-   * @param {nsISupports} aContext Not used
-   * @param {String} aMsg The message data
-   */
-  onMessageAvailable: function(aContext, aMsg) {
-    let msg = {};
-    try {
-      msg = JSON.parse(aMsg);
-    }
-    catch (error) {
-      log.error("MozLoopService: error parsing progress message - ", error);
-      return;
-    }
-
-    if (msg.messageType && msg.messageType === 'hello') {
-      this._handshakeComplete = true;
-      this._onSuccess();
-    }
-  },
-
-
-  /**
-   * Create a JSON message payload and send on websocket.
-   *
-   * @param {Object} aMsg Message to send.
-   */
-  _send: function(aMsg) {
-    if (!this._handshakeComplete) {
-      log.warn("MozLoopService::_send error - handshake not complete");
-      return;
-    }
-
-    try {
-      this._websocket.sendMsg(JSON.stringify(aMsg));
-    }
-    catch (error) {
-      this._onError(error);
-    }
-  },
-
-  /**
-   * Notifies the server that the user has declined the call
-   * with a reason of busy.
-   */
-  sendBusy: function() {
-    this._send({
-      messageType: "action",
-      event: "terminate",
-      reason: "busy"
-    });
-  },
-};
-
 /**
  * Internal helper methods and state
  *
  * The registration is a two-part process. First we need to connect to
  * and register with the push server. Then we need to take the result of that
  * and register with the Loop server.
  */
 let MozLoopServiceInternal = {
-  callsData: {inUse: false},
   _mocks: {webSocket: undefined},
 
   // The uri of the Loop server.
   get loopServerUri() Services.prefs.getCharPref("loop.server"),
 
   /**
    * The initial delay for push registration. This ensures we don't start
    * kicking off straight after browser startup, just a few seconds later.
@@ -449,34 +301,83 @@ let MozLoopServiceInternal = {
   },
 
   /**
    * Starts registration of Loop with the push server, and then will register
    * with the Loop server. It will return early if already registered.
    *
    * @param {Object} mockPushHandler Optional, test-only mock push handler. Used
    *                                 to allow mocking of the MozLoopPushHandler.
+   * @param {Object} mockWebSocket Optional, test-only mock webSocket. To be passed
+   *                               through to MozLoopPushHandler.
    * @returns {Promise} a promise that is resolved with no params on completion, or
    *          rejected with an error code or string.
    */
   promiseRegisteredWithServers: function(mockPushHandler, mockWebSocket) {
-    this._mocks.webSocket = mockWebSocket;
-
     if (gRegisteredDeferred) {
       return gRegisteredDeferred.promise;
     }
 
+    this._mocks.webSocket = mockWebSocket;
+    this._mocks.pushHandler = mockPushHandler;
+
+    // Wrap push notification registration call-back in a Promise.
+    let registerForNotification = function(channelID, onNotification) {
+      return new Promise((resolve, reject) => {
+        let onRegistered = (error, pushUrl) => {
+          if (error) {
+            reject(Error(error));
+          } else {
+            resolve(pushUrl);
+          }
+        };
+        gPushHandler.register(channelID, onRegistered, onNotification);
+      });
+    };
+
     gRegisteredDeferred = Promise.defer();
     // We grab the promise early in case .initialize or its results sets
     // it back to null on error.
     let result = gRegisteredDeferred.promise;
 
     gPushHandler = mockPushHandler || MozLoopPushHandler;
-    gPushHandler.initialize(this.onPushRegistered.bind(this),
-                            this.onHandleNotification.bind(this));
+    let options = mockWebSocket ? {mockWebSocket: mockWebSocket} : {};
+    gPushHandler.initialize(options);
+
+    let callsRegGuest = registerForNotification(LoopCalls.channelIDs.Guest,
+                                                LoopCalls.onNotification);
+
+    let roomsRegGuest = registerForNotification(LoopRooms.channelIDs.Guest,
+                                                LoopRooms.onNotification);
+
+    let callsRegFxA = registerForNotification(LoopCalls.channelIDs.FxA,
+                                              LoopCalls.onNotification);
+
+    let roomsRegFxA = registerForNotification(LoopRooms.channelIDs.FxA,
+                                              LoopRooms.onNotification);
+    Promise.all([callsRegGuest, roomsRegGuest, callsRegFxA, roomsRegFxA])
+    .then((pushUrls) => {
+      return this.registerWithLoopServer(LOOP_SESSION_TYPE.GUEST,
+                                         {calls: pushUrls[0], rooms: pushUrls[1]}) })
+    .then(() => {
+      // storeSessionToken could have rejected and nulled the promise if the token was malformed.
+      if (!gRegisteredDeferred) {
+        return;
+      }
+      gRegisteredDeferred.resolve("registered to guest status");
+      // No need to clear the promise here, everything was good, so we don't need
+      // to re-register.
+    }, error => {
+      log.error("Failed to register with Loop server: ", error);
+      // registerWithLoopServer may have already made this null.
+      if (gRegisteredDeferred) {
+        gRegisteredDeferred.reject(error);
+      }
+      gRegisteredDeferred = null;
+    });
 
     return result;
   },
 
   /**
    * Performs a hawk based request to the loop server.
    *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
@@ -602,72 +503,42 @@ let MozLoopServiceInternal = {
    *                                        One of the LOOP_SESSION_TYPE members.
    */
   clearSessionToken: function(sessionType) {
     Services.prefs.clearUserPref(this.getSessionTokenPrefName(sessionType));
     log.debug("Cleared hawk session token for sessionType", sessionType);
   },
 
   /**
-   * Callback from MozLoopPushHandler - The push server has been registered
-   * and has given us a push url.
-   *
-   * @param {String} pushUrl The push url given by the push server.
-   */
-  onPushRegistered: function(err, pushUrl) {
-    if (err) {
-      gRegisteredDeferred.reject(err);
-      gRegisteredDeferred = null;
-      return;
-    }
-
-    this.registerWithLoopServer(LOOP_SESSION_TYPE.GUEST, pushUrl).then(() => {
-      // storeSessionToken could have rejected and nulled the promise if the token was malformed.
-      if (!gRegisteredDeferred) {
-        return;
-      }
-      gRegisteredDeferred.resolve("registered to guest status");
-      // No need to clear the promise here, everything was good, so we don't need
-      // to re-register.
-    }, error => {
-      log.error("Failed to register with Loop server: ", error);
-      // registerWithLoopServer may have already made this null.
-      if (gRegisteredDeferred) {
-        gRegisteredDeferred.reject(error);
-      }
-      gRegisteredDeferred = null;
-    });
-  },
-
-  /**
    * Registers with the Loop server either as a guest or a FxA user.
    *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
-   * @param {String} pushUrl The push url given by the push server.
+   * @param {String} pushUrls The push url given by the push server.
    * @param {Boolean} [retry=true] Whether to retry if authentication fails.
    * @return {Promise}
    */
-  registerWithLoopServer: function(sessionType, pushUrl, retry = true) {
-    return this.hawkRequest(sessionType, "/registration", "POST", { simplePushURL: pushUrl})
+  registerWithLoopServer: function(sessionType, pushUrls, retry = true) {
+    return this.hawkRequest(sessionType, "/registration", "POST", { simplePushURLs: pushUrls})
       .then((response) => {
         // If this failed we got an invalid token. storeSessionToken rejects
         // the gRegisteredDeferred promise for us, so here we just need to
         // early return.
-        if (!this.storeSessionToken(sessionType, response.headers))
+        if (!this.storeSessionToken(sessionType, response.headers)) {
           return;
+        }
 
         log.debug("Successfully registered with server for sessionType", sessionType);
         this.clearError("registration");
       }, (error) => {
         // There's other errors than invalid auth token, but we should only do the reset
         // as a last resort.
         if (error.code === 401) {
           // Authorization failed, invalid token, we need to try again with a new token.
           if (retry) {
-            return this.registerWithLoopServer(sessionType, pushUrl, false);
+            return this.registerWithLoopServer(sessionType, pushUrls, false);
           }
         }
 
         log.error("Failed to register with the loop server. Error: ", error);
         this.setError("registration", error);
         gRegisteredDeferred.reject(error);
         gRegisteredDeferred = null;
         throw error;
@@ -676,209 +547,48 @@ let MozLoopServiceInternal = {
   },
 
   /**
    * Unregisters from the Loop server either as a guest or a FxA user.
    *
    * This is normally only wanted for FxA users as we normally want to keep the
    * guest session with the device.
    *
+   * NOTE: It is the responsibiliy of the caller the clear the session token
+   * after all of the notification classes: calls and rooms, for either
+   * Guest or FxA have been unregistered with the LoopServer.
+   *
    * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
-   * @param {String} pushURL The push URL previously given by the push server.
+   * @param {String} pushURLs The push URL previously given by the push server.
    *                         This may not be necessary to unregister in the future.
    * @return {Promise} resolving when the unregistration request finishes
    */
   unregisterFromLoopServer: function(sessionType, pushURL) {
     let prefType = Services.prefs.getPrefType(this.getSessionTokenPrefName(sessionType));
     if (prefType == Services.prefs.PREF_INVALID) {
       return Promise.resolve("already unregistered");
     }
 
     let unregisterURL = "/registration?simplePushURL=" + encodeURIComponent(pushURL);
     return this.hawkRequest(sessionType, unregisterURL, "DELETE")
       .then(() => {
         log.debug("Successfully unregistered from server for sessionType", sessionType);
-        MozLoopServiceInternal.clearSessionToken(sessionType);
       },
       error => {
-        // Always clear the registration token regardless of whether the server acknowledges the logout.
-        MozLoopServiceInternal.clearSessionToken(sessionType);
         if (error.code === 401) {
           // Authorization failed, invalid token. This is fine since it may mean we already logged out.
           return;
         }
 
         log.error("Failed to unregister with the loop server. Error: ", error);
         throw error;
       });
   },
 
   /**
-   * Callback from MozLoopPushHandler - A push notification has been received from
-   * the server.
-   *
-   * @param {String} version The version information from the server.
-   */
-  onHandleNotification: function(version) {
-    if (this.doNotDisturb) {
-      return;
-    }
-
-    // We set this here as it is assumed that once the user receives an incoming
-    // call, they'll have had enough time to see the terms of service. See
-    // bug 1046039 for background.
-    Services.prefs.setCharPref("loop.seenToS", "seen");
-
-    // Request the information on the new call(s) associated with this version.
-    // The registered FxA session is checked first, then the anonymous session.
-    // Make the call to get the GUEST session regardless of whether the FXA
-    // request fails.
-
-    if (MozLoopService.userProfile) {
-      this._getCalls(LOOP_SESSION_TYPE.FXA, version).catch(() => {});
-    }
-    this._getCalls(LOOP_SESSION_TYPE.GUEST, version).catch(
-      error => {this._hawkRequestError(error);});
-  },
-
-  /**
-   * Make a hawkRequest to GET/calls?=version for this session type.
-   *
-   * @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
-   *        for the GET operation.
-   * @param {Object} version - LoopPushService notification version
-   *
-   * @returns {Promise}
-   *
-   */
-
-  _getCalls: function(sessionType, version) {
-    return this.hawkRequest(sessionType, "/calls?version=" + version, "GET").then(
-      response => {this._processCalls(response, sessionType);}
-    );
-  },
-
-  /**
-   * Process the calls array returned from a GET/calls?version request.
-   * Only one active call is permitted at this time.
-   *
-   * @param {Object} response - response payload from GET
-   *
-   * @param {LOOP_SESSION_TYPE} sessionType - type of hawk token used
-   *        for the GET operation.
-   *
-   */
-
-  _processCalls: function(response, sessionType) {
-    try {
-      let respData = JSON.parse(response.body);
-      if (respData.calls && Array.isArray(respData.calls)) {
-        respData.calls.forEach((callData) => {
-          if (!this.callsData.inUse) {
-            callData.sessionType = sessionType;
-            this._startCall(callData, "incoming");
-          } else {
-            this._returnBusy(callData);
-          }
-        });
-      } else {
-        log.warn("Error: missing calls[] in response");
-      }
-    } catch (err) {
-      log.warn("Error parsing calls info", err);
-    }
-  },
-
-  /**
-   * Starts a call, saves the call data, and opens a chat window.
-   *
-   * @param {Object} callData The data associated with the call including an id.
-   * @param {String} conversationType Whether or not the call is "incoming"
-   *                                  or "outgoing"
-   */
-  _startCall: function(callData, conversationType) {
-    const openChat = () => {
-      this.callsData.inUse = true;
-      this.callsData.data = callData;
-
-      this.openChatWindow(
-        null,
-        // No title, let the page set that, to avoid flickering.
-        "",
-        "about:loopconversation#" + conversationType + "/" + callData.callId);
-    };
-
-    if (conversationType == "incoming" && ("callerId" in callData) &&
-        EMAIL_OR_PHONE_RE.test(callData.callerId)) {
-      LoopContacts.search({
-        q: callData.callerId,
-        field: callData.callerId.contains("@") ? "email" : "tel"
-      }, (err, contacts) => {
-        if (err) {
-          // Database error, helas!
-          openChat();
-          return;
-        }
-
-        for (let contact of contacts) {
-          if (contact.blocked) {
-            // Blocked! Send a busy signal back to the caller.
-            this._returnBusy(callData);
-            return;
-          }
-        }
-
-        openChat();
-      })
-    } else {
-      openChat();
-    }
-  },
-
-  /**
-   * Starts a direct call to the contact addresses.
-   *
-   * @param {Object} contact The contact to call
-   * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
-   * @return true if the call is opened, false if it is not opened (i.e. busy)
-   */
-  startDirectCall: function(contact, callType) {
-    if (this.callsData.inUse)
-      return false;
-
-    var callData = {
-      contact: contact,
-      callType: callType,
-      // XXX Really we shouldn't be using random numbers, bug 1090209 will fix this.
-      callId: Math.floor((Math.random() * 100000000))
-    };
-
-    this._startCall(callData, "outgoing");
-    return true;
-  },
-
-   /**
-   * Open call progress websocket and terminate with a reason of busy
-   * the server.
-   *
-   * @param {callData} Must contain the progressURL, callId and websocketToken
-   *                   returned by the LoopService.
-   */
-  _returnBusy: function(callData) {
-    let callProgress = new CallProgressSocket(
-      callData.progressURL,
-      callData.callId,
-      callData.websocketToken);
-    callProgress._websocket = this._mocks.webSocket;
-    // This instance of CallProgressSocket should stay alive until the underlying
-    // websocket is closed since it is passed to the websocket as the nsIWebSocketListener.
-    callProgress.connect(() => {callProgress.sendBusy();});
-  },
-
-  /**
    * A getter to obtain and store the strings for loop. This is structured
    * for use by l10n.js.
    *
    * @returns {Object} a map of element ids with attributes to set.
    */
   get localizedStrings() {
     if (gLocalizedStrings)
       return gLocalizedStrings;
@@ -1163,18 +873,21 @@ let gInitializeTimerFunc = (deferredInit
       if (!MozLoopServiceInternal.fxAOAuthTokenData) {
         log.debug("MozLoopService: Initialized without an already logged-in account");
         deferredInitialization.resolve("initialized to guest status");
         return;
       }
 
       log.debug("MozLoopService: Initializing with already logged-in account");
       let registeredPromise =
-            MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA,
-                                                          gPushHandler.pushUrl);
+            MozLoopServiceInternal.registerWithLoopServer(
+              LOOP_SESSION_TYPE.FXA, {
+                calls: gPushHandler.registeredChannels[LoopCalls.channelIDs.FxA],
+                rooms: gPushHandler.registeredChannels[LoopRooms.channelIDs.FxA]
+              });
       registeredPromise.then(() => {
         deferredInitialization.resolve("initialized to logged-in status");
       }, error => {
         log.debug("MozLoopService: error logging in using cached auth token");
         MozLoopServiceInternal.setError("login", error);
         deferredInitialization.reject("error logging in using cached auth token");
       });
     }), error => {
@@ -1238,16 +951,28 @@ this.MozLoopService = {
         // This never gets cleared since there is no UI to recover. Only restarting will work.
         MozLoopServiceInternal.setError("initialization", error);
       }
       throw error;
     });
   }),
 
   /**
+   * Opens the chat window
+   *
+   * @param {Object} contentWindow The window to open the chat window in, may
+   *                               be null.
+   * @param {String} title The title of the chat window.
+   * @param {String} url The page to load in the chat window.
+   */
+  openChatWindow: function(contentWindow, title, url) {
+    MozLoopServiceInternal.openChatWindow(contentWindow, title, url);
+  },
+
+  /**
    * If we're operating the service in "soft start" mode, and this browser
    * isn't already activated, check whether it's time for it to become active.
    * If so, activate the loop service.
    *
    * @param {Function} doneCb   [optional] Callback that is called when the
    *                            check has completed.
    */
   checkSoftStart(doneCb) {
@@ -1453,49 +1178,16 @@ this.MozLoopService = {
       return Services.prefs.getComplexValue("general.useragent.locale",
         Ci.nsISupportsString).data;
     } catch (ex) {
       return "en-US";
     }
   },
 
   /**
-   * Returns the callData for a specific loopCallId
-   *
-   * The data was retrieved from the LoopServer via a GET/calls/<version> request
-   * triggered by an incoming message from the LoopPushServer.
-   *
-   * @param {int} loopCallId
-   * @return {callData} The callData or undefined if error.
-   */
-  getCallData: function(loopCallId) {
-    if (MozLoopServiceInternal.callsData.data &&
-        MozLoopServiceInternal.callsData.data.callId == loopCallId) {
-      return MozLoopServiceInternal.callsData.data;
-    } else {
-      return undefined;
-    }
-  },
-
-  /**
-   * Releases the callData for a specific loopCallId
-   *
-   * The result of this call will be a free call session slot.
-   *
-   * @param {int} loopCallId
-   */
-  releaseCallData: function(loopCallId) {
-    if (MozLoopServiceInternal.callsData.data &&
-        MozLoopServiceInternal.callsData.data.callId == loopCallId) {
-      MozLoopServiceInternal.callsData.data = undefined;
-      MozLoopServiceInternal.callsData.inUse = false;
-    }
-  },
-
-  /**
    * Set any character preference under "loop.".
    *
    * @param {String} prefName The name of the pref without the preceding "loop."
    * @param {String} value The value to set.
    *
    * Any errors thrown by the Mozilla pref API are logged to the console.
    */
   setLoopCharPref: function(prefName, value) {
@@ -1560,28 +1252,30 @@ this.MozLoopService = {
    *
    * @return {Promise} that resolves when the FxA login flow is complete.
    */
   logInToFxA: function() {
     log.debug("logInToFxA with fxAOAuthTokenData:", !!MozLoopServiceInternal.fxAOAuthTokenData);
     if (MozLoopServiceInternal.fxAOAuthTokenData) {
       return Promise.resolve(MozLoopServiceInternal.fxAOAuthTokenData);
     }
-
     return MozLoopServiceInternal.promiseFxAOAuthAuthorization().then(response => {
       return MozLoopServiceInternal.promiseFxAOAuthToken(response.code, response.state);
     }).then(tokenData => {
       MozLoopServiceInternal.fxAOAuthTokenData = tokenData;
       return tokenData;
     }).then(tokenData => {
       return gRegisteredDeferred.promise.then(Task.async(function*() {
-        if (gPushHandler.pushUrl) {
-          yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, gPushHandler.pushUrl);
+        let callsUrl = gPushHandler.registeredChannels[LoopCalls.channelIDs.FxA],
+            roomsUrl = gPushHandler.registeredChannels[LoopRooms.channelIDs.FxA];
+        if (callsUrl && roomsUrl) {
+          yield MozLoopServiceInternal.registerWithLoopServer(
+            LOOP_SESSION_TYPE.FXA, {calls: callsUrl, rooms: roomsUrl});
         } else {
-          throw new Error("No pushUrl for FxA registration");
+          throw new Error("No pushUrls for FxA registration");
         }
         MozLoopServiceInternal.clearError("login");
         MozLoopServiceInternal.clearError("profile");
         return MozLoopServiceInternal.fxAOAuthTokenData;
       }));
     }).then(tokenData => {
       let client = new FxAccountsProfileClient({
         serverURL: gFxAOAuthClient.parameters.profile_uri,
@@ -1612,20 +1306,33 @@ this.MozLoopService = {
    * Logs the user out from FxA.
    *
    * Gracefully handles if the user is already logged out.
    *
    * @return {Promise} that resolves when the FxA logout flow is complete.
    */
   logOutFromFxA: Task.async(function*() {
     log.debug("logOutFromFxA");
-    if (gPushHandler && gPushHandler.pushUrl) {
-      yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
-                                                            gPushHandler.pushUrl);
-    } else {
+    let callsPushUrl, roomsPushUrl;
+    if (gPushHandler) {
+      callsPushUrl = gPushHandler.registeredChannels[LoopCalls.channelIDs.FxA];
+      roomsPushUrl = gPushHandler.registeredChannels[LoopRooms.channelIDs.FxA];
+    }
+    try {
+      if (callsPushUrl) {
+        yield MozLoopServiceInternal.unregisterFromLoopServer(
+          LOOP_SESSION_TYPE.FXA, callsPushUrl);
+      }
+      if (roomsPushUrl) {
+        yield MozLoopServiceInternal.unregisterFromLoopServer(
+          LOOP_SESSION_TYPE.FXA, roomsPushUrl);
+      }
+    }
+    catch (error) {throw error}
+    finally {
       MozLoopServiceInternal.clearSessionToken(LOOP_SESSION_TYPE.FXA);
     }
 
     MozLoopServiceInternal.fxAOAuthTokenData = null;
     MozLoopServiceInternal.fxAOAuthProfile = null;
 
     // Reset the client since the initial promiseFxAOAuthParameters() call is
     // what creates a new session.
@@ -1641,17 +1348,16 @@ this.MozLoopService = {
 
   openFxASettings: Task.async(function() {
     try {
       let fxAOAuthClient = yield MozLoopServiceInternal.promiseFxAOAuthClient();
       if (!fxAOAuthClient) {
         log.error("Could not get the OAuth client");
         return;
       }
-
       let url = new URL("/settings", fxAOAuthClient.parameters.content_uri);
       let win = Services.wm.getMostRecentWindow("navigator:browser");
       win.switchToTabHavingURI(url.toString(), true);
     } catch (ex) {
       log.error("Error opening FxA settings", ex);
     }
   }),
 
@@ -1669,20 +1375,9 @@ this.MozLoopService = {
    *        or is rejected with an error.  If the server response can be parsed
    *        as JSON and contains an 'error' property, the promise will be
    *        rejected with this JSON-parsed response.
    */
   hawkRequest: function(sessionType, path, method, payloadObj) {
     return MozLoopServiceInternal.hawkRequest(sessionType, path, method, payloadObj).catch(
       error => {MozLoopServiceInternal._hawkRequestError(error);});
   },
-
-    /**
-     * Starts a direct call to the contact addresses.
-     *
-     * @param {Object} contact The contact to call
-     * @param {String} callType The type of call, e.g. "audio-video" or "audio-only"
-     * @return true if the call is opened, false if it is not opened (i.e. busy)
-     */
-  startDirectCall: function(contact, callType) {
-    MozLoopServiceInternal.startDirectCall(contact, callType);
-  },
 };
--- a/browser/components/loop/moz.build
+++ b/browser/components/loop/moz.build
@@ -10,15 +10,17 @@ XPCSHELL_TESTS_MANIFESTS += ['test/xpcsh
 
 BROWSER_CHROME_MANIFESTS += [
     'test/mochitest/browser.ini',
 ]
 
 EXTRA_JS_MODULES.loop += [
     'CardDavImporter.jsm',
     'GoogleImporter.jsm',
+    'LoopCalls.jsm',
     'LoopContacts.jsm',
+    'LoopRooms.jsm',
     'LoopStorage.jsm',
     'MozLoopAPI.jsm',
     'MozLoopPushHandler.jsm',
     'MozLoopService.jsm',
     'MozLoopWorker.js',
 ]
--- a/browser/components/loop/test/mochitest/browser_fxa_login.js
+++ b/browser/components/loop/test/mochitest/browser_fxa_login.js
@@ -117,17 +117,17 @@ add_task(function* params_no_hawk_sessio
   ok(caught, "Should have caught the rejection");
   let prefName = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   ise(Services.prefs.getPrefType(prefName),
       Services.prefs.PREF_INVALID,
       "Check FxA hawk token is not set");
 });
 
 add_task(function* params_nonJSON() {
-  Services.prefs.setCharPref("loop.server", "https://loop.invalid");
+  Services.prefs.setCharPref("loop.server", "https://localhost:3000/invalid");
   // Reset after changing the server so a new HawkClient is created
   yield resetFxA();
 
   let loginPromise = MozLoopService.logInToFxA();
   let caught = false;
   yield loginPromise.catch(() => {
     ok(true, "The login promise should be rejected due to non-JSON params");
     caught = true;
@@ -243,49 +243,53 @@ add_task(function* basicAuthorizationAnd
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   info("registering");
-  mockPushHandler.pushUrl = "https://localhost/pushUrl/guest";
+  mockPushHandler.registrationPushURL = "https://localhost/pushUrl/guest";
   // Notification observed due to the error being cleared upon successful registration.
   let statusChangedPromise = promiseObserverNotified("loop-status-changed");
   yield MozLoopService.register(mockPushHandler);
   yield statusChangedPromise;
 
   // Normally the same pushUrl would be registered but we change it in the test
   // to be able to check for success on the second registration.
-  mockPushHandler.pushUrl = "https://localhost/pushUrl/fxa";
+  mockPushHandler.registeredChannels[LoopCalls.channelIDs.FxA] = "https://localhost/pushUrl/fxa-calls";
+  mockPushHandler.registeredChannels[LoopRooms.channelIDs.FxA] = "https://localhost/pushUrl/fxa-rooms";
 
   statusChangedPromise = promiseObserverNotified("loop-status-changed");
   yield loadLoopPanel({loopURL: BASE_URL, stayOnline: true});
   yield statusChangedPromise;
   let loopDoc = document.getElementById("loop").contentDocument;
   let visibleEmail = loopDoc.getElementsByClassName("user-identity")[0];
   is(visibleEmail.textContent, "Guest", "Guest should be displayed on the panel when not logged in");
   is(MozLoopService.userProfile, null, "profile should be null before log-in");
   let loopButton = document.getElementById("loop-button-throttled");
   is(loopButton.getAttribute("state"), "", "state of loop button should be empty when not logged in");
 
+  info("Login");
   let tokenData = yield MozLoopService.logInToFxA();
   yield promiseObserverNotified("loop-status-changed", "login");
   ise(tokenData.access_token, "code1_access_token", "Check access_token");
   ise(tokenData.scope, "profile", "Check scope");
   ise(tokenData.token_type, "bearer", "Check token_type");
 
   is(MozLoopService.userProfile.email, "test@example.com", "email should exist in the profile data");
   is(MozLoopService.userProfile.uid, "1234abcd", "uid should exist in the profile data");
   is(visibleEmail.textContent, "test@example.com", "the email should be correct on the panel");
   is(loopButton.getAttribute("state"), "active", "state of loop button should be active when logged in");
 
   let registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
-  ise(registrationResponse.response.simplePushURL, "https://localhost/pushUrl/fxa",
+  ise(registrationResponse.response.simplePushURLs.calls, "https://localhost/pushUrl/fxa-calls",
+      "Check registered push URL");
+  ise(registrationResponse.response.simplePushURLs.rooms, "https://localhost/pushUrl/fxa-rooms",
       "Check registered push URL");
 
   let loopPanel = document.getElementById("loop-notification-panel");
   loopPanel.hidePopup();
   statusChangedPromise = promiseObserverNotified("loop-status-changed");
   yield loadLoopPanel({loopURL: BASE_URL, stayOnline: true});
   yield statusChangedPromise;
   is(loopButton.getAttribute("state"), "", "state of loop button should return to empty after panel is opened");
@@ -324,53 +328,55 @@ add_task(function* loginWithParams401() 
   });
 
   yield checkFxA401();
 });
 
 add_task(function* logoutWithIncorrectPushURL() {
   yield resetFxA();
   let pushURL = "http://www.example.com/";
-  mockPushHandler.pushUrl = pushURL;
+  mockPushHandler.registeredChannels[LoopCalls.channelIDs.FxA] = pushURL;
+  mockPushHandler.registeredChannels[LoopRooms.channelIDs.FxA] = pushURL;
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
-  yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, pushURL);
+  yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, {calls: pushURL});
   let registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
-  ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL");
-  mockPushHandler.pushUrl = "http://www.example.com/invalid";
+  ise(registrationResponse.response.simplePushURLs.calls, pushURL, "Check registered push URL");
+  mockPushHandler.registeredChannels[LoopCalls.channelIDs.FxA] = "http://www.example.com/invalid";
   let caught = false;
   yield MozLoopService.logOutFromFxA().catch((error) => {
     caught = true;
   });
   ok(caught, "Should have caught an error logging out with a mismatched push URL");
   checkLoggedOutState();
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
-  ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
+  ise(registrationResponse.response.simplePushURLs.calls, pushURL, "Check registered push URL wasn't deleted");
 });
 
 add_task(function* logoutWithNoPushURL() {
   yield resetFxA();
   let pushURL = "http://www.example.com/";
-  mockPushHandler.pushUrl = pushURL;
+  mockPushHandler.registeredChannels[LoopCalls.channelIDs.FxA] = pushURL;
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
-  yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, pushURL);
+  yield MozLoopServiceInternal.registerWithLoopServer(LOOP_SESSION_TYPE.FXA, {calls: pushURL});
   let registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
-  ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL");
-  mockPushHandler.pushUrl = null;
+  ise(registrationResponse.response.simplePushURLs.calls, pushURL, "Check registered push URL");
+  mockPushHandler.registeredChannels[LoopCalls.channelIDs.FxA] = null;
+  mockPushHandler.registeredChannels[LoopRooms.channelIDs.FxA] = null;
   yield MozLoopService.logOutFromFxA();
   checkLoggedOutState();
   registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
-  ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
+  ise(registrationResponse.response.simplePushURLs.calls, pushURL, "Check registered push URL wasn't deleted");
 });
 
 add_task(function* loginWithRegistration401() {
   yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -1,16 +1,18 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const HAWK_TOKEN_LENGTH = 64;
 const {
   LOOP_SESSION_TYPE,
   MozLoopServiceInternal,
 } = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
+const {LoopCalls} = Cu.import("resource:///modules/loop/LoopCalls.jsm", {});
+const {LoopRooms} = Cu.import("resource:///modules/loop/LoopRooms.jsm", {});
 
 // Cache this value only once, at the beginning of a
 // test run, so that it doesn't pick up the offline=true
 // if offline mode is requested multiple times in a test run.
 const WAS_OFFLINE = Services.io.offline;
 
 var gMozLoopAPI;
 
@@ -115,16 +117,17 @@ function promiseOAuthParamsSetup(baseURL
   return deferred.promise;
 }
 
 function* resetFxA() {
   let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
   global.gHawkClient = null;
   global.gFxAOAuthClientPromise = null;
   global.gFxAOAuthClient = null;
+  global.gRegisteredDeferred = null;
   MozLoopServiceInternal.fxAOAuthProfile = null;
   MozLoopServiceInternal.fxAOAuthTokenData = null;
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.clearUserPref(fxASessionPref);
   MozLoopService.errors.clear();
   let notified = promiseObserverNotified("loop-status-changed");
   MozLoopServiceInternal.notifyStatusChanged();
   yield notified;
@@ -191,31 +194,40 @@ function getLoopString(stringID) {
  * This is used to fake push registration and notifications for
  * MozLoopService tests. There is only one object created per test instance, as
  * once registration has taken place, the object cannot currently be changed.
  */
 let mockPushHandler = {
   // This sets the registration result to be returned when initialize
   // is called. By default, it is equivalent to success.
   registrationResult: null,
-  pushUrl: undefined,
+  registrationPushURL: null,
+  notificationCallback: {},
+  registeredChannels: {},
 
   /**
    * MozLoopPushHandler API
    */
-  initialize: function(registerCallback, notificationCallback) {
-    registerCallback(this.registrationResult, this.pushUrl);
-    this._notificationCallback = notificationCallback;
+  initialize: function(options = {}) {
+    if ("mockWebSocket" in options) {
+      this._mockWebSocket = options.mockWebSocket;
+    }
+  },
+
+  register: function(channelId, registerCallback, notificationCallback) {
+    this.notificationCallback[channelId] = notificationCallback;
+    this.registeredChannels[channelId] = this.registrationPushURL;
+    setTimeout(registerCallback(this.registrationResult, this.registrationPushURL, channelId), 0);
   },
 
   /**
    * Test-only API to simplify notifying a push notification result.
    */
-  notify: function(version) {
-    this._notificationCallback(version);
+  notify: function(version, chanId) {
+    this.notificationCallback[chanId](version, chanId);
   }
 };
 
 // Add the Loop button to the navbar.
 CustomizableUI.addWidgetToArea("loop-button-throttled", CustomizableUI.AREA_NAVBAR);
 
 registerCleanupFunction(function() {
   CustomizableUI.reset();
--- a/browser/components/loop/test/mochitest/loop_fxa.sjs
+++ b/browser/components/loop/test/mochitest/loop_fxa.sjs
@@ -192,19 +192,21 @@ function profile(request, response) {
  * POST /registration
  *
  * Mock Loop registration endpoint. Hawk Authorization headers are expected only for FxA sessions.
  */
 function registration(request, response) {
   let body = NetUtil.readInputStreamToString(request.bodyInputStream,
                                              request.bodyInputStream.available());
   let payload = JSON.parse(body);
-  if (payload.simplePushURL == "https://localhost/pushUrl/fxa" &&
-       (!request.hasHeader("Authorization") ||
-        !request.getHeader("Authorization").startsWith("Hawk"))) {
+  if ((payload.simplePushURL == "https://localhost/pushUrl/fxa" ||
+       payload.simplePushURLs.calls == "https://localhost/pushUrl/fxa-calls" ||
+       payload.simplePushURLs.rooms == "https://localhost/pushUrl/fxa-rooms") &&
+      (!request.hasHeader("Authorization") ||
+       !request.getHeader("Authorization").startsWith("Hawk"))) {
     response.setStatusLine(request.httpVersion, 401, "Missing Hawk");
     response.write("401 Missing Hawk Authorization header");
     return;
   }
   setSharedState("/registration", body);
 }
 
 /**
@@ -219,21 +221,24 @@ function delete_registration(request, re
     response.write("401 Missing Hawk Authorization header");
     return;
   }
 
   // Do some query string munging due to the SJS file using a base with a trailing "?"
   // making the path become a query parameter. This is because we aren't actually
   // registering endpoints at the root of the hostname e.g. /registration.
   let url = new URL(request.queryString.replace(/%3F.*/,""), "http://www.example.com");
-  let registration = JSON.parse(getSharedState("/registration"));
-  if (registration.simplePushURL == url.searchParams.get("simplePushURL")) {
-    setSharedState("/registration", "");
-  } else {
-    response.setStatusLine(request.httpVersion, 400, "Bad Request");
+  let state = getSharedState("/registration");
+  if (state != "") { //Already set to empty value on a successful channel unregsitration.
+    let registration = JSON.parse(state);
+    if (registration.simplePushURLs.calls == url.searchParams.get("simplePushURL")) {
+      setSharedState("/registration", "");
+    } else {
+      response.setStatusLine(request.httpVersion, 400, "Bad Request");
+    }
   }
 }
 
 /**
  * GET /get_registration
  *
  * Used for testing purposes to check if registration succeeded by returning the POST body.
  */
--- a/browser/components/loop/test/xpcshell/head.js
+++ b/browser/components/loop/test/xpcshell/head.js
@@ -69,42 +69,50 @@ function getLoopString(stringID) {
  * This is used to fake push registration and notifications for
  * MozLoopService tests. There is only one object created per test instance, as
  * once registration has taken place, the object cannot currently be changed.
  */
 let mockPushHandler = {
   // This sets the registration result to be returned when initialize
   // is called. By default, it is equivalent to success.
   registrationResult: null,
-  registrationPushURL: undefined,
+  registrationPushURL: null,
+  notificationCallback: {},
+  registeredChannels: {},
 
   /**
    * MozLoopPushHandler API
    */
-  initialize: function(registerCallback, notificationCallback) {
-    registerCallback(this.registrationResult, this.registrationPushURL);
-    this._notificationCallback = notificationCallback;
+  initialize: function(options = {}) {
+    if ("mockWebSocket" in options) {
+      this._mockWebSocket = options.mockWebSocket;
+    }
+  },
+
+  register: function(channelId, registerCallback, notificationCallback) {
+    this.notificationCallback[channelId] = notificationCallback;
+    this.registeredChannels[channelId] = this.registrationPushURL;
+    registerCallback(this.registrationResult, this.registrationPushURL, channelId);
   },
 
   /**
    * Test-only API to simplify notifying a push notification result.
    */
-  notify: function(version) {
-    this._notificationCallback(version);
+  notify: function(version, chanId) {
+    this.notificationCallback[chanId](version, chanId);
   }
 };
 
 /**
  * Mock nsIWebSocketChannel for tests. This mocks the WebSocketChannel, and
  * enables us to check parameters and return messages similar to the push
  * server.
  */
-let MockWebSocketChannel = function(options) {
-  let _options = options || {};
-  this.defaultMsgHandler = _options.defaultMsgHandler;
+let MockWebSocketChannel = function(options = {}) {
+  this.defaultMsgHandler = options.defaultMsgHandler;
 };
 
 MockWebSocketChannel.prototype = {
   QueryInterface: XPCOMUtils.generateQI(Ci.nsIWebSocketChannel),
 
   /**
    * nsIWebSocketChannel implementations.
    * See nsIWebSocketChannel.idl for API details.
--- a/browser/components/loop/test/xpcshell/test_looppush_initialize.js
+++ b/browser/components/loop/test/xpcshell/test_looppush_initialize.js
@@ -1,65 +1,82 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 {
+  let dummyCallback = () => {};
+  let mockWebSocket = new MockWebSocketChannel();
+
   add_test(function test_initalize_offline() {
     Services.io.offline = true;
-
-    MozLoopPushHandler.initialize(function(err) {
-      Assert.equal(err, "offline", "Should error with 'offline' when offline");
+    do_check_false(MozLoopPushHandler.initialize());
+    Services.io.offline = false;
+    MozLoopPushHandler.initialize({mockWebSocket: mockWebSocket});
+    run_next_test();
+  });
 
-      Services.io.offline = false;
-      run_next_test();
-    });
+  add_test(function test_initalize_missing_chanid() {
+    Assert.throws(() => {MozLoopPushHandler.register(null, dummyCallback, dummyCallback)});
+    run_next_test();
   });
 
-  let mockWebSocket = new MockWebSocketChannel();
+  add_test(function test_initalize_missing_regcallback() {
+    Assert.throws(() => {MozLoopPushHandler.register("chan-1", null, dummyCallback)});
+    run_next_test();
+  });
+
+  add_test(function test_initalize_missing_notifycallback() {
+    Assert.throws(() => {MozLoopPushHandler.register("chan-1", dummyCallback, null)});
+    run_next_test();
+  });
 
   add_test(function test_initalize_websocket() {
-    MozLoopPushHandler.initialize(
-      function(err, url) {
+    MozLoopPushHandler.register(
+      "chan-1",
+      function(err, url, id) {
         Assert.equal(err, null, "Should return null for success");
         Assert.equal(url, kEndPointUrl, "Should return push server application URL");
+        Assert.equal(id, "chan-1", "Should have channel id = chan-1");
         Assert.equal(mockWebSocket.uri.prePath, kServerPushUrl,
                      "Should have the url from preferences");
         Assert.equal(mockWebSocket.origin, kServerPushUrl,
                      "Should have the origin url from preferences");
         Assert.equal(mockWebSocket.protocol, "push-notification",
                      "Should have the protocol set to push-notifications");
         mockWebSocket.notify(15);
       },
-      function(version) {
+      function(version, id) {
         Assert.equal(version, 15, "Should have version number 15");
+        Assert.equal(id, "chan-1", "Should have channel id = chan-1");
         run_next_test();
-      }, 
+      },
       mockWebSocket);
   });
 
   add_test(function test_reconnect_websocket() {
     MozLoopPushHandler.uaID = undefined;
-    MozLoopPushHandler.pushUrl = undefined; //Do this to force a new registration callback.
+    MozLoopPushHandler.registeredChannels = {}; //Do this to force a new registration callback.
     mockWebSocket.stop();
   });
 
   add_test(function test_reopen_websocket() {
     MozLoopPushHandler.uaID = undefined;
-    MozLoopPushHandler.pushUrl = undefined; //Do this to force a new registration callback.
+    MozLoopPushHandler.registeredChannels = {}; //Do this to force a new registration callback.
     mockWebSocket.serverClose();
   });
 
   add_test(function test_retry_registration() {
     MozLoopPushHandler.uaID = undefined;
-    MozLoopPushHandler.pushUrl = undefined; //Do this to force a new registration callback.
+    MozLoopPushHandler.registeredChannels = {}; //Do this to force a new registration callback.
     mockWebSocket.initRegStatus = 500;
     mockWebSocket.stop();
   });
 
   function run_test() {
     setupFakeLoopServer();
 
     Services.prefs.setCharPref("services.push.serverURL", kServerPushUrl);
+    Services.prefs.setCharPref("loop.server", kLoopServerUrl);
     Services.prefs.setIntPref("loop.retry_delay.start", 10); // 10 ms
     Services.prefs.setIntPref("loop.retry_delay.limit", 20); // 20 ms
 
     run_next_test();
   };
 }
--- a/browser/components/loop/test/xpcshell/test_loopservice_busy.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_busy.js
@@ -1,57 +1,129 @@
 /* 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/. */
 
+const { LoopCallsInternal } = Cu.import("resource:///modules/loop/LoopCalls.jsm", {});
+
 XPCOMUtils.defineLazyModuleGetter(this, "Chat",
                                   "resource:///modules/Chat.jsm");
 
 let openChatOrig = Chat.open;
 
 const firstCallId = 4444333221;
 const secondCallId = 1001100101;
 
-function test_send_busy_on_call() {
-  let actionReceived = false;
+let msgHandler = function(msg) {
+  if (msg.messageType &&
+      msg.messageType === "action" &&
+      msg.event === "terminate" &&
+      msg.reason === "busy") {
+    actionReceived = true;
+  }
+}
+
+let mockWebSocket = new MockWebSocketChannel({defaultMsgHandler: msgHandler});
+LoopCallsInternal._mocks.webSocket = mockWebSocket;
+
+Services.io.offline = false;
+
+add_test(function test_busy_2guest_calls() {
+  actionReceived = false;
 
-  let msgHandler = function(msg) {
-    if (msg.messageType &&
-        msg.messageType === "action" &&
-        msg.event === "terminate" &&
-        msg.reason === "busy") {
-      actionReceived = true;
-    }
-  };
+  MozLoopService.register(mockPushHandler, mockWebSocket).then(() => {
+    let opened = 0;
+    Chat.open = function() {
+      opened++;
+    };
+
+    mockPushHandler.notify(1, LoopCalls.channelIDs.Guest);
 
-  let mockWebSocket = new MockWebSocketChannel({defaultMsgHandler: msgHandler});
-  Services.io.offline = false;
+    waitForCondition(() => {return actionReceived && opened > 0}).then(() => {
+      do_check_true(opened === 1, "should open only one chat window");
+      do_check_true(actionReceived, "should respond with busy/reject to second call");
+      LoopCalls.releaseCallData(firstCallId);
+      run_next_test();
+    }, () => {
+      do_throw("should have opened a chat window for first call and rejected second call");
+    });
+
+  });
+});
+
+add_test(function test_busy_1fxa_1guest_calls() {
+  actionReceived = false;
 
   MozLoopService.register(mockPushHandler, mockWebSocket).then(() => {
     let opened = 0;
     Chat.open = function() {
       opened++;
     };
 
-    mockPushHandler.notify(1);
+    mockPushHandler.notify(1, LoopCalls.channelIDs.FxA);
+    mockPushHandler.notify(1, LoopCalls.channelIDs.Guest);
 
     waitForCondition(() => {return actionReceived && opened > 0}).then(() => {
       do_check_true(opened === 1, "should open only one chat window");
       do_check_true(actionReceived, "should respond with busy/reject to second call");
-      MozLoopService.releaseCallData(firstCallId);
+      LoopCalls.releaseCallData(firstCallId);
       run_next_test();
     }, () => {
       do_throw("should have opened a chat window for first call and rejected second call");
     });
 
   });
-}
+});
+
+add_test(function test_busy_2fxa_calls() {
+  actionReceived = false;
+
+  MozLoopService.register(mockPushHandler, mockWebSocket).then(() => {
+    let opened = 0;
+    Chat.open = function() {
+      opened++;
+    };
+
+    mockPushHandler.notify(1, LoopCalls.channelIDs.FxA);
+
+    waitForCondition(() => {return actionReceived && opened > 0}).then(() => {
+      do_check_true(opened === 1, "should open only one chat window");
+      do_check_true(actionReceived, "should respond with busy/reject to second call");
+      LoopCalls.releaseCallData(firstCallId);
+      run_next_test();
+    }, () => {
+      do_throw("should have opened a chat window for first call and rejected second call");
+    });
 
-add_test(test_send_busy_on_call); //FXA call accepted, Guest call rejected
-add_test(test_send_busy_on_call); //No FXA call, first Guest call accepted, second rejected
+  });
+});
+
+add_test(function test_busy_1guest_1fxa_calls() {
+  actionReceived = false;
+
+  MozLoopService.register(mockPushHandler, mockWebSocket).then(() => {
+    let opened = 0;
+    Chat.open = function() {
+      opened++;
+    };
+
+    mockPushHandler.notify(1, LoopCalls.channelIDs.Guest);
+    mockPushHandler.notify(1, LoopCalls.channelIDs.FxA);
+
+    waitForCondition(() => {return actionReceived && opened > 0}).then(() => {
+      do_check_true(opened === 1, "should open only one chat window");
+      do_check_true(actionReceived, "should respond with busy/reject to second call");
+      LoopCalls.releaseCallData(firstCallId);
+      run_next_test();
+    }, () => {
+      do_throw("should have opened a chat window for first call and rejected second call");
+    });
+
+  });
+});
 
 function run_test()
 {
   setupFakeLoopServer();
 
   // Setup fake login state so we get FxA requests.
   const MozLoopServiceInternal = Cu.import("resource:///modules/loop/MozLoopService.jsm", {}).MozLoopServiceInternal;
   MozLoopServiceInternal.fxAOAuthTokenData = {token_type:"bearer",access_token:"1bad3e44b12f77a88fe09f016f6a37c42e40f974bc7a8b432bb0d2f0e37e1752",scope:"profile"};
@@ -61,26 +133,24 @@ function run_test()
   // for any pending calls on the FxA hawk session and then again using the guest session.
   // A pair of response objects in the callsResponses array will be consumed for each
   // notification. The even calls object is for the FxA session, the odd the Guest session.
 
   let callsRespCount = 0;
   let callsResponses = [
     {calls: [{callId: firstCallId,
               websocketToken: "0deadbeef0",
-              progressURL: "wss://localhost:5000/websocket"}]},
-    {calls: [{callId: secondCallId,
+              progressURL: "wss://localhost:5000/websocket"},
+             {callId: secondCallId,
               websocketToken: "1deadbeef1",
               progressURL: "wss://localhost:5000/websocket"}]},
-
-    {calls: []},
     {calls: [{callId: firstCallId,
               websocketToken: "0deadbeef0",
-              progressURL: "wss://localhost:5000/websocket"},
-             {callId: secondCallId,
+              progressURL: "wss://localhost:5000/websocket"}]},
+    {calls: [{callId: secondCallId,
               websocketToken: "1deadbeef1",
               progressURL: "wss://localhost:5000/websocket"}]},
   ];
 
   loopServer.registerPathHandler("/registration", (request, response) => {
     response.setStatusLine(null, 200, "OK");
     response.processAsync();
     response.finish();
@@ -102,12 +172,14 @@ function run_test()
     // Revert original Chat.open implementation
     Chat.open = openChatOrig;
 
     // Revert fake login state
     MozLoopServiceInternal.fxAOAuthTokenData = null;
 
     // clear test pref
     Services.prefs.clearUserPref("loop.seenToS");
+
+    LoopCallsInternal._mocks.webSocket = undefined;
   });
 
   run_next_test();
 }
--- a/browser/components/loop/test/xpcshell/test_loopservice_directcall.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_directcall.js
@@ -16,42 +16,42 @@ const contact = {
 };
 
 add_task(function test_startDirectCall_opens_window() {
   let openedUrl;
   Chat.open = function(contentWindow, origin, title, url) {
     openedUrl = url;
   };
 
-  MozLoopService.startDirectCall(contact, "audio-video");
+  LoopCalls.startDirectCall(contact, "audio-video");
 
   do_check_true(!!openedUrl, "should open a chat window");
 
   // Stop the busy kicking in for following tests.
   let callId = openedUrl.match(/about:loopconversation\#outgoing\/(.*)/)[1];
-  MozLoopService.releaseCallData(callId);
+  LoopCalls.releaseCallData(callId);
 });
 
 add_task(function test_startDirectCall_getCallData() {
   let openedUrl;
   Chat.open = function(contentWindow, origin, title, url) {
     openedUrl = url;
   };
 
-  MozLoopService.startDirectCall(contact, "audio-video");
+  LoopCalls.startDirectCall(contact, "audio-video");
 
   let callId = openedUrl.match(/about:loopconversation\#outgoing\/(.*)/)[1];
 
-  let callData = MozLoopService.getCallData(callId);
+  let callData = LoopCalls.getCallData(callId);
 
   do_check_eq(callData.callType, "audio-video", "should have the correct call type");
   do_check_eq(callData.contact, contact, "should have the contact details");
 
   // Stop the busy kicking in for following tests.
-  MozLoopService.releaseCallData(callId);
+  LoopCalls.releaseCallData(callId);
 });
 
 function run_test() {
   do_register_cleanup(function() {
     // Revert original Chat.open implementation
     Chat.open = openChatOrig;
   });
 
--- a/browser/components/loop/test/xpcshell/test_loopservice_dnd.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_dnd.js
@@ -32,17 +32,17 @@ add_test(function test_do_not_disturb_di
   MozLoopService.doNotDisturb = false;
 
   MozLoopService.register(mockPushHandler).then(() => {
     let opened = false;
     Chat.open = function() {
       opened = true;
     };
 
-    mockPushHandler.notify(1);
+    mockPushHandler.notify(1, LoopCalls.channelIDs.Guest);
 
     waitForCondition(function() opened).then(() => {
       run_next_test();
     }, () => {
       do_throw("should have opened a chat window");
     });
   });
 });
@@ -51,17 +51,17 @@ add_test(function test_do_not_disturb_en
   MozLoopService.doNotDisturb = true;
 
   // We registered in the previous test, so no need to do that on this one.
   let opened = false;
   Chat.open = function() {
     opened = true;
   };
 
-  mockPushHandler.notify(1);
+  mockPushHandler.notify(1, LoopCalls.channelIDs.Guest);
 
   do_timeout(500, function() {
     do_check_false(opened, "should not open a chat window");
     run_next_test();
   });
 });
 
 function run_test()
--- a/browser/components/loop/test/xpcshell/test_loopservice_notification.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_notification.js
@@ -11,17 +11,17 @@ add_test(function test_openChatWindow_on
   Services.prefs.setCharPref("loop.seenToS", "unseen");
 
   MozLoopService.register(mockPushHandler).then(() => {
     let opened = false;
     Chat.open = function() {
       opened = true;
     };
 
-    mockPushHandler.notify(1);
+    mockPushHandler.notify(1, LoopCalls.channelIDs.Guest);
 
     waitForCondition(function() opened).then(() => {
       do_check_true(opened, "should open a chat window");
 
       do_check_eq(Services.prefs.getCharPref("loop.seenToS"), "seen",
                   "should set the pref to 'seen'");
 
       run_next_test();
--- a/browser/components/loop/test/xpcshell/test_loopservice_registration.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_registration.js
@@ -6,63 +6,49 @@ Cu.import("resource://services-common/ut
 /**
  * This file is to test general registration. Note that once successful
  * registration has taken place, we can no longer test the server side
  * parts as the service protects against this, hence, they need testing in
  * other test files.
  */
 
 /**
- * Tests reported failures when we're in offline mode.
- */
-add_test(function test_register_offline() {
-  mockPushHandler.registrationResult = "offline";
-
-  // It should callback with failure if in offline mode
-  Services.io.offline = true;
-
-  MozLoopService.register(mockPushHandler).then(() => {
-    do_throw("should not succeed when offline");
-  }, err => {
-    Assert.equal(err, "offline", "should reject with 'offline' when offline");
-    Services.io.offline = false;
-    run_next_test();
-  });
-});
-
-/**
  * Test that the websocket can be fully registered, and that a Loop server
  * failure is reported.
  */
 add_test(function test_register_websocket_success_loop_server_fail() {
-  mockPushHandler.registrationResult = null;
+  mockPushHandler.registrationResult = "404";
 
   MozLoopService.register(mockPushHandler).then(() => {
     do_throw("should not succeed when loop server registration fails");
-  }, err => {
+  }, (err) => {
     // 404 is an expected failure indicated by the lack of route being set
     // up on the Loop server mock. This is added in the next test.
-    Assert.equal(err.errno, 404, "Expected no errors in websocket registration");
+    Assert.equal(err.message, "404", "Expected no errors in websocket registration");
 
     run_next_test();
   });
 });
 
 /**
  * Tests that we get a success response when both websocket and Loop server
  * registration are complete.
  */
+
 add_test(function test_register_success() {
   mockPushHandler.registrationPushURL = kEndPointUrl;
+  mockPushHandler.registrationResult = null;
 
   loopServer.registerPathHandler("/registration", (request, response) => {
     let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
     let data = JSON.parse(body);
-    Assert.equal(data.simplePushURL, kEndPointUrl,
-                 "Should send correct push url");
+    Assert.equal(data.simplePushURLs.calls, kEndPointUrl,
+                 "Should send correct calls push url");
+    Assert.equal(data.simplePushURLs.rooms, kEndPointUrl,
+                 "Should send correct rooms push url");
 
     response.setStatusLine(null, 200, "OK");
     response.processAsync();
     response.finish();
   });
   MozLoopService.register(mockPushHandler).then(() => {
     run_next_test();
   }, err => {