/* 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/. */ var loop = loop || {}; loop.store = loop.store || {}; (function() { "use strict"; var sharedActions = loop.shared.actions; var CALL_TYPES = loop.shared.utils.CALL_TYPES; var REST_ERRNOS = loop.shared.utils.REST_ERRNOS; var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS; var WEBSOCKET_REASONS = loop.shared.utils.WEBSOCKET_REASONS; /** * Websocket states taken from: * https://docs.services.mozilla.com/loop/apis.html#call-progress-state-change-progress */ var WS_STATES = loop.store.WS_STATES = { // The call is starting, and the remote party is not yet being alerted. INIT: "init", // The called party is being alerted. ALERTING: "alerting", // The call is no longer being set up and has been aborted for some reason. TERMINATED: "terminated", // The called party has indicated that he has answered the call, // but the media is not yet confirmed. CONNECTING: "connecting", // One of the two parties has indicated successful media set up, // but the other has not yet. HALF_CONNECTED: "half-connected", // Both endpoints have reported successfully establishing media. CONNECTED: "connected" }; var CALL_STATES = loop.store.CALL_STATES = { // The initial state of the view. INIT: "cs-init", // The store is gathering the call data from the server. GATHER: "cs-gather", // The initial data has been gathered, the websocket is connecting, or has // connected, and waiting for the other side to connect to the server. CONNECTING: "cs-connecting", // The websocket has received information that we're now alerting // the peer. ALERTING: "cs-alerting", // The call is ongoing. ONGOING: "cs-ongoing", // The call ended successfully. FINISHED: "cs-finished", // The user has finished with the window. CLOSE: "cs-close", // The call was terminated due to an issue during connection. TERMINATED: "cs-terminated" }; /** * Conversation store. * * @param {loop.Dispatcher} dispatcher The dispatcher for dispatching actions * and registering to consume actions. * @param {Object} options Options object: * - {client} client The client object. * - {mozLoop} mozLoop The MozLoop API object. * - {loop.OTSdkDriver} loop.OTSdkDriver The SDK Driver */ loop.store.ConversationStore = loop.store.createStore({ // Further actions are registered in setupWindowData when // we know what window type this is. actions: [ "setupWindowData" ], getInitialStoreState: function() { return { // The id of the window. Currently used for getting the window id. windowId: undefined, // The current state of the call callState: CALL_STATES.INIT, // The reason if a call was terminated callStateReason: undefined, // True if the call is outgoing, false if not, undefined if unknown outgoing: undefined, // The contact being called for outgoing calls contact: undefined, // The call type for the call. // XXX Don't hard-code, this comes from the data in bug 1072323 callType: CALL_TYPES.AUDIO_VIDEO, // A link for emailing once obtained from the server emailLink: undefined, // Call Connection information // The call id from the loop-server callId: undefined, // The caller id of the contacting side callerId: undefined, // True if media has been connected both-ways. mediaConnected: false, // 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, // If the local audio is muted audioMuted: false, // If the local video is muted videoMuted: false, remoteVideoEnabled: false }; }, /** * Handles initialisation of the store. * * @param {Object} options Options object. */ initialize: function(options) { options = options || {}; if (!options.client) { throw new Error("Missing option client"); } if (!options.sdkDriver) { throw new Error("Missing option sdkDriver"); } if (!options.mozLoop) { throw new Error("Missing option mozLoop"); } this.client = options.client; this.sdkDriver = options.sdkDriver; this.mozLoop = options.mozLoop; this._isDesktop = options.isDesktop || false; }, /** * Handles the connection failure action, setting the state to * terminated. * * @param {sharedActions.ConnectionFailure} actionData The action data. */ connectionFailure: function(actionData) { this._endSession(); this.setStoreState({ callState: CALL_STATES.TERMINATED, callStateReason: actionData.reason }); }, /** * Handles the connection progress action, setting the next state * appropriately. * * @param {sharedActions.ConnectionProgress} actionData The action data. */ connectionProgress: function(actionData) { var state = this.getStoreState(); switch(actionData.wsState) { case WS_STATES.INIT: { if (state.callState === CALL_STATES.GATHER) { this.setStoreState({callState: CALL_STATES.CONNECTING}); } break; } case WS_STATES.ALERTING: { this.setStoreState({callState: CALL_STATES.ALERTING}); break; } case WS_STATES.CONNECTING: { if (state.outgoing) { // We can start the connection right away if we're the outgoing // call because this is the only way we know the other peer has // accepted. this._startCallConnection(); } else if (state.callState !== CALL_STATES.ONGOING) { console.error("Websocket unexpectedly changed to next state whilst waiting for call acceptance."); // Abort and close the window. this.declineCall(new sharedActions.DeclineCall({blockCaller: false})); } break; } case WS_STATES.HALF_CONNECTED: case WS_STATES.CONNECTED: { if (this.getStoreState("callState") !== CALL_STATES.ONGOING) { console.error("Unexpected websocket state received in wrong callState"); this.setStoreState({callState: CALL_STATES.ONGOING}); } break; } default: { console.error("Unexpected websocket state passed to connectionProgress:", actionData.wsState); } } }, setupWindowData: function(actionData) { var windowType = actionData.type; if (windowType !== "outgoing" && windowType !== "incoming") { // Not for this store, don't do anything. return; } this.dispatcher.register(this, [ "connectionFailure", "connectionProgress", "acceptCall", "declineCall", "connectCall", "hangupCall", "remotePeerDisconnected", "cancelCall", "retryCall", "mediaConnected", "setMute", "fetchRoomEmailLink", "mediaStreamCreated", "mediaStreamDestroyed", "remoteVideoStatus", "windowUnload" ]); this.setStoreState({ apiKey: actionData.apiKey, callerId: actionData.callerId, callId: actionData.callId, callState: CALL_STATES.GATHER, callType: actionData.callType, contact: actionData.contact, outgoing: windowType === "outgoing", progressURL: actionData.progressURL, sessionId: actionData.sessionId, sessionToken: actionData.sessionToken, videoMuted: actionData.callType === CALL_TYPES.AUDIO_ONLY, websocketToken: actionData.websocketToken, windowId: actionData.windowId }); if (this.getStoreState("outgoing")) { this._setupOutgoingCall(); } else { this._setupIncomingCall(); } }, /** * Handles starting a call connection - connecting the session on the * sdk, and setting the state appropriately. */ _startCallConnection: function() { var state = this.getStoreState(); this.sdkDriver.connectSession({ apiKey: state.apiKey, sessionId: state.sessionId, sessionToken: state.sessionToken, sendTwoWayMediaTelemetry: state.outgoing // only one side of the call }); this.mozLoop.addConversationContext( state.windowId, state.sessionId, state.callId); this.setStoreState({callState: CALL_STATES.ONGOING}); }, /** * Accepts an incoming call. * * @param {sharedActions.AcceptCall} actionData */ acceptCall: function(actionData) { if (this.getStoreState("outgoing")) { console.error("Received AcceptCall action in outgoing call state"); return; } this.setStoreState({ callType: actionData.callType, videoMuted: actionData.callType === CALL_TYPES.AUDIO_ONLY }); this._websocket.accept(); this._startCallConnection(); }, /** * Declines an incoming call. * * @param {sharedActions.DeclineCall} actionData */ declineCall: function(actionData) { if (actionData.blockCaller) { this.mozLoop.calls.blockDirectCaller(this.getStoreState("callerId"), function(err) { // XXX The conversation window will be closed when this cb is triggered // figure out if there is a better way to report the error to the user // (bug 1103150). console.log(err.fileName + ":" + err.lineNumber + ": " + err.message); }); } this._websocket.decline(); // Now we've declined, end the session and close the window. this._endSession(); this.setStoreState({callState: CALL_STATES.CLOSE}); }, /** * Handles the connect call action, this saves the appropriate * data and starts the connection for the websocket to notify the * server of progress. * * @param {sharedActions.ConnectCall} actionData The action data. */ connectCall: function(actionData) { this.setStoreState(actionData.sessionData); this._connectWebSocket(); }, /** * Hangs up an ongoing call. */ hangupCall: function() { if (this._websocket) { // Let the server know the user has hung up. this._websocket.mediaFail(); } this._endSession(); this.setStoreState({callState: CALL_STATES.FINISHED}); }, /** * The remote peer disconnected from the session. * * @param {sharedActions.RemotePeerDisconnected} actionData */ remotePeerDisconnected: function(actionData) { this._endSession(); // If the peer hungup, we end normally, otherwise // we treat this as a call failure. if (actionData.peerHungup) { this.setStoreState({callState: CALL_STATES.FINISHED}); } else { this.setStoreState({ callState: CALL_STATES.TERMINATED, callStateReason: "peerNetworkDisconnected" }); } }, /** * Cancels a call. This can happen for incoming or outgoing calls. * Although the user doesn't "cancel" an incoming call, it may be that * the remote peer cancelled theirs before the incoming call was accepted. */ cancelCall: function() { if (this.getStoreState("outgoing")) { var callState = this.getStoreState("callState"); if (this._websocket && (callState === CALL_STATES.CONNECTING || callState === CALL_STATES.ALERTING)) { // Let the server know the user has hung up. this._websocket.cancel(); } } this._endSession(); this.setStoreState({callState: CALL_STATES.CLOSE}); }, /** * Retries a call */ retryCall: function() { var callState = this.getStoreState("callState"); if (callState !== CALL_STATES.TERMINATED) { console.error("Unexpected retry in state", callState); return; } this.setStoreState({callState: CALL_STATES.GATHER}); if (this.getStoreState("outgoing")) { this._setupOutgoingCall(); } }, /** * Notifies that all media is now connected */ mediaConnected: function() { this._websocket.mediaUp(); this.setStoreState({mediaConnected: true}); }, /** * Records the mute state for the stream. * * @param {sharedActions.setMute} actionData The mute state for the stream type. */ setMute: function(actionData) { var newState = {}; newState[actionData.type + "Muted"] = !actionData.enabled; this.setStoreState(newState); }, /** * Fetches a new room URL intended to be sent over email when a contact * can't be reached. */ fetchRoomEmailLink: function(actionData) { this.mozLoop.rooms.create({ decryptedContext: { roomName: actionData.roomName }, maxSize: loop.store.MAX_ROOM_CREATION_SIZE, expiresIn: loop.store.DEFAULT_EXPIRES_IN }, function(err, createdRoomData) { var buckets = this.mozLoop.ROOM_CREATE; if (err) { this.trigger("error:emailLink"); this.mozLoop.telemetryAddValue("LOOP_ROOM_CREATE", buckets.CREATE_FAIL); return; } this.setStoreState({"emailLink": createdRoomData.roomUrl}); this.mozLoop.telemetryAddValue("LOOP_ROOM_CREATE", buckets.CREATE_SUCCESS); }.bind(this)); }, /** * Handles a media stream being created. This may be a local or a remote stream. * * @param {sharedActions.MediaStreamCreated} actionData */ mediaStreamCreated: function(actionData) { if (actionData.isLocal) { this.setStoreState({ localVideoEnabled: actionData.hasVideo, localSrcMediaElement: actionData.srcMediaElement }); return; } this.setStoreState({ remoteVideoEnabled: actionData.hasVideo, remoteSrcMediaElement: actionData.srcMediaElement }); }, /** * Handles a media stream being destroyed. This may be a local or a remote stream. * * @param {sharedActions.MediaStreamDestroyed} actionData */ mediaStreamDestroyed: function(actionData) { if (actionData.isLocal) { this.setStoreState({ localSrcMediaElement: null }); return; } this.setStoreState({ remoteSrcMediaElement: null }); }, /** * Handles a remote stream having video enabled or disabled. * * @param {sharedActions.RemoteVideoStatus} actionData */ remoteVideoStatus: function(actionData) { this.setStoreState({ remoteVideoEnabled: actionData.videoEnabled }); }, /** * Called when the window is unloaded, either by code, or by the user * explicitly closing it. Expected to do any necessary housekeeping, such * as shutting down the call cleanly and adding any relevant telemetry data. */ windowUnload: function() { if (!this.getStoreState("outgoing") && this.getStoreState("callState") === CALL_STATES.ALERTING && this._websocket) { this._websocket.decline(); } this._endSession(); }, /** * Sets up an incoming call. All we really need to do here is * to connect the websocket, as we've already got all the information * when the window opened. */ _setupIncomingCall: function() { this._connectWebSocket(); }, /** * Obtains the outgoing call data from the server and handles the * result. */ _setupOutgoingCall: function() { var contactAddresses = []; var contact = this.getStoreState("contact"); this.mozLoop.calls.setCallInProgress(this.getStoreState("windowId")); function appendContactValues(property, strip) { if (contact.hasOwnProperty(property)) { contact[property].forEach(function(item) { if (strip) { contactAddresses.push(item.value .replace(/^(\+)?(.*)$/g, function(m, prefix, number) { return (prefix || "") + number.replace(/[\D]+/g, ""); })); } else { contactAddresses.push(item.value); } }); } } appendContactValues("email"); appendContactValues("tel", true); this.client.setupOutgoingCall(contactAddresses, this.getStoreState("callType"), function(err, result) { if (err) { console.error("Failed to get outgoing call data", err); var failureReason = FAILURE_DETAILS.UNKNOWN; if (err.errno === REST_ERRNOS.USER_UNAVAILABLE) { failureReason = FAILURE_DETAILS.USER_UNAVAILABLE; } this.dispatcher.dispatch( new sharedActions.ConnectionFailure({reason: failureReason})); return; } // Success, dispatch a new action. this.dispatcher.dispatch( new sharedActions.ConnectCall({sessionData: result})); }.bind(this) ); }, /** * Sets up and connects the websocket to the server. The websocket * deals with sending and obtaining status via the server about the * setup of the call. */ _connectWebSocket: function() { this._websocket = new loop.CallConnectionWebSocket({ url: this.getStoreState("progressURL"), callId: this.getStoreState("callId"), websocketToken: this.getStoreState("websocketToken") }); this._websocket.promiseConnect().then( function(progressState) { this.dispatcher.dispatch(new sharedActions.ConnectionProgress({ // This is the websocket call state, i.e. waiting for the // other end to connect to the server. wsState: progressState })); }.bind(this), function(error) { console.error("Websocket failed to connect", error); this.dispatcher.dispatch(new sharedActions.ConnectionFailure({ reason: "websocket-setup" })); }.bind(this) ); this.listenTo(this._websocket, "progress", this._handleWebSocketProgress); }, /** * Ensures the session is ended and the websocket is disconnected. */ _endSession: function(nextState) { this.sdkDriver.disconnectSession(); if (this._websocket) { this.stopListening(this._websocket); // Now close the websocket. this._websocket.close(); delete this._websocket; } this.mozLoop.calls.clearCallInProgress( this.getStoreState("windowId")); }, /** * If we hit any of the termination reasons, and the user hasn't accepted * then it seems reasonable to close the window/abort the incoming call. * * If the user has accepted the call, and something's happened, display * the call failed view. * * https://wiki.mozilla.org/Loop/Architecture/MVP#Termination_Reasons * * For outgoing calls, we treat all terminations as failures. * * @param {Object} progressData The progress data received from the websocket. * @param {String} previousState The previous state the websocket was in. */ _handleWebSocketStateTerminated: function(progressData, previousState) { if (this.getStoreState("outgoing") || (previousState !== WS_STATES.INIT && previousState !== WS_STATES.ALERTING)) { // For outgoing calls we can treat everything as connection failure. // XXX We currently fallback to the websocket reason, but really these should // be fully migrated to use FAILURE_DETAILS, so as not to expose websocket // states outside of this store. Bug 1124384 should help to fix this. var reason = progressData.reason; if (reason === WEBSOCKET_REASONS.REJECT || reason === WEBSOCKET_REASONS.BUSY) { reason = FAILURE_DETAILS.USER_UNAVAILABLE; } this.dispatcher.dispatch(new sharedActions.ConnectionFailure({ reason: reason })); return; } this.dispatcher.dispatch(new sharedActions.CancelCall()); }, /** * Used to handle any progressed received from the websocket. This will * dispatch new actions so that the data can be handled appropriately. * * @param {Object} progressData The progress data received from the websocket. * @param {String} previousState The previous state the websocket was in. */ _handleWebSocketProgress: function(progressData, previousState) { switch(progressData.state) { case WS_STATES.TERMINATED: { this._handleWebSocketStateTerminated(progressData, previousState); break; } default: { this.dispatcher.dispatch(new sharedActions.ConnectionProgress({ wsState: progressData.state })); break; } } } }); })();