--- 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
@@ -535,19 +535,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
@@ -535,19 +535,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"));
+ });
+ });
+ });
+});