Bug 972017 Part 4 - Hook up the OT sdk to the direct calling window for Loop. r=nperriault
authorMark Banner <standard8@mozilla.com>
Fri, 03 Oct 2014 22:42:02 +0100
changeset 218139 669ecc39ceae37972aa0732062b4f41b107ac023
parent 218138 798083bcc387e268d096d3b35a3eaa5b9c34e8ee
child 218140 7a5f4d72e05dcdff5ca479c3c02ebcd78bdfffb4
push id2
push usergszorc@mozilla.com
push dateWed, 12 Nov 2014 19:43:22 +0000
treeherderfig@7a5f4d72e05d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnperriault
bugs972017
milestone34.0a2
Bug 972017 Part 4 - Hook up the OT sdk to the direct calling window for Loop. r=nperriault
browser/components/loop/content/conversation.html
browser/components/loop/content/js/client.js
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/content/shared/js/otSdkDriver.js
browser/components/loop/jar.mn
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/conversation_test.js
browser/components/loop/test/desktop-local/index.html
browser/components/loop/test/shared/conversationStore_test.js
browser/components/loop/test/shared/index.html
browser/components/loop/test/shared/otSdkDriver_test.js
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -28,16 +28,17 @@
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
     <script type="text/javascript" src="loop/shared/js/mixins.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
     <script type="text/javascript" src="loop/shared/js/feedbackApiClient.js"></script>
     <script type="text/javascript" src="loop/shared/js/actions.js"></script>
     <script type="text/javascript" src="loop/shared/js/validate.js"></script>
     <script type="text/javascript" src="loop/shared/js/dispatcher.js"></script>
+    <script type="text/javascript" src="loop/shared/js/otSdkDriver.js"></script>
     <script type="text/javascript" src="loop/shared/js/conversationStore.js"></script>
     <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/shared/js/websocket.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
     <script type="text/javascript" src="loop/js/conversationViews.js"></script>
     <script type="text/javascript" src="loop/js/conversation.js"></script>
   </body>
 </html>
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -222,16 +222,18 @@ loop.Client = (function($) {
      * - err null on successful registration, non-null otherwise.
      * - result an object of the obtained data for starting the call, if successful
      *
      * @param {Array} calleeIds an array of emails and phone numbers.
      * @param {String} callType the type of call.
      * @param {Function} cb Callback(err, result)
      */
     setupOutgoingCall: function(calleeIds, callType, cb) {
+      // For direct calls, we only ever use the logged-in session. Direct
+      // calls by guests aren't valid.
       this.mozLoop.hawkRequest(this.mozLoop.LOOP_SESSION_TYPE.FXA,
         "/calls", "POST", {
           calleeId: calleeIds,
           callType: callType
         },
         function (err, responseText) {
           if (err) {
             this._failureHandler(cb, err);
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -533,19 +533,25 @@ loop.conversation = (function(mozL10n) {
       set: function(guid, callback) {
         navigator.mozLoop.setLoopCharPref("ot.guid", guid);
         callback(null);
       }
     });
 
     var dispatcher = new loop.Dispatcher();
     var client = new loop.Client();
+    var sdkDriver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: OT
+    });
+
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
-      dispatcher: dispatcher
+      dispatcher: dispatcher,
+      sdkDriver: sdkDriver
     });
 
     // XXX For now key this on the pref, but this should really be
     // set by the information from the mozLoop API when we can get it (bug 1072323).
     var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
 
     // XXX Old class creation for the incoming conversation view, whilst
     // we transition across (bug 1072323).
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -533,19 +533,25 @@ loop.conversation = (function(mozL10n) {
       set: function(guid, callback) {
         navigator.mozLoop.setLoopCharPref("ot.guid", guid);
         callback(null);
       }
     });
 
     var dispatcher = new loop.Dispatcher();
     var client = new loop.Client();
+    var sdkDriver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: OT
+    });
+
     var conversationStore = new loop.store.ConversationStore({}, {
       client: client,
-      dispatcher: dispatcher
+      dispatcher: dispatcher,
+      sdkDriver: sdkDriver
     });
 
     // XXX For now key this on the pref, but this should really be
     // set by the information from the mozLoop API when we can get it (bug 1072323).
     var outgoingEmail = navigator.mozLoop.getLoopCharPref("outgoingemail");
 
     // XXX Old class creation for the incoming conversation view, whilst
     // we transition across (bug 1072323).
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -153,41 +153,95 @@ loop.conversationViews = (function(mozL1
       /**
        * OT inserts inline styles into the markup. Using a listener for
        * resize events helps us trigger a full width/height on the element
        * so that they update to the correct dimensions.
        * XXX: this should be factored as a mixin.
        */
       window.addEventListener('orientationchange', this.updateVideoContainer);
       window.addEventListener('resize', this.updateVideoContainer);
+
+      // The SDK needs to know about the configuration and the elements to use
+      // for display. So the best way seems to pass the information here - ideally
+      // the sdk wouldn't need to know this, but we can't change that.
+      this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        publisherConfig: this._getPublisherConfig(),
+        getLocalElementFunc: this._getElement.bind(this, ".local"),
+        getRemoteElementFunc: this._getElement.bind(this, ".remote")
+      }));
     },
 
     componentWillUnmount: function() {
       window.removeEventListener('orientationchange', this.updateVideoContainer);
       window.removeEventListener('resize', this.updateVideoContainer);
     },
 
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    },
+
+    /**
+     * Returns the required configuration for publishing video on the sdk.
+     */
+    _getPublisherConfig: function() {
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: this.props.video.enabled,
+        style: {
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off"
+        }
+      }
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
     updateVideoContainer: function() {
-      var localStreamParent = document.querySelector('.local .OT_publisher');
-      var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
       if (localStreamParent) {
         localStreamParent.style.width = "100%";
       }
       if (remoteStreamParent) {
         remoteStreamParent.style.height = "100%";
       }
     },
 
+    /**
+     * Hangs up the call.
+     */
     hangup: function() {
       this.props.dispatcher.dispatch(
         new sharedActions.HangupCall());
     },
 
+    /**
+     * Used to control publishing a stream - i.e. to mute a stream
+     *
+     * @param {String} type The type of stream, e.g. "audio" or "video".
+     * @param {Boolean} enabled True to enable the stream, false otherwise.
+     */
     publishStream: function(type, enabled) {
-      // XXX Add this as part of bug 972017.
+      this.props.dispatcher.dispatch(
+        new sharedActions.SetMute({
+          type: type,
+          enabled: enabled
+        }));
     },
 
     render: function() {
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
         "local-stream-audio": !this.props.video.enabled
       });
@@ -281,17 +335,18 @@ loop.conversationViews = (function(mozL1
         case CALL_STATES.TERMINATED: {
           return (CallFailedView({
             dispatcher: this.props.dispatcher}
           ));
         }
         case CALL_STATES.ONGOING: {
           return (OngoingConversationView({
             dispatcher: this.props.dispatcher, 
-            video: {enabled: this.state.callType === CALL_TYPES.AUDIO_VIDEO}}
+            video: {enabled: this.state.videoMuted}, 
+            audio: {enabled: this.state.audioMuted}}
             )
           );
         }
         case CALL_STATES.FINISHED: {
           return this._renderFeedbackView();
         }
         default: {
           return (PendingConversationView({
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -153,41 +153,95 @@ loop.conversationViews = (function(mozL1
       /**
        * OT inserts inline styles into the markup. Using a listener for
        * resize events helps us trigger a full width/height on the element
        * so that they update to the correct dimensions.
        * XXX: this should be factored as a mixin.
        */
       window.addEventListener('orientationchange', this.updateVideoContainer);
       window.addEventListener('resize', this.updateVideoContainer);
+
+      // The SDK needs to know about the configuration and the elements to use
+      // for display. So the best way seems to pass the information here - ideally
+      // the sdk wouldn't need to know this, but we can't change that.
+      this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        publisherConfig: this._getPublisherConfig(),
+        getLocalElementFunc: this._getElement.bind(this, ".local"),
+        getRemoteElementFunc: this._getElement.bind(this, ".remote")
+      }));
     },
 
     componentWillUnmount: function() {
       window.removeEventListener('orientationchange', this.updateVideoContainer);
       window.removeEventListener('resize', this.updateVideoContainer);
     },
 
+    /**
+     * Returns either the required DOMNode
+     *
+     * @param {String} className The name of the class to get the element for.
+     */
+    _getElement: function(className) {
+      return this.getDOMNode().querySelector(className);
+    },
+
+    /**
+     * Returns the required configuration for publishing video on the sdk.
+     */
+    _getPublisherConfig: function() {
+      // height set to 100%" to fix video layout on Google Chrome
+      // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
+      return {
+        insertMode: "append",
+        width: "100%",
+        height: "100%",
+        publishVideo: this.props.video.enabled,
+        style: {
+          bugDisplayMode: "off",
+          buttonDisplayMode: "off",
+          nameDisplayMode: "off"
+        }
+      }
+    },
+
+    /**
+     * Used to update the video container whenever the orientation or size of the
+     * display area changes.
+     */
     updateVideoContainer: function() {
-      var localStreamParent = document.querySelector('.local .OT_publisher');
-      var remoteStreamParent = document.querySelector('.remote .OT_subscriber');
+      var localStreamParent = this._getElement('.local .OT_publisher');
+      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
       if (localStreamParent) {
         localStreamParent.style.width = "100%";
       }
       if (remoteStreamParent) {
         remoteStreamParent.style.height = "100%";
       }
     },
 
+    /**
+     * Hangs up the call.
+     */
     hangup: function() {
       this.props.dispatcher.dispatch(
         new sharedActions.HangupCall());
     },
 
+    /**
+     * Used to control publishing a stream - i.e. to mute a stream
+     *
+     * @param {String} type The type of stream, e.g. "audio" or "video".
+     * @param {Boolean} enabled True to enable the stream, false otherwise.
+     */
     publishStream: function(type, enabled) {
-      // XXX Add this as part of bug 972017.
+      this.props.dispatcher.dispatch(
+        new sharedActions.SetMute({
+          type: type,
+          enabled: enabled
+        }));
     },
 
     render: function() {
       var localStreamClasses = React.addons.classSet({
         local: true,
         "local-stream": true,
         "local-stream-audio": !this.props.video.enabled
       });
@@ -281,17 +335,18 @@ loop.conversationViews = (function(mozL1
         case CALL_STATES.TERMINATED: {
           return (<CallFailedView
             dispatcher={this.props.dispatcher}
           />);
         }
         case CALL_STATES.ONGOING: {
           return (<OngoingConversationView
             dispatcher={this.props.dispatcher}
-            video={{enabled: this.state.callType === CALL_TYPES.AUDIO_VIDEO}}
+            video={{enabled: this.state.videoMuted}}
+            audio={{enabled: this.state.audioMuted}}
             />
           );
         }
         case CALL_STATES.FINISHED: {
           return this._renderFeedbackView();
         }
         default: {
           return (<PendingConversationView
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -65,26 +65,61 @@ loop.shared.actions = (function() {
 
     /**
      * Used for hanging up the call at the end of a successful call.
      */
     HangupCall: Action.define("hangupCall", {
     }),
 
     /**
+     * Used to indicate the peer hung up the call.
+     */
+    PeerHungupCall: Action.define("peerHungupCall", {
+    }),
+
+    /**
      * Used for notifying of connection progress state changes.
      * The connection refers to the overall connection flow as indicated
      * on the websocket.
      */
     ConnectionProgress: Action.define("connectionProgress", {
       // The connection state from the websocket.
       wsState: String
     }),
 
     /**
      * Used for notifying of connection failures.
      */
     ConnectionFailure: Action.define("connectionFailure", {
       // A string relating to the reason the connection failed.
       reason: String
+    }),
+
+    /**
+     * Used by the ongoing views to notify stores about the elements
+     * required for the sdk.
+     */
+    SetupStreamElements: Action.define("setupStreamElements", {
+      // The configuration for the publisher/subscribe options
+      publisherConfig: Object,
+      // The local stream element
+      getLocalElementFunc: Function,
+      // The remote stream element
+      getRemoteElementFunc: Function
+    }),
+
+    /**
+     * Used for notifying that the media is now up for the call.
+     */
+    MediaConnected: Action.define("mediaConnected", {
+    }),
+
+    /**
+     * Used to mute or unmute a stream
+     */
+    SetMute: Action.define("setMute", {
+      // The part of the stream to enable, e.g. "audio" or "video"
+      type: String,
+      // Whether or not to enable the stream.
+      enabled: Boolean
     })
   };
 })();
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -3,17 +3,17 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* global loop:true */
 
 var loop = loop || {};
 loop.store = (function() {
 
   var sharedActions = loop.shared.actions;
-  var sharedUtils = loop.shared.utils;
+  var CALL_TYPES = loop.shared.utils.CALL_TYPES;
 
   /**
    * Websocket states taken from:
    * https://docs.services.mozilla.com/loop/apis.html#call-progress-state-change-progress
    */
   var WS_STATES = {
     // The call is starting, and the remote party is not yet being alerted.
     INIT: "init",
@@ -62,31 +62,35 @@ loop.store = (function() {
       // The error information, if there was a failure
       error: undefined,
       // True if the call is outgoing, false if not, undefined if unknown
       outgoing: undefined,
       // The id of the person being called for outgoing calls
       calleeId: undefined,
       // The call type for the call.
       // XXX Don't hard-code, this comes from the data in bug 1072323
-      callType: sharedUtils.CALL_TYPES.AUDIO_VIDEO,
+      callType: CALL_TYPES.AUDIO_VIDEO,
 
       // Call Connection information
       // The call id from the loop-server
       callId: undefined,
       // The connection progress url to connect the websocket
       progressURL: undefined,
       // The websocket token that allows connection to the progress url
       websocketToken: undefined,
       // SDK API key
       apiKey: undefined,
       // SDK session ID
       sessionId: undefined,
       // SDK session token
-      sessionToken: undefined
+      sessionToken: undefined,
+      // If the audio is muted
+      audioMuted: true,
+      // If the video is muted
+      videoMuted: true
     },
 
     /**
      * Constructor
      *
      * Options:
      * - {loop.Dispatcher} dispatcher The dispatcher for dispatching actions and
      *                                registering to consume actions.
@@ -99,38 +103,46 @@ loop.store = (function() {
       options = options || {};
 
       if (!options.dispatcher) {
         throw new Error("Missing option dispatcher");
       }
       if (!options.client) {
         throw new Error("Missing option client");
       }
+      if (!options.sdkDriver) {
+        throw new Error("Missing option sdkDriver");
+      }
 
       this.client = options.client;
       this.dispatcher = options.dispatcher;
+      this.sdkDriver = options.sdkDriver;
 
       this.dispatcher.register(this, [
         "connectionFailure",
         "connectionProgress",
         "gatherCallData",
         "connectCall",
         "hangupCall",
+        "peerHungupCall",
         "cancelCall",
-        "retryCall"
+        "retryCall",
+        "mediaConnected",
+        "setMute"
       ]);
     },
 
     /**
      * Handles the connection failure action, setting the state to
      * terminated.
      *
      * @param {sharedActions.ConnectionFailure} actionData The action data.
      */
     connectionFailure: function(actionData) {
+      this._endSession();
       this.set({
         callState: CALL_STATES.TERMINATED,
         callStateReason: actionData.reason
       });
     },
 
     /**
      * Handles the connection progress action, setting the next state
@@ -147,17 +159,25 @@ loop.store = (function() {
             this.set({callState: CALL_STATES.CONNECTING});
           }
           break;
         }
         case WS_STATES.ALERTING: {
           this.set({callState: CALL_STATES.ALERTING});
           break;
         }
-        case WS_STATES.CONNECTING:
+        case WS_STATES.CONNECTING: {
+          this.sdkDriver.connectSession({
+            apiKey: this.get("apiKey"),
+            sessionId: this.get("sessionId"),
+            sessionToken: this.get("sessionToken")
+          });
+          this.set({callState: CALL_STATES.ONGOING});
+          break;
+        }
         case WS_STATES.HALF_CONNECTED:
         case WS_STATES.CONNECTED: {
           this.set({callState: CALL_STATES.ONGOING});
           break;
         }
         default: {
           console.error("Unexpected websocket state passed to connectionProgress:",
             actionData.wsState);
@@ -174,16 +194,18 @@ loop.store = (function() {
     gatherCallData: function(actionData) {
       this.set({
         calleeId: actionData.calleeId,
         outgoing: !!actionData.calleeId,
         callId: actionData.callId,
         callState: CALL_STATES.GATHER
       });
 
+      this.videoMuted = this.get("callType") !== CALL_TYPES.AUDIO_VIDEO;
+
       if (this.get("outgoing")) {
         this._setupOutgoingCall();
       } // XXX Else, other types aren't supported yet.
     },
 
     /**
      * Handles the connect call action, this saves the appropriate
      * data and starts the connection for the websocket to notify the
@@ -195,51 +217,47 @@ loop.store = (function() {
       this.set(actionData.sessionData);
       this._connectWebSocket();
     },
 
     /**
      * Hangs up an ongoing call.
      */
     hangupCall: function() {
-      // XXX Stop the SDK once we add it.
-
-      // Ensure the websocket has been disconnected.
       if (this._websocket) {
         // Let the server know the user has hung up.
         this._websocket.mediaFail();
-        this._ensureWebSocketDisconnected();
       }
 
+      this._endSession();
+      this.set({callState: CALL_STATES.FINISHED});
+    },
+
+    /**
+     * The peer hungup the call.
+     */
+    peerHungupCall: function() {
+      this._endSession();
       this.set({callState: CALL_STATES.FINISHED});
     },
 
     /**
      * Cancels a call
      */
     cancelCall: function() {
       var callState = this.get("callState");
-      if (callState === CALL_STATES.TERMINATED) {
-        // All we need to do is close the window.
-        this.set({callState: CALL_STATES.CLOSE});
-        return;
+      if (this._websocket &&
+          (callState === CALL_STATES.CONNECTING ||
+           callState === CALL_STATES.ALERTING)) {
+         // Let the server know the user has hung up.
+        this._websocket.cancel();
       }
 
-      if (callState === CALL_STATES.CONNECTING ||
-          callState === CALL_STATES.ALERTING) {
-        if (this._websocket) {
-          // Let the server know the user has hung up.
-          this._websocket.cancel();
-          this._ensureWebSocketDisconnected();
-        }
-        this.set({callState: CALL_STATES.CLOSE});
-        return;
-      }
-
-      console.log("Unsupported cancel in state", callState);
+      this._endSession();
+      this.set({callState: CALL_STATES.CLOSE});
     },
 
     /**
      * Retries a call
      */
     retryCall: function() {
       var callState = this.get("callState");
       if (callState !== CALL_STATES.TERMINATED) {
@@ -249,16 +267,33 @@ loop.store = (function() {
 
       this.set({callState: CALL_STATES.GATHER});
       if (this.get("outgoing")) {
         this._setupOutgoingCall();
       }
     },
 
     /**
+     * Notifies that all media is now connected
+     */
+    mediaConnected: function() {
+      this._websocket.mediaUp();
+    },
+
+    /**
+     * Records the mute state for the stream.
+     *
+     * @param {sharedActions.setMute} actionData The mute state for the stream type.
+     */
+    setMute: function(actionData) {
+      var muteType = actionData.type + "Muted";
+      this.set(muteType, actionData.enabled);
+    },
+
+    /**
      * Obtains the outgoing call data from the server and handles the
      * result.
      */
     _setupOutgoingCall: function() {
       // XXX For now, we only have one calleeId, so just wrap that in an array.
       this.client.setupOutgoingCall([this.get("calleeId")],
         this.get("callType"),
         function(err, result) {
@@ -303,24 +338,27 @@ loop.store = (function() {
           }));
         }.bind(this)
       );
 
       this.listenTo(this._websocket, "progress", this._handleWebSocketProgress);
     },
 
     /**
-     * Ensures the websocket gets disconnected.
+     * Ensures the session is ended and the websocket is disconnected.
      */
-    _ensureWebSocketDisconnected: function() {
-     this.stopListening(this._websocket);
+    _endSession: function(nextState) {
+      this.sdkDriver.disconnectSession();
+      if (this._websocket) {
+        this.stopListening(this._websocket);
 
-      // Now close the websocket.
-      this._websocket.close();
-      delete this._websocket;
+        // Now close the websocket.
+        this._websocket.close();
+        delete this._websocket;
+      }
     },
 
     /**
      * Used to handle any progressed received from the websocket. This will
      * dispatch new actions so that the data can be handled appropriately.
      */
     _handleWebSocketProgress: function(progressData) {
       var action;
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -0,0 +1,237 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.OTSdkDriver = (function() {
+
+  var sharedActions = loop.shared.actions;
+
+  /**
+   * This is a wrapper for the OT sdk. It is used to translate the SDK events into
+   * actions, and instruct the SDK what to do as a result of actions.
+   */
+  var OTSdkDriver = function(options) {
+      if (!options.dispatcher) {
+        throw new Error("Missing option dispatcher");
+      }
+      if (!options.sdk) {
+        throw new Error("Missing option sdk");
+      }
+
+      this.dispatcher = options.dispatcher;
+      this.sdk = options.sdk;
+
+      this.dispatcher.register(this, [
+        "setupStreamElements",
+        "setMute"
+      ]);
+  };
+
+  OTSdkDriver.prototype = {
+    /**
+     * Handles the setupStreamElements action. Saves the required data and
+     * kicks off the initialising of the publisher.
+     *
+     * @param {sharedActions.SetupStreamElements} actionData The data associated
+     *   with the action. See action.js.
+     */
+    setupStreamElements: function(actionData) {
+      this.getLocalElement = actionData.getLocalElementFunc;
+      this.getRemoteElement = actionData.getRemoteElementFunc;
+      this.publisherConfig = actionData.publisherConfig;
+
+      // At this state we init the publisher, even though we might be waiting for
+      // the initial connect of the session. This saves time when setting up
+      // the media.
+      this.publisher = this.sdk.initPublisher(this.getLocalElement(),
+        this.publisherConfig,
+        this._onPublishComplete.bind(this));
+    },
+
+    /**
+     * Handles the setMute action. Informs the published stream to mute
+     * or unmute audio as appropriate.
+     *
+     * @param {sharedActions.SetMute} actionData The data associated with the
+     *                                           action. See action.js.
+     */
+    setMute: function(actionData) {
+      if (actionData.type === "audio") {
+        this.publisher.publishAudio(actionData.enabled);
+      } else {
+        this.publisher.publishVideo(actionData.enabled);
+      }
+    },
+
+    /**
+     * Connects a session for the SDK, listening to the required events.
+     *
+     * sessionData items:
+     * - sessionId: The OT session ID
+     * - apiKey: The OT API key
+     * - sessionToken: The token for the OT session
+     *
+     * @param {Object} sessionData The session data for setting up the OT session.
+     */
+    connectSession: function(sessionData) {
+      this.session = this.sdk.initSession(sessionData.sessionId);
+
+      this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this));
+      this.session.on("connectionDestroyed",
+        this._onConnectionDestroyed.bind(this));
+      this.session.on("sessionDisconnected",
+        this._onSessionDisconnected.bind(this));
+
+      // This starts the actual session connection.
+      this.session.connect(sessionData.apiKey, sessionData.sessionToken,
+        this._onConnectionComplete.bind(this));
+    },
+
+    /**
+     * Disconnects the sdk session.
+     */
+    disconnectSession: function() {
+      if (this.session) {
+        this.session.off("streamCreated", this._onRemoteStreamCreated.bind(this));
+        this.session.off("connectionDestroyed",
+          this._onConnectionDestroyed.bind(this));
+        this.session.off("sessionDisconnected",
+          this._onSessionDisconnected.bind(this));
+
+        this.session.disconnect();
+        delete this.session;
+      }
+      if (this.publisher) {
+        this.publisher.destroy();
+        delete this.publisher;
+      }
+
+      // Also, tidy these variables ready for next time.
+      delete this._sessionConnected;
+      delete this._publisherReady;
+      delete this._publishedLocalStream;
+      delete this._subscribedRemoteStream;
+    },
+
+    /**
+     * Called once the session has finished connecting.
+     *
+     * @param {Error} error An OT error object, null if there was no error.
+     */
+    _onConnectionComplete: function(error) {
+      if (error) {
+        console.error("Failed to complete connection", error);
+        this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+          reason: "couldNotConnect"
+        }));
+        return;
+      }
+
+      this._sessionConnected = true;
+      this._maybePublishLocalStream();
+    },
+
+    /**
+     * Handles the connection event for a peer's connection being dropped.
+     *
+     * @param {SessionDisconnectEvent} event The event details
+     * https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
+     */
+    _onConnectionDestroyed: function(event) {
+      var action;
+      if (event.reason === "clientDisconnected") {
+        action = new sharedActions.PeerHungupCall();
+      } else {
+        // Strictly speaking this isn't a failure on our part, but since our
+        // flow requires a full reconnection, then we just treat this as
+        // if a failure of our end had occurred.
+        action = new sharedActions.ConnectionFailure({
+          reason: "peerNetworkDisconnected"
+        });
+      }
+      this.dispatcher.dispatch(action);
+    },
+
+    /**
+     * Handles the session event for the connection for this client being
+     * destroyed.
+     *
+     * @param {SessionDisconnectEvent} event The event details:
+     * https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html
+     */
+    _onSessionDisconnected: function(event) {
+      // We only need to worry about the network disconnected reason here.
+      if (event.reason === "networkDisconnected") {
+        this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+          reason: "networkDisconnected"
+        }));
+      }
+    },
+
+    /**
+     * Handles the event when the remote stream is created.
+     *
+     * @param {StreamEvent} event The event details:
+     * https://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html
+     */
+    _onRemoteStreamCreated: function(event) {
+      this.session.subscribe(event.stream,
+        this.getRemoteElement(), this.publisherConfig);
+
+      this._subscribedRemoteStream = true;
+      if (this._checkAllStreamsConnected()) {
+        this.dispatcher.dispatch(new sharedActions.MediaConnected());
+      }
+    },
+
+    /**
+     * Handles the publishing being complete.
+     *
+     * @param {Error} error An OT error object, null if there was no error.
+     */
+    _onPublishComplete: function(error) {
+      if (error) {
+        console.error("Failed to initialize publisher", error);
+        this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+          reason: "noMedia"
+        }));
+        return;
+      }
+
+      this._publisherReady = true;
+      this._maybePublishLocalStream();
+    },
+
+    /**
+     * Publishes the local stream if the session is connected
+     * and the publisher is ready.
+     */
+    _maybePublishLocalStream: function() {
+      if (this._sessionConnected && this._publisherReady) {
+        // We are clear to publish the stream to the session.
+        this.session.publish(this.publisher);
+
+        // Now record the fact, and check if we've got all media yet.
+        this._publishedLocalStream = true;
+        if (this._checkAllStreamsConnected()) {
+          this.dispatcher.dispatch(new sharedActions.MediaConnected());
+        }
+      }
+    },
+
+    /**
+     * Used to check if both local and remote streams are available
+     * and send an action if they are.
+     */
+    _checkAllStreamsConnected: function() {
+      return this._publishedLocalStream &&
+        this._subscribedRemoteStream;
+    }
+  };
+
+  return OTSdkDriver;
+
+})();
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -54,16 +54,17 @@ browser.jar:
 
   # Shared scripts
   content/browser/loop/shared/js/actions.js           (content/shared/js/actions.js)
   content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
   content/browser/loop/shared/js/dispatcher.js        (content/shared/js/dispatcher.js)
   content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
   content/browser/loop/shared/js/models.js            (content/shared/js/models.js)
   content/browser/loop/shared/js/mixins.js            (content/shared/js/mixins.js)
+  content/browser/loop/shared/js/otSdkDriver.js       (content/shared/js/otSdkDriver.js)
   content/browser/loop/shared/js/views.js             (content/shared/js/views.js)
   content/browser/loop/shared/js/utils.js             (content/shared/js/utils.js)
   content/browser/loop/shared/js/validate.js          (content/shared/js/validate.js)
   content/browser/loop/shared/js/websocket.js         (content/shared/js/websocket.js)
 
   # Shared libs
 #ifdef DEBUG
   content/browser/loop/shared/libs/react-0.11.1.js    (content/shared/libs/react-0.11.1.js)
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -156,36 +156,130 @@ describe("loop.conversationViews", funct
         React.addons.TestUtils.Simulate.click(cancelBtn);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "cancelCall"));
       });
   });
 
+  describe("OngoingConversationView", function() {
+    function mountTestComponent(props) {
+      return TestUtils.renderIntoDocument(
+        loop.conversationViews.OngoingConversationView(props));
+    }
+
+    it("should dispatch a setupStreamElements action when the view is created",
+      function() {
+        view = mountTestComponent({
+          dispatcher: dispatcher
+        });
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setupStreamElements"));
+      });
+
+    it("should dispatch a hangupCall action when the hangup button is pressed",
+      function() {
+        view = mountTestComponent({
+          dispatcher: dispatcher
+        });
+
+        var hangupBtn = view.getDOMNode().querySelector('.btn-hangup');
+
+        React.addons.TestUtils.Simulate.click(hangupBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "hangupCall"));
+      });
+
+    it("should dispatch a setMute action when the audio mute button is pressed",
+      function() {
+        view = mountTestComponent({
+          dispatcher: dispatcher,
+          audio: {enabled: false}
+        });
+
+        var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
+
+        React.addons.TestUtils.Simulate.click(muteBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setMute"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("enabled", true));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("type", "audio"));
+      });
+
+    it("should dispatch a setMute action when the video mute button is pressed",
+      function() {
+        view = mountTestComponent({
+          dispatcher: dispatcher,
+          video: {enabled: true}
+        });
+
+        var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
+
+        React.addons.TestUtils.Simulate.click(muteBtn);
+
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "setMute"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("enabled", false));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("type", "video"));
+      });
+
+    it("should set the mute button as mute off", function() {
+      view = mountTestComponent({
+        dispatcher: dispatcher,
+        video: {enabled: true}
+      });
+
+      var muteBtn = view.getDOMNode().querySelector('.btn-mute-video');
+
+      expect(muteBtn.classList.contains("muted")).eql(false);
+    });
+
+    it("should set the mute button as mute on", function() {
+      view = mountTestComponent({
+        dispatcher: dispatcher,
+        audio: {enabled: false}
+      });
+
+      var muteBtn = view.getDOMNode().querySelector('.btn-mute-audio');
+
+      expect(muteBtn.classList.contains("muted")).eql(true);
+    });
+  });
+
   describe("OutgoingConversationView", function() {
     var store;
 
     function mountTestComponent() {
       return TestUtils.renderIntoDocument(
         loop.conversationViews.OutgoingConversationView({
+          dispatcher: dispatcher,
           store: store
         }));
     }
 
     beforeEach(function() {
-      store = new loop.store.ConversationStore({}, {
-        dispatcher: dispatcher,
-        client: {}
-      });
-
       navigator.mozLoop = {
         getLoopCharPref: function() { return "fake"; },
         appVersionInfo: sinon.spy()
       };
+
+      store = new loop.store.ConversationStore({}, {
+        dispatcher: dispatcher,
+        client: {},
+        sdkDriver: {}
+      });
     });
 
     afterEach(function() {
       delete navigator.mozLoop;
     });
 
     it("should render the CallFailedView when the call state is 'terminated'",
       function() {
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -138,17 +138,18 @@ describe("loop.conversation", function()
       oldTitle = document.title;
       client = new loop.Client();
       conversation = new loop.shared.models.ConversationModel({}, {
         sdk: {}
       });
       dispatcher = new loop.Dispatcher();
       store = new loop.store.ConversationStore({}, {
         client: client,
-        dispatcher: dispatcher
+        dispatcher: dispatcher,
+        sdkDriver: {}
       });
     });
 
     afterEach(function() {
       ccView = undefined;
       document.title = oldTitle;
     });
 
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -37,16 +37,17 @@
   <script src="../../content/shared/js/conversationStore.js"></script>
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
+  <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../content/js/client.js"></script>
   <script src="../../content/js/conversationViews.js"></script>
   <script src="../../content/js/conversation.js"></script>
   <script type="text/javascript;version=1.8" src="../../content/js/contacts.js"></script>
   <script src="../../content/js/panel.js"></script>
 
   <!-- Test scripts -->
   <script src="client_test.js"></script>
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -5,18 +5,19 @@ var expect = chai.expect;
 
 describe("loop.ConversationStore", function () {
   "use strict";
 
   var CALL_STATES = loop.store.CALL_STATES;
   var WS_STATES = loop.store.WS_STATES;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
-  var sandbox, dispatcher, client, store, fakeSessionData;
+  var sandbox, dispatcher, client, store, fakeSessionData, sdkDriver;
   var connectPromise, resolveConnectPromise, rejectConnectPromise;
+  var wsCancelSpy, wsCloseSpy, wsMediaUpSpy, fakeWebsocket;
 
   function checkFailures(done, f) {
     try {
       f();
       done();
     } catch (err) {
       done(err);
     }
@@ -24,19 +25,35 @@ describe("loop.ConversationStore", funct
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     dispatcher = new loop.Dispatcher();
     client = {
       setupOutgoingCall: sinon.stub()
     };
+    sdkDriver = {
+      connectSession: sinon.stub(),
+      disconnectSession: sinon.stub()
+    };
+
+    wsCancelSpy = sinon.spy();
+    wsCloseSpy = sinon.spy();
+    wsMediaUpSpy = sinon.spy();
+
+    fakeWebsocket = {
+      cancel: wsCancelSpy,
+      close: wsCloseSpy,
+      mediaUp: wsMediaUpSpy
+    };
+
     store = new loop.store.ConversationStore({}, {
       client: client,
-      dispatcher: dispatcher
+      dispatcher: dispatcher,
+      sdkDriver: sdkDriver
     });
     fakeSessionData = {
       apiKey: "fakeKey",
       callId: "142536",
       sessionId: "321456",
       sessionToken: "341256",
       websocketToken: "543216",
       progressURL: "fakeURL"
@@ -58,28 +75,61 @@ describe("loop.ConversationStore", funct
 
   afterEach(function() {
     sandbox.restore();
   });
 
   describe("#initialize", function() {
     it("should throw an error if the dispatcher is missing", function() {
       expect(function() {
-        new loop.store.ConversationStore({}, {client: client});
+        new loop.store.ConversationStore({}, {
+          client: client,
+          sdkDriver: sdkDriver
+        });
       }).to.Throw(/dispatcher/);
     });
 
     it("should throw an error if the client is missing", function() {
       expect(function() {
-        new loop.store.ConversationStore({}, {dispatcher: dispatcher});
+        new loop.store.ConversationStore({}, {
+          dispatcher: dispatcher,
+          sdkDriver: sdkDriver
+        });
       }).to.Throw(/client/);
     });
+
+    it("should throw an error if the sdkDriver is missing", function() {
+      expect(function() {
+        new loop.store.ConversationStore({}, {
+          client: client,
+          dispatcher: dispatcher
+        });
+      }).to.Throw(/sdkDriver/);
+    });
   });
 
   describe("#connectionFailure", function() {
+    beforeEach(function() {
+      store._websocket = fakeWebsocket;
+    });
+
+    it("should disconnect the session", function() {
+      dispatcher.dispatch(
+        new sharedActions.ConnectionFailure({reason: "fake"}));
+
+      sinon.assert.calledOnce(sdkDriver.disconnectSession);
+    });
+
+    it("should ensure the websocket is closed", function() {
+      dispatcher.dispatch(
+        new sharedActions.ConnectionFailure({reason: "fake"}));
+
+      sinon.assert.calledOnce(wsCloseSpy);
+    });
+
     it("should set the state to 'terminated'", function() {
       store.set({callState: CALL_STATES.ALERTING});
 
       dispatcher.dispatch(
         new sharedActions.ConnectionFailure({reason: "fake"}));
 
       expect(store.get("callState")).eql(CALL_STATES.TERMINATED);
       expect(store.get("callStateReason")).eql("fake");
@@ -114,45 +164,39 @@ describe("loop.ConversationStore", funct
         dispatcher.dispatch(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.ALERTING}));
 
         expect(store.get("callState")).eql(CALL_STATES.ALERTING);
       });
     });
 
     describe("progress: connecting", function() {
-      it("should change the state to 'ongoing'", function() {
+      beforeEach(function() {
         store.set({callState: CALL_STATES.ALERTING});
+      });
 
+      it("should change the state to 'ongoing'", function() {
         dispatcher.dispatch(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
 
         expect(store.get("callState")).eql(CALL_STATES.ONGOING);
       });
-    });
 
-    describe("progress: half-connected", function() {
-      it("should change the state to 'ongoing'", function() {
-        store.set({callState: CALL_STATES.ALERTING});
+      it("should connect the session", function() {
+        store.set(fakeSessionData);
 
         dispatcher.dispatch(
-          new sharedActions.ConnectionProgress({wsState: WS_STATES.HALF_CONNECTED}));
-
-        expect(store.get("callState")).eql(CALL_STATES.ONGOING);
-      });
-    });
+          new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
 
-    describe("progress: connecting", function() {
-      it("should change the state to 'ongoing'", function() {
-        store.set({callState: CALL_STATES.ALERTING});
-
-        dispatcher.dispatch(
-          new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTED}));
-
-        expect(store.get("callState")).eql(CALL_STATES.ONGOING);
+        sinon.assert.calledOnce(sdkDriver.connectSession);
+        sinon.assert.calledWithExactly(sdkDriver.connectSession, {
+          apiKey: "fakeKey",
+          sessionId: "321456",
+          sessionToken: "341256"
+        });
       });
     });
   });
 
   describe("#gatherCallData", function() {
     beforeEach(function() {
       store.set({callState: CALL_STATES.INIT});
     });
@@ -327,16 +371,22 @@ describe("loop.ConversationStore", funct
 
       store._websocket = {
         mediaFail: wsMediaFailSpy,
         close: wsCloseSpy
       };
       store.set({callState: CALL_STATES.ONGOING});
     });
 
+    it("should disconnect the session", function() {
+      dispatcher.dispatch(new sharedActions.HangupCall());
+
+      sinon.assert.calledOnce(sdkDriver.disconnectSession);
+    });
+
     it("should send a media-fail message to the websocket if it is open", function() {
       dispatcher.dispatch(new sharedActions.HangupCall());
 
       sinon.assert.calledOnce(wsMediaFailSpy);
     });
 
     it("should ensure the websocket is closed", function() {
       dispatcher.dispatch(new sharedActions.HangupCall());
@@ -346,56 +396,87 @@ describe("loop.ConversationStore", funct
 
     it("should set the callState to finished", function() {
       dispatcher.dispatch(new sharedActions.HangupCall());
 
       expect(store.get("callState")).eql(CALL_STATES.FINISHED);
     });
   });
 
+  describe("#peerHungupCall", function() {
+    var wsMediaFailSpy, wsCloseSpy;
+    beforeEach(function() {
+      wsMediaFailSpy = sinon.spy();
+      wsCloseSpy = sinon.spy();
+
+      store._websocket = {
+        mediaFail: wsMediaFailSpy,
+        close: wsCloseSpy
+      };
+      store.set({callState: CALL_STATES.ONGOING});
+    });
+
+    it("should disconnect the session", function() {
+      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+
+      sinon.assert.calledOnce(sdkDriver.disconnectSession);
+    });
+
+    it("should ensure the websocket is closed", function() {
+      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+
+      sinon.assert.calledOnce(wsCloseSpy);
+    });
+
+    it("should set the callState to finished", function() {
+      dispatcher.dispatch(new sharedActions.PeerHungupCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.FINISHED);
+    });
+  });
+
   describe("#cancelCall", function() {
+    beforeEach(function() {
+      store._websocket = fakeWebsocket;
+
+      store.set({callState: CALL_STATES.CONNECTING});
+    });
+
+    it("should disconnect the session", function() {
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      sinon.assert.calledOnce(sdkDriver.disconnectSession);
+    });
+
+    it("should send a cancel message to the websocket if it is open", function() {
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      sinon.assert.calledOnce(wsCancelSpy);
+    });
+
+    it("should ensure the websocket is closed", function() {
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      sinon.assert.calledOnce(wsCloseSpy);
+    });
+
+    it("should set the state to close if the call is connecting", function() {
+      dispatcher.dispatch(new sharedActions.CancelCall());
+
+      expect(store.get("callState")).eql(CALL_STATES.CLOSE);
+    });
+
     it("should set the state to close if the call has terminated already", function() {
       store.set({callState: CALL_STATES.TERMINATED});
 
       dispatcher.dispatch(new sharedActions.CancelCall());
 
       expect(store.get("callState")).eql(CALL_STATES.CLOSE);
     });
 
-    describe("whilst connecting", function() {
-      var wsCancelSpy, wsCloseSpy;
-      beforeEach(function() {
-        wsCancelSpy = sinon.spy();
-        wsCloseSpy = sinon.spy();
-
-        store._websocket = {
-          cancel: wsCancelSpy,
-          close: wsCloseSpy
-        };
-        store.set({callState: CALL_STATES.CONNECTING});
-      });
-
-      it("should send a cancel message to the websocket if it is open", function() {
-        dispatcher.dispatch(new sharedActions.CancelCall());
-
-        sinon.assert.calledOnce(wsCancelSpy);
-      });
-
-      it("should ensure the websocket is closed", function() {
-        dispatcher.dispatch(new sharedActions.CancelCall());
-
-        sinon.assert.calledOnce(wsCloseSpy);
-      });
-
-      it("should set the state to close if the call is connecting", function() {
-        dispatcher.dispatch(new sharedActions.CancelCall());
-
-        expect(store.get("callState")).eql(CALL_STATES.CLOSE);
-      });
-    });
   });
 
   describe("#retryCall", function() {
     it("should set the state to gather", function() {
       store.set({callState: CALL_STATES.TERMINATED});
 
       dispatcher.dispatch(new sharedActions.RetryCall());
 
@@ -413,16 +494,50 @@ describe("loop.ConversationStore", funct
       dispatcher.dispatch(new sharedActions.RetryCall());
 
       sinon.assert.calledOnce(client.setupOutgoingCall);
       sinon.assert.calledWith(client.setupOutgoingCall,
         ["fake"], sharedUtils.CALL_TYPES.AUDIO_VIDEO);
     });
   });
 
+  describe("#mediaConnected", function() {
+    it("should send mediaUp via the websocket", function() {
+      store._websocket = fakeWebsocket;
+
+      dispatcher.dispatch(new sharedActions.MediaConnected());
+
+      sinon.assert.calledOnce(wsMediaUpSpy);
+    });
+  });
+
+  describe("#setMute", function() {
+    it("should save the mute state for the audio stream", function() {
+      store.set({"audioMuted": false});
+
+      dispatcher.dispatch(new sharedActions.SetMute({
+        type: "audio",
+        enabled: true
+      }));
+
+      expect(store.get("audioMuted")).eql(true);
+    });
+
+    it("should save the mute state for the video stream", function() {
+      store.set({"videoMuted": true});
+
+      dispatcher.dispatch(new sharedActions.SetMute({
+        type: "video",
+        enabled: false
+      }));
+
+      expect(store.get("videoMuted")).eql(false);
+    });
+  });
+
   describe("Events", function() {
     describe("Websocket progress", function() {
       beforeEach(function() {
         dispatcher.dispatch(
           new sharedActions.ConnectCall({sessionData: fakeSessionData}));
 
         sandbox.stub(dispatcher, "dispatch");
       });
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -37,27 +37,29 @@
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
+  <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../content/shared/js/conversationStore.js"></script>
 
   <!-- Test scripts -->
   <script src="models_test.js"></script>
   <script src="mixins_test.js"></script>
   <script src="utils_test.js"></script>
   <script src="views_test.js"></script>
   <script src="websocket_test.js"></script>
   <script src="feedbackApiClient_test.js"></script>
   <script src="validate_test.js"></script>
   <script src="dispatcher_test.js"></script>
   <script src="conversationStore_test.js"></script>
+  <script src="otSdkDriver_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
   </script>
 </body>
 </html>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -0,0 +1,300 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var expect = chai.expect;
+
+describe("loop.OTSdkDriver", function () {
+  "use strict";
+
+  var sharedActions = loop.shared.actions;
+
+  var sandbox;
+  var dispatcher, driver, publisher, sdk, session, sessionData;
+  var fakeLocalElement, fakeRemoteElement, publisherConfig;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+
+    fakeLocalElement = {fake: 1};
+    fakeRemoteElement = {fake: 2};
+    publisherConfig = {
+      fake: "config"
+    };
+    sessionData = {
+      apiKey: "1234567890",
+      sessionId: "3216549870",
+      sessionToken: "1357924680"
+    };
+
+    dispatcher = new loop.Dispatcher();
+    session = _.extend({
+      connect: sinon.stub(),
+      disconnect: sinon.stub(),
+      publish: sinon.stub(),
+      subscribe: sinon.stub()
+    }, Backbone.Events);
+
+    publisher = {
+      destroy: sinon.stub(),
+      publishAudio: sinon.stub(),
+      publishVideo: sinon.stub()
+    };
+
+    sdk = {
+      initPublisher: sinon.stub(),
+      initSession: sinon.stub().returns(session)
+    };
+
+    driver = new loop.OTSdkDriver({
+      dispatcher: dispatcher,
+      sdk: sdk
+    });
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("Constructor", function() {
+    it("should throw an error if the dispatcher is missing", function() {
+      expect(function() {
+        new loop.OTSdkDriver({sdk: sdk});
+      }).to.Throw(/dispatcher/);
+    });
+
+    it("should throw an error if the sdk is missing", function() {
+      expect(function() {
+        new loop.OTSdkDriver({dispatcher: dispatcher});
+      }).to.Throw(/sdk/);
+    });
+  });
+
+  describe("#setupStreamElements", function() {
+    it("should call initPublisher", function() {
+      dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        getLocalElementFunc: function() {return fakeLocalElement;},
+        getRemoteElementFunc: function() {return fakeRemoteElement;},
+        publisherConfig: publisherConfig
+      }));
+
+      sinon.assert.calledOnce(sdk.initPublisher);
+      sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig);
+    });
+
+    describe("On Publisher Complete", function() {
+      it("should publish the stream if the connection is ready", function() {
+        sdk.initPublisher.callsArgWith(2, null);
+
+        driver.session = session;
+        driver._sessionConnected = true;
+
+        dispatcher.dispatch(new sharedActions.SetupStreamElements({
+          getLocalElementFunc: function() {return fakeLocalElement;},
+          getRemoteElementFunc: function() {return fakeRemoteElement;},
+          publisherConfig: publisherConfig
+        }));
+
+        sinon.assert.calledOnce(session.publish);
+      });
+
+      it("should dispatch connectionFailure if connecting failed", function() {
+        sdk.initPublisher.callsArgWith(2, new Error("Failure"));
+
+        // Special stub, as we want to use the dispatcher, but also know that
+        // we've been called correctly for the second dispatch.
+        var dispatchStub = (function() {
+          var originalDispatch = dispatcher.dispatch.bind(dispatcher);
+          return sandbox.stub(dispatcher, "dispatch", function(action) {
+            originalDispatch(action);
+          });
+        }());
+
+        driver.session = session;
+        driver._sessionConnected = true;
+
+        dispatcher.dispatch(new sharedActions.SetupStreamElements({
+          getLocalElementFunc: function() {return fakeLocalElement;},
+          getRemoteElementFunc: function() {return fakeRemoteElement;},
+          publisherConfig: publisherConfig
+        }));
+
+        sinon.assert.called(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", "noMedia"));
+      });
+    });
+  });
+
+  describe("#setMute", function() {
+    beforeEach(function() {
+      sdk.initPublisher.returns(publisher);
+
+      dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        getLocalElementFunc: function() {return fakeLocalElement;},
+        getRemoteElementFunc: function() {return fakeRemoteElement;},
+        publisherConfig: publisherConfig
+      }));
+    });
+
+    it("should publishAudio with the correct enabled value", function() {
+      dispatcher.dispatch(new sharedActions.SetMute({
+        type: "audio",
+        enabled: false
+      }));
+
+      sinon.assert.calledOnce(publisher.publishAudio);
+      sinon.assert.calledWithExactly(publisher.publishAudio, false);
+    });
+
+    it("should publishVideo with the correct enabled value", function() {
+      dispatcher.dispatch(new sharedActions.SetMute({
+        type: "video",
+        enabled: true
+      }));
+
+      sinon.assert.calledOnce(publisher.publishVideo);
+      sinon.assert.calledWithExactly(publisher.publishVideo, true);
+    });
+  });
+
+  describe("#connectSession", function() {
+    it("should initialise a new session", function() {
+      driver.connectSession(sessionData);
+
+      sinon.assert.calledOnce(sdk.initSession);
+      sinon.assert.calledWithExactly(sdk.initSession, "3216549870");
+    });
+
+    it("should connect the session", function () {
+      driver.connectSession(sessionData);
+
+      sinon.assert.calledOnce(session.connect);
+      sinon.assert.calledWith(session.connect, "1234567890", "1357924680");
+    });
+
+    describe("On connection complete", function() {
+      it("should publish the stream if the publisher is ready", function() {
+        driver._publisherReady = true;
+        session.connect.callsArg(2);
+
+        driver.connectSession(sessionData);
+
+        sinon.assert.calledOnce(session.publish);
+      });
+
+      it("should dispatch connectionFailure if connecting failed", function() {
+        session.connect.callsArgWith(2, new Error("Failure"));
+        sandbox.stub(dispatcher, "dispatch");
+
+        driver.connectSession(sessionData);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", "couldNotConnect"));
+      });
+    });
+  });
+
+  describe("#disconnectionSession", function() {
+    it("should disconnect the session", function() {
+      driver.session = session;
+
+      driver.disconnectSession();
+
+      sinon.assert.calledOnce(session.disconnect);
+    });
+
+    it("should destroy the publisher", function() {
+      driver.publisher = publisher;
+
+      driver.disconnectSession();
+
+      sinon.assert.calledOnce(publisher.destroy);
+    });
+  });
+
+  describe("Events", function() {
+    beforeEach(function() {
+      driver.connectSession(sessionData);
+
+      dispatcher.dispatch(new sharedActions.SetupStreamElements({
+        getLocalElementFunc: function() {return fakeLocalElement;},
+        getRemoteElementFunc: function() {return fakeRemoteElement;},
+        publisherConfig: publisherConfig
+      }));
+
+      sandbox.stub(dispatcher, "dispatch");
+    });
+
+    describe("connectionDestroyed", function() {
+      it("should dispatch a peerHungupCall action if the client disconnected", function() {
+        session.trigger("connectionDestroyed", {
+          reason: "clientDisconnected"
+        });
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "peerHungupCall"));
+      });
+
+      it("should dispatch a connectionFailure action if the connection failed", function() {
+        session.trigger("connectionDestroyed", {
+          reason: "networkDisconnected"
+        });
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "connectionFailure"));
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("reason", "peerNetworkDisconnected"));
+      });
+    });
+
+    describe("sessionDisconnected", function() {
+      it("should dispatch a connectionFailure action if the session was disconnected",
+        function() {
+          session.trigger("sessionDisconnected", {
+            reason: "networkDisconnected"
+          });
+
+          sinon.assert.calledOnce(dispatcher.dispatch);
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("name", "connectionFailure"));
+          sinon.assert.calledWithMatch(dispatcher.dispatch,
+            sinon.match.hasOwn("reason", "networkDisconnected"));
+        });
+    });
+
+    describe("streamCreated", function() {
+      var fakeStream;
+
+      beforeEach(function() {
+        fakeStream = {
+          fakeStream: 3
+        };
+      });
+
+      it("should subscribe to the stream", function() {
+        session.trigger("streamCreated", {stream: fakeStream});
+
+        sinon.assert.calledOnce(session.subscribe);
+        sinon.assert.calledWithExactly(session.subscribe,
+          fakeStream, fakeRemoteElement, publisherConfig);
+      });
+
+      it("should dispach a mediaConnected action if both streams are up", function() {
+        driver._publishedLocalStream = true;
+
+        session.trigger("streamCreated", {stream: fakeStream});
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithMatch(dispatcher.dispatch,
+          sinon.match.hasOwn("name", "mediaConnected"));
+      });
+    });
+  });
+});