/* global $, $iq, Strophe */
import {getLogger} from 'jitsi-meet-logger';
const logger = getLogger(__filename);
import JingleSession from './JingleSession';
const SDPDiffer = require('./SDPDiffer');
const SDPUtil = require('./SDPUtil');
const SDP = require('./SDP');
const async = require('async');
const XMPPEvents = require('../../service/xmpp/XMPPEvents');
const RTCBrowserType = require('../RTC/RTCBrowserType');
const GlobalOnErrorHandler = require('../util/GlobalOnErrorHandler');
const Statistics = require('../statistics/statistics');
import * as JingleSessionState from './JingleSessionState';
/**
* Constant tells how long we're going to wait for IQ response, before timeout
* error is triggered.
* @type {number}
*/
const IQ_TIMEOUT = 10000;
export default class JingleSessionPC extends JingleSession {
/**
* Creates new JingleSessionPC
* @param {string} sid the Jingle Session ID - random string which
* identifies the session
* @param {string} me our JID
* @param {string} peerjid remote peer JID
* @param {Strophe.Connection} connection Strophe XMPP connection instance
* used to send packets.
* @param media_constraints the media constraints object passed to
* createOffer/Answer, as defined by the WebRTC standard
* @param ice_config the ICE servers config object as defined by the WebRTC
* standard.
* @param {object} options a set of config options
* @param {boolean} options.webrtcIceUdpDisable true to block UDP
* candidates.
* @param {boolean} options.webrtcIceTcpDisable true to block TCP
* candidates.
* @param {boolean} options.failICE it's an option used in the tests. Set to
* true to block any real candidates and make the ICE fail.
*
* @constructor
*
* @implements {SignalingLayer}
*/
constructor(sid, me, peerjid, connection,
media_constraints, ice_config, options) {
super(sid, me, peerjid, connection, media_constraints, ice_config);
this.lasticecandidate = false;
this.closed = false;
this.modifyingLocalStreams = false;
/**
* Used to keep state about muted/unmuted video streams
* so we can prevent errant source-add/source-removes
* from happening
*/
this.modifiedSSRCs = {};
/**
* The local ICE username fragment for this session.
*/
this.localUfrag = null;
/**
* The remote ICE username fragment for this session.
*/
this.remoteUfrag = null;
/**
* A map that stores SSRCs of remote streams. And is used only locally
* We store the mapping when jingle is received, and later is used
* onaddstream webrtc event where we have only the ssrc
* FIXME: This map got filled and never cleaned and can grow during
* long conference
* @type {{}} maps SSRC number to jid
*/
this.ssrcOwners = {};
this.webrtcIceUdpDisable = Boolean(options.webrtcIceUdpDisable);
this.webrtcIceTcpDisable = Boolean(options.webrtcIceTcpDisable);
/**
* Flag used to enforce ICE failure through the URL parameter for
* the automatic testing purpose.
* @type {boolean}
*/
this.failICE = Boolean(options.failICE);
this.modificationQueue
= async.queue(this._processQueueTasks.bind(this), 1);
}
doInitialize() {
this.lasticecandidate = false;
// True if reconnect is in progress
this.isreconnect = false;
// Set to true if the connection was ever stable
this.wasstable = false;
// Create new peer connection instance
this.peerconnection
= this.rtc.createPeerConnection(
this,
this.connection.jingle.ice_config,
/* Options */
{
disableSimulcast: this.room.options.disableSimulcast,
disableRtx: this.room.options.disableRtx,
preferH264: this.room.options.preferH264
});
this.peerconnection.onicecandidate = ev => {
if (!ev) {
// There was an incomplete check for ev before which left
// the last line of the function unprotected from a potential
// throw of an exception. Consequently, it may be argued that
// the check is unnecessary. Anyway, I'm leaving it and making
// the check complete.
return;
}
// XXX this is broken, candidate is not parsed.
const candidate = ev.candidate;
if (candidate) {
// Discard candidates of disabled protocols.
let protocol = candidate.protocol;
if (typeof protocol === 'string') {
protocol = protocol.toLowerCase();
if (protocol === 'tcp' || protocol === 'ssltcp') {
if (this.webrtcIceTcpDisable) {
return;
}
} else if (protocol == 'udp') {
if (this.webrtcIceUdpDisable) {
return;
}
}
}
}
this.sendIceCandidate(candidate);
};
// Note there is a change in the spec about closed:
// This value moved into the RTCPeerConnectionState enum in
// the May 13, 2016 draft of the specification, as it reflects the state
// of the RTCPeerConnection, not the signaling connection. You now
// detect a closed connection by checking for connectionState to be
// "closed" instead.
// I suppose at some point this will be moved to onconnectionstatechange
this.peerconnection.onsignalingstatechange = () => {
if (!this.peerconnection) {
return;
}
if (this.peerconnection.signalingState === 'stable') {
this.wasstable = true;
} else if (
(this.peerconnection.signalingState === 'closed'
|| this.peerconnection.connectionState === 'closed')
&& !this.closed) {
this.room.eventEmitter.emit(XMPPEvents.SUSPEND_DETECTED);
}
};
/**
* The oniceconnectionstatechange event handler contains the code to
* execute when the iceconnectionstatechange event, of type Event,
* is received by this RTCPeerConnection. Such an event is sent when
* the value of RTCPeerConnection.iceConnectionState changes.
*/
this.peerconnection.oniceconnectionstatechange = () => {
if (!this.peerconnection) {
return;
}
const now = window.performance.now();
this.room.connectionTimes[
`ice.state.${this.peerconnection.iceConnectionState}`]
= now;
logger.log(
`(TIME) ICE ${this.peerconnection.iceConnectionState}:\t`,
now);
Statistics.analytics.sendEvent(
`ice.${this.peerconnection.iceConnectionState}`,
{value: now});
this.room.eventEmitter.emit(
XMPPEvents.ICE_CONNECTION_STATE_CHANGED,
this.peerconnection.iceConnectionState);
switch (this.peerconnection.iceConnectionState) {
case 'connected':
// Informs interested parties that the connection has been
// restored.
if (this.peerconnection.signalingState === 'stable'
&& this.isreconnect) {
this.room.eventEmitter.emit(
XMPPEvents.CONNECTION_RESTORED);
}
this.isreconnect = false;
break;
case 'disconnected':
if (this.closed) {
break;
}
this.isreconnect = true;
// Informs interested parties that the connection has been
// interrupted.
if (this.wasstable) {
this.room.eventEmitter.emit(
XMPPEvents.CONNECTION_INTERRUPTED);
}
break;
case 'failed':
this.room.eventEmitter.emit(
XMPPEvents.CONNECTION_ICE_FAILED, this.peerconnection);
break;
}
};
this.peerconnection.onnegotiationneeded = () => {
this.room.eventEmitter.emit(XMPPEvents.PEERCONNECTION_READY, this);
};
}
sendIceCandidate(candidate) {
const localSDP = new SDP(this.peerconnection.localDescription.sdp);
if (candidate && !this.lasticecandidate) {
const ice
= SDPUtil.iceparams(
localSDP.media[candidate.sdpMLineIndex], localSDP.session);
const jcand = SDPUtil.candidateToJingle(candidate.candidate);
if (!(ice && jcand)) {
const errorMesssage = 'failed to get ice && jcand';
GlobalOnErrorHandler.callErrorHandler(new Error(errorMesssage));
logger.error(errorMesssage);
return;
}
ice.xmlns = 'urn:xmpp:jingle:transports:ice-udp:1';
if (this.usedrip) {
if (this.drip_container.length === 0) {
// start 20ms callout
setTimeout(() => {
if (this.drip_container.length === 0) {
return;
}
this.sendIceCandidates(this.drip_container);
this.drip_container = [];
}, 20);
}
this.drip_container.push(candidate);
} else {
this.sendIceCandidates([candidate]);
}
} else {
logger.log('sendIceCandidate: last candidate.');
// FIXME: remember to re-think in ICE-restart
this.lasticecandidate = true;
}
}
sendIceCandidates(candidates) {
logger.log('sendIceCandidates', candidates);
const cand = $iq({to: this.peerjid, type: 'set'})
.c('jingle', {xmlns: 'urn:xmpp:jingle:1',
action: 'transport-info',
initiator: this.initiator,
sid: this.sid});
const localSDP = new SDP(this.peerconnection.localDescription.sdp);
for (let mid = 0; mid < localSDP.media.length; mid++) {
const cands = candidates.filter(function(el) {
return el.sdpMLineIndex == mid;
});
const mline
= SDPUtil.parse_mline(localSDP.media[mid].split('\r\n')[0]);
if (cands.length > 0) {
const ice
= SDPUtil.iceparams(localSDP.media[mid], localSDP.session);
ice.xmlns = 'urn:xmpp:jingle:transports:ice-udp:1';
cand.c('content', {
creator: this.initiator == this.localJid
? 'initiator' : 'responder',
name: cands[0].sdpMid ? cands[0].sdpMid : mline.media
}).c('transport', ice);
for (let i = 0; i < cands.length; i++) {
const candidate
= SDPUtil.candidateToJingle(cands[i].candidate);
// Mangle ICE candidate if 'failICE' test option is enabled
if (this.failICE) {
candidate.ip = '1.1.1.1';
}
cand.c('candidate', candidate).up();
}
// add fingerprint
const fingerprint_line
= SDPUtil.find_line(
localSDP.media[mid],
'a=fingerprint:', localSDP.session);
if (fingerprint_line) {
const tmp = SDPUtil.parse_fingerprint(fingerprint_line);
tmp.required = true;
cand.c(
'fingerprint',
{xmlns: 'urn:xmpp:jingle:apps:dtls:0'})
.t(tmp.fingerprint);
delete tmp.fingerprint;
cand.attrs(tmp);
cand.up();
}
cand.up(); // transport
cand.up(); // content
}
}
// might merge last-candidate notification into this, but it is called
// a lot later. See webrtc issue #2340
// logger.log('was this the last candidate', this.lasticecandidate);
this.connection.sendIQ(
cand, null, this.newJingleErrorHandler(cand, function(error) {
GlobalOnErrorHandler.callErrorHandler(
new Error(`Jingle error: ${JSON.stringify(error)}`));
}), IQ_TIMEOUT);
}
readSsrcInfo(contents) {
$(contents).each((idx, content) => {
const ssrcs
= $(content).find(
'description>'
+ 'source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]');
ssrcs.each((idx, ssrcElement) => {
const ssrc = ssrcElement.getAttribute('ssrc');
$(ssrcElement)
.find('>ssrc-info[xmlns="http://jitsi.org/jitmeet"]')
.each((idx, ssrcInfoElement) => {
const owner = ssrcInfoElement.getAttribute('owner');
if (owner && owner.length) {
this.ssrcOwners[ssrc]
= Strophe.getResourceFromJid(owner);
}
}
);
});
});
}
/**
* Makes the underlying TraceablePeerConnection generate new SSRC for
* the recvonly video stream.
* @deprecated
*/
generateRecvonlySsrc() {
if (this.peerconnection) {
this.peerconnection.generateRecvonlySsrc();
} else {
logger.error(
'Unable to generate recvonly SSRC - no peerconnection');
}
}
/**
* Accepts incoming Jingle 'session-initiate' and should send
* 'session-accept' in result.
* @param jingleOffer jQuery selector pointing to the jingle element of
* the offer IQ
* @param success callback called when we accept incoming session
* successfully and receive RESULT packet to 'session-accept' sent.
* @param failure function(error) called if for any reason we fail to accept
* the incoming offer. 'error' argument can be used to log some
* details about the error.
*/
acceptOffer(jingleOffer, success, failure) {
this.state = JingleSessionState.ACTIVE;
this.setOfferCycle(
jingleOffer,
() => {
// FIXME we may not care about RESULT packet for session-accept
// then we should either call 'success' here immediately or
// modify sendSessionAccept method to do that
this.sendSessionAccept(success, failure);
},
failure);
}
/**
* This is a setRemoteDescription/setLocalDescription cycle which starts at
* converting Strophe Jingle IQ into remote offer SDP. Once converted
* setRemoteDescription, createAnswer and setLocalDescription calls follow.
* @param jingleOfferIq jQuery selector pointing to the jingle element of
* the offer IQ
* @param success callback called when sRD/sLD cycle finishes successfully.
* @param failure callback called with an error object as an argument if we
* fail at any point during setRD, createAnswer, setLD.
*/
setOfferCycle(jingleOfferIq, success, failure) {
const workFunction = finishedCallback => {
const newRemoteSdp = this._processNewJingleOfferIq(jingleOfferIq);
this._renegotiate(newRemoteSdp)
.then(() => {
finishedCallback();
}, error => {
logger.error(
`Error renegotiating after setting new remote offer: ${
error}`);
JingleSessionPC.onJingleFatalError(this, error);
finishedCallback(error);
});
};
this.modificationQueue.push(
workFunction,
error => {
error ? failure(error) : success();
});
}
/**
* Although it states "replace transport" it does accept full Jingle offer
* which should contain new ICE transport details.
* @param jingleOfferElem an element Jingle IQ that contains new offer and
* transport info.
* @param success callback called when we succeed to accept new offer.
* @param failure function(error) called when we fail to accept new offer.
*/
replaceTransport(jingleOfferElem, success, failure) {
// We need to first set an offer without the 'data' section to have the
// SCTP stack cleaned up. After that the original offer is set to have
// the SCTP connection established with the new bridge.
this.room.eventEmitter.emit(XMPPEvents.ICE_RESTARTING);
const originalOffer = jingleOfferElem.clone();
jingleOfferElem.find('>content[name=\'data\']').remove();
// First set an offer without the 'data' section
this.setOfferCycle(
jingleOfferElem,
() => {
// Now set the original offer(with the 'data' section)
this.setOfferCycle(
originalOffer,
() => {
const localSDP
= new SDP(this.peerconnection.localDescription.sdp);
this.sendTransportAccept(localSDP, success, failure);
},
failure);
},
failure
);
}
/**
* Sends Jingle 'session-accept' message.
* @param {function()} success callback called when we receive 'RESULT'
* packet for the 'session-accept'
* @param {function(error)} failure called when we receive an error response
* or when the request has timed out.
*/
sendSessionAccept(success, failure) {
// NOTE: since we're just reading from it, we don't need to be within
// the modification queue to access the local description
const localSDP = new SDP(this.peerconnection.localDescription.sdp);
let accept = $iq({to: this.peerjid,
type: 'set'})
.c('jingle', {xmlns: 'urn:xmpp:jingle:1',
action: 'session-accept',
initiator: this.initiator,
responder: this.responder,
sid: this.sid });
if (this.webrtcIceTcpDisable) {
localSDP.removeTcpCandidates = true;
}
if (this.webrtcIceUdpDisable) {
localSDP.removeUdpCandidates = true;
}
if (this.failICE) {
localSDP.failICE = true;
}
localSDP.toJingle(
accept,
this.initiator == this.localJid ? 'initiator' : 'responder',
null);
this.fixJingle(accept);
// Calling tree() to print something useful
accept = accept.tree();
logger.info('Sending session-accept', accept);
this.connection.sendIQ(accept,
success,
this.newJingleErrorHandler(accept, error => {
failure(error);
// 'session-accept' is a critical timeout and we'll
// have to restart
this.room.eventEmitter.emit(XMPPEvents.SESSION_ACCEPT_TIMEOUT);
}),
IQ_TIMEOUT);
// XXX Videobridge needs WebRTC's answer (ICE ufrag and pwd, DTLS
// fingerprint and setup) ASAP in order to start the connection
// establishment.
//
// FIXME Flushing the connection at this point triggers an issue with
// BOSH request handling in Prosody on slow connections.
//
// The problem is that this request will be quite large and it may take
// time before it reaches Prosody. In the meantime Strophe may decide
// to send the next one. And it was observed that a small request with
// 'transport-info' usually follows this one. It does reach Prosody
// before the previous one was completely received. 'rid' on the server
// is increased and Prosody ignores the request with 'session-accept'.
// It will never reach Jicofo and everything in the request table is
// lost. Removing the flush does not guarantee it will never happen, but
// makes it much less likely('transport-info' is bundled with
// 'session-accept' and any immediate requests).
//
// this.connection.flush();
}
/**
* Sends Jingle 'transport-accept' message which is a response to
* 'transport-replace'.
* @param localSDP the 'SDP' object with local session description
* @param success callback called when we receive 'RESULT' packet for
* 'transport-replace'
* @param failure function(error) called when we receive an error response
* or when the request has timed out.
*/
sendTransportAccept(localSDP, success, failure) {
let transportAccept = $iq({to: this.peerjid, type: 'set'})
.c('jingle', {
xmlns: 'urn:xmpp:jingle:1',
action: 'transport-accept',
initiator: this.initiator,
sid: this.sid
});
localSDP.media.forEach((medialines, idx) => {
const mline = SDPUtil.parse_mline(medialines.split('\r\n')[0]);
transportAccept.c('content',
{
creator:
this.initiator == this.localJid
? 'initiator'
: 'responder',
name: mline.media
}
);
localSDP.transportToJingle(idx, transportAccept);
transportAccept.up();
});
// Calling tree() to print something useful to the logger
transportAccept = transportAccept.tree();
logger.info('Sending transport-accept: ', transportAccept);
this.connection.sendIQ(transportAccept,
success,
this.newJingleErrorHandler(transportAccept, failure),
IQ_TIMEOUT);
}
/**
* Sends Jingle 'transport-reject' message which is a response to
* 'transport-replace'.
* @param success callback called when we receive 'RESULT' packet for
* 'transport-replace'
* @param failure function(error) called when we receive an error response
* or when the request has timed out.
*/
sendTransportReject(success, failure) {
// Send 'transport-reject', so that the focus will
// know that we've failed
let transportReject = $iq({to: this.peerjid, type: 'set'})
.c('jingle', {
xmlns: 'urn:xmpp:jingle:1',
action: 'transport-reject',
initiator: this.initiator,
sid: this.sid
});
transportReject = transportReject.tree();
logger.info('Sending \'transport-reject', transportReject);
this.connection.sendIQ(transportReject,
success,
this.newJingleErrorHandler(transportReject, failure),
IQ_TIMEOUT);
}
/**
* @inheritDoc
*/
terminate(reason, text, success, failure) {
this.state = JingleSessionState.ENDED;
let sessionTerminate = $iq({ to: this.peerjid, type: 'set' })
.c('jingle', {
xmlns: 'urn:xmpp:jingle:1',
action: 'session-terminate',
initiator: this.initiator,
sid: this.sid
})
.c('reason')
.c(reason || 'success');
if (text) {
sessionTerminate.up().c('text').t(text);
}
// Calling tree() to print something useful
sessionTerminate = sessionTerminate.tree();
logger.info('Sending session-terminate', sessionTerminate);
this.connection.sendIQ(
sessionTerminate,
success,
this.newJingleErrorHandler(sessionTerminate, failure), IQ_TIMEOUT);
// this should result in 'onTerminated' being called by strope.jingle.js
this.connection.jingle.terminate(this.sid);
}
onTerminated(reasonCondition, reasonText) {
this.state = 'ended';
// Do something with reason and reasonCondition when we start to care
// this.reasonCondition = reasonCondition;
// this.reasonText = reasonText;
logger.info('Session terminated', this, reasonCondition, reasonText);
this.close();
}
/**
* Parse the information from the xml sourceAddElem and translate it
* into sdp lines
* @param {jquery xml element} sourceAddElem the source-add
* element from jingle
* @param {SDP object} currentRemoteSdp the current remote
* sdp (as of this new source-add)
* @returns {list} a list of SDP line strings that should
* be added to the remote SDP
*/
_parseSsrcInfoFromSourceAdd(sourceAddElem, currentRemoteSdp) {
const addSsrcInfo = [];
$(sourceAddElem).each(function(idx, content) {
const name = $(content).attr('name');
let lines = '';
$(content)
.find('ssrc-group[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]')
.each(function() {
const semantics = this.getAttribute('semantics');
const ssrcs = $(this).find('>source').map(function() {
return this.getAttribute('ssrc');
}).get();
if (ssrcs.length) {
lines
+= `a=ssrc-group:${semantics} ${ssrcs.join(' ')
}\r\n`;
}
});
// handles both >source and >description>source
const tmp
= $(content).find(
'source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]');
tmp.each(function() {
const ssrc = $(this).attr('ssrc');
if (currentRemoteSdp.containsSSRC(ssrc)) {
logger.warn(
`Source-add request for existing SSRC: ${ssrc}`);
return;
}
$(this).find('>parameter').each(function() {
lines += `a=ssrc:${ssrc} ${$(this).attr('name')}`;
if ($(this).attr('value') && $(this).attr('value').length) {
lines += `:${$(this).attr('value')}`;
}
lines += '\r\n';
});
});
currentRemoteSdp.media.forEach(function(media, idx) {
if (!SDPUtil.find_line(media, `a=mid:${name}`)) {
return;
}
if (!addSsrcInfo[idx]) {
addSsrcInfo[idx] = '';
}
addSsrcInfo[idx] += lines;
});
});
return addSsrcInfo;
}
/**
* Handles a Jingle source-add message for this Jingle session.
* @param elem An array of Jingle "content" elements.
*/
addRemoteStream(elem) {
// FIXME there is not stop condition for this wait !!!
if (!this.peerconnection.localDescription) {
logger.warn('addSource - localDescription not ready yet');
setTimeout(() => this.addRemoteStream(elem), 200);
return;
}
logger.log('Processing add remote stream');
logger.log(
'ICE connection state: ', this.peerconnection.iceConnectionState);
this.readSsrcInfo(elem);
const workFunction = finishedCallback => {
const sdp = new SDP(this.peerconnection.remoteDescription.sdp);
const mySdp = new SDP(this.peerconnection.localDescription.sdp);
const addSsrcInfo = this._parseSsrcInfoFromSourceAdd(elem, sdp);
const newRemoteSdp = this._processRemoteAddSource(addSsrcInfo);
this._renegotiate(newRemoteSdp)
.then(() => {
logger.info('Remote source-add processed');
const newSdp
= new SDP(this.peerconnection.localDescription.sdp);
logger.log('SDPs', mySdp, newSdp);
this.notifyMySSRCUpdate(mySdp, newSdp);
finishedCallback();
}, error => {
logger.error(
`Error renegotiating after processing remote source-add: ${error}`);
finishedCallback(error);
});
};
this.modificationQueue.push(workFunction);
}
/**
* Handles a Jingle source-remove message for this Jingle session.
* @param elem An array of Jingle "content" elements.
*/
removeRemoteStream(elem) {
// FIXME there is no stop condition for this wait !
if (!this.peerconnection.localDescription) {
logger.warn('removeSource - localDescription not ready yet');
setTimeout(() => this.removeRemoteStream(elem), 200);
return;
}
logger.log('Remove remote stream');
logger.log(
'ICE connection state: ', this.peerconnection.iceConnectionState);
const workFunction = finishedCallback => {
const sdp = new SDP(this.peerconnection.remoteDescription.sdp);
const mySdp = new SDP(this.peerconnection.localDescription.sdp);
const removeSsrcInfo
= this._parseSsrcInfoFromSourceRemove(elem, sdp);
const newRemoteSdp
= this._processRemoteRemoveSource(removeSsrcInfo);
this._renegotiate(newRemoteSdp)
.then(() => {
logger.info('Remote source-remove processed');
const newSdp
= new SDP(this.peerconnection.localDescription.sdp);
logger.log('SDPs', mySdp, newSdp);
this.notifyMySSRCUpdate(mySdp, newSdp);
finishedCallback();
}, error => {
logger.error(
`Error renegotiating after processing remote source-remove: ${error}`);
finishedCallback(error);
});
};
this.modificationQueue.push(workFunction);
}
/**
* The 'task' function will be given a callback it MUST call with either:
* 1) No arguments if it was successful or
* 2) An error argument if there was an error
* If the task wants to process the success or failure of the task, it
* should pass a handler to the .push function, e.g.:
* queue.push(task, (err) => {
* if (err) {
* // error handling
* } else {
* // success handling
* }
* });
*/
_processQueueTasks(task, finishedCallback) {
task(finishedCallback);
}
/**
* Takes in a jingle offer iq, returns the new sdp offer
* @param {jquery xml element} offerIq the incoming offer
* @returns {SDP object} the jingle offer translated to SDP
*/
_processNewJingleOfferIq(offerIq) {
const remoteSdp = new SDP('');
if (this.webrtcIceTcpDisable) {
remoteSdp.removeTcpCandidates = true;
}
if (this.webrtcIceUdpDisable) {
remoteSdp.removeUdpCandidates = true;
}
if (this.failICE) {
remoteSdp.failICE = true;
}
remoteSdp.fromJingle(offerIq);
this.readSsrcInfo($(offerIq).find('>content'));
return remoteSdp;
}
/**
* Remove the given ssrc lines from the current remote sdp
* @param {list} removeSsrcInfo a list of SDP line strings that
* should be removed from the remote SDP
* @returns type {SDP Object} the new remote SDP (after removing the lines
* in removeSsrcInfo
*/
_processRemoteRemoveSource(removeSsrcInfo) {
const remoteSdp = new SDP(this.peerconnection.remoteDescription.sdp);
removeSsrcInfo.forEach(function(lines, idx) {
lines = lines.split('\r\n');
lines.pop(); // remove empty last element;
lines.forEach(function(line) {
remoteSdp.media[idx]
= remoteSdp.media[idx].replace(`${line}\r\n`, '');
});
});
remoteSdp.raw = remoteSdp.session + remoteSdp.media.join('');
return remoteSdp;
}
/**
* Add the given ssrc lines to the current remote sdp
* @param {list} addSsrcInfo a list of SDP line strings that
* should be added to the remote SDP
* @returns type {SDP Object} the new remote SDP (after removing the lines
* in removeSsrcInfo
*/
_processRemoteAddSource(addSsrcInfo) {
const remoteSdp = new SDP(this.peerconnection.remoteDescription.sdp);
addSsrcInfo.forEach(function(lines, idx) {
remoteSdp.media[idx] += lines;
});
remoteSdp.raw = remoteSdp.session + remoteSdp.media.join('');
return remoteSdp;
}
/**
* Do a new o/a flow using the existing remote description
* @param {SDP object} optionalRemoteSdp optional remote sdp
* to use. If not provided, the remote sdp from the
* peerconnection will be used
* @returns {Promise} promise which resolves when the
* o/a flow is complete with no arguments or
* rejects with an error {string}
*/
_renegotiate(optionalRemoteSdp) {
const media_constraints = this.media_constraints;
const remoteSdp
= optionalRemoteSdp
|| new SDP(this.peerconnection.remoteDescription.sdp);
const remoteDescription = new RTCSessionDescription({
type: 'offer',
sdp: remoteSdp.raw
});
// TODO(brian): in the code below there are 2 chunks of code that relate
// to observing changes in local and remove ufrags. since they
// just need to read and observe the SDPs, we should create the
// notion of an SDP observer in TraceablePeerConnection that
// gets notified of all SDP changes. Code like the ufrag
// logic below could listen to that and be separated from
// core flows like this.
return new Promise((resolve, reject) => {
const remoteUfrag = JingleSessionPC.getUfrag(remoteDescription.sdp);
if (remoteUfrag != this.remoteUfrag) {
this.remoteUfrag = remoteUfrag;
this.room.eventEmitter.emit(
XMPPEvents.REMOTE_UFRAG_CHANGED, remoteUfrag);
}
logger.debug('Renegotiate: setting remote description');
this.peerconnection.setRemoteDescription(
remoteDescription,
() => {
if (this.signalingState === 'closed') {
reject('Attempted to setRemoteDescription in state closed');
return;
}
logger.debug('Renegotiate: creating answer');
this.peerconnection.createAnswer(
answer => {
const localUfrag
= JingleSessionPC.getUfrag(answer.sdp);
if (localUfrag != this.localUfrag) {
this.localUfrag = localUfrag;
this.room.eventEmitter.emit(
XMPPEvents.LOCAL_UFRAG_CHANGED,
localUfrag);
}
logger.debug(
'Renegotiate: setting local description');
this.peerconnection.setLocalDescription(
answer,
() => {
resolve();
},
error => {
reject(
`setLocalDescription failed: ${error}`);
}
);
},
error => reject(`createAnswer failed: ${error}`),
media_constraints
);
},
error => {
reject(`setRemoteDescription failed: ${error}`);
}
);
});
}
/**
* Replaces oldStream with newStream and performs a single offer/answer
* cycle after both operations are done. Either oldStream or newStream
* can be null; replacing a valid 'oldStream' with a null 'newStream'
* effectively just removes 'oldStream'
* @param {JitsiLocalTrack|null} oldTrack the current track in use to be
* replaced
* @param {JitsiLocalTrack|null} newTrack the new track to use
* @returns {Promise} which resolves once the replacement is complete
* with no arguments or rejects with an error {string}
*/
replaceTrack(oldTrack, newTrack) {
return new Promise((resolve, reject) => {
const workFunction = finishedCallback => {
const oldSdp
= new SDP(this.peerconnection.localDescription.sdp);
// NOTE the code below assumes that no more than 1 video track
// can be added to the peer connection.
// Transition from no video to video (possibly screen sharing)
if (!oldTrack && newTrack && newTrack.isVideoTrack()) {
// Clearing current primary SSRC will make
// the SdpConsistency generate a new one which will result
// with:
// 1. source-remove for the recvonly
// 2. source-add for the new video stream
this.peerconnection.clearRecvonlySsrc();
// Transition from video to no video
} else if (oldTrack && oldTrack.isVideoTrack() && !newTrack) {
// Clearing current primary SSRC and generating the recvonly
// will result in:
// 1. source-remove for the old video stream
// 2. source-add for the recvonly stream
this.peerconnection.clearRecvonlySsrc();
this.peerconnection.generateRecvonlySsrc();
}
this.removeStreamFromPeerConnection(oldTrack);
this.addStreamToPeerConnection(newTrack);
this._renegotiate()
.then(() => {
const newSdp
= new SDP(this.peerconnection.localDescription.sdp);
this.notifyMySSRCUpdate(oldSdp, newSdp);
finishedCallback();
}, error => {
logger.error(
`replaceTrack renegotiation failed: ${error}`);
finishedCallback(error);
});
};
this.modificationQueue.push(
workFunction,
error => {
if (!error) {
resolve();
} else {
reject(error);
}
}
);
});
}
/**
* Just add the stream to the peerconnection
* @param stream either the low-level webrtc MediaStream or
* a Jitsi mediastream
* NOTE: must be called within a work function being executed
* by the modification queue.
*/
addStreamToPeerConnection(stream, ssrcInfo) {
const actualStream
= stream && stream.getOriginalStream
? stream.getOriginalStream() : stream;
if (this.peerconnection) {
this.peerconnection.addStream(actualStream, ssrcInfo);
}
}
/**
* Parse the information from the xml sourceRemoveElem and translate it
* into sdp lines
* @param {jquery xml element} sourceRemoveElem the source-remove
* element from jingle
* @param {SDP object} currentRemoteSdp the current remote
* sdp (as of this new source-remove)
* @returns {list} a list of SDP line strings that should
* be removed from the remote SDP
*/
_parseSsrcInfoFromSourceRemove(sourceRemoveElem, currentRemoteSdp) {
const removeSsrcInfo = [];
$(sourceRemoveElem).each(function(idx, content) {
const name = $(content).attr('name');
let lines = '';
$(content)
.find('ssrc-group[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]')
.each(function() {
const semantics = this.getAttribute('semantics');
const ssrcs = $(this).find('>source').map(function() {
return this.getAttribute('ssrc');
}).get();
if (ssrcs.length) {
lines
+= `a=ssrc-group:${semantics} ${ssrcs.join(' ')
}\r\n`;
}
});
const ssrcs = [];
// handles both >source and >description>source versions
const tmp
= $(content).find(
'source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]');
tmp.each(function() {
const ssrc = $(this).attr('ssrc');
ssrcs.push(ssrc);
});
currentRemoteSdp.media.forEach(function(media, idx) {
if (!SDPUtil.find_line(media, `a=mid:${name}`)) {
return;
}
if (!removeSsrcInfo[idx]) {
removeSsrcInfo[idx] = '';
}
ssrcs.forEach(function(ssrc) {
const ssrcLines
= SDPUtil.find_lines(media, `a=ssrc:${ssrc}`);
if (ssrcLines.length) {
removeSsrcInfo[idx] += `${ssrcLines.join('\r\n')}\r\n`;
}
});
removeSsrcInfo[idx] += lines;
});
});
return removeSsrcInfo;
}
/**
* Adds stream.
* @param stream new stream that will be added.
* @param callback callback executed after successful stream addition.
* @param errorCallback callback executed if stream addition fail.
* @param ssrcInfo object with information about the SSRCs associated with
* the stream.
* @param dontModifySources {boolean} if true _modifySources won't be
* called.
* Used for streams added before the call start.
* NOTE(brian): there is a decent amount of overlap here with replaceTrack
* that could be re-used...however we can't leverage that currently because
* the extra work we do here must be in the work function context and if we
* then called replaceTrack we'd be adding another task on the queue
* from within a task which would then deadlock. The 'replaceTrack' core
* logic should be moved into a helper function that could be called within
* the 'doReplaceStream' task or the 'doAddStream' task (for example)
*/
addStream(stream, callback, errorCallback, ssrcInfo, dontModifySources) {
const workFunction = finishedCallback => {
if (!this.peerconnection) {
finishedCallback(
'Error: tried adding stream with no active peer connection');
return;
}
this.addStreamToPeerConnection(stream, ssrcInfo);
if (ssrcInfo) {
// available only on video mute/unmute
this.modifiedSSRCs[ssrcInfo.type]
= this.modifiedSSRCs[ssrcInfo.type] || [];
this.modifiedSSRCs[ssrcInfo.type].push(ssrcInfo);
}
if (dontModifySources) {
finishedCallback();
return;
}
const oldSdp = new SDP(this.peerconnection.localDescription.sdp);
this._renegotiate()
.then(() => {
const newSdp
= new SDP(this.peerconnection.localDescription.sdp);
// FIXME objects should not be logged
logger.log('SDPs', oldSdp, newSdp);
this.notifyMySSRCUpdate(oldSdp, newSdp);
finishedCallback();
}, error => {
finishedCallback(error);
});
};
this.modificationQueue.push(
workFunction,
error => {
if (!error) {
callback();
} else {
errorCallback(error);
}
}
);
}
/**
* Generate ssrc info object for a stream with the following properties:
* - ssrcs - Array of the ssrcs associated with the stream.
* - groups - Array of the groups associated with the stream.
*/
generateNewStreamSSRCInfo() {
return this.peerconnection.generateNewStreamSSRCInfo();
}
/**
* Remove stream handling for firefox
* @param stream: webrtc media stream
*/
_handleFirefoxRemoveStream(stream) {
if (!stream) { // There is nothing to be changed
return;
}
let sender = null;
// On Firefox we don't replace MediaStreams as this messes up the
// m-lines (which can't be removed in Plan Unified) and brings a lot
// of complications. Instead, we use the RTPSender and remove just
// the track.
let track = null;
if (stream.getAudioTracks() && stream.getAudioTracks().length) {
track = stream.getAudioTracks()[0];
} else if (stream.getVideoTracks() && stream.getVideoTracks().length) {
track = stream.getVideoTracks()[0];
}
if (!track) {
const msg = 'Cannot remove tracks: no tracks.';
logger.log(msg);
return;
}
// Find the right sender (for audio or video)
this.peerconnection.peerconnection.getSenders().some(function(s) {
if (s.track === track) {
sender = s;
return true;
}
return false;
});
if (sender) {
this.peerconnection.peerconnection.removeTrack(sender);
} else {
logger.log('Cannot remove tracks: no RTPSender.');
}
}
/**
* Just remove the stream from the peerconnection
* @param {JitsiLocalTrack|MediaStream} stream the stream to remove
* NOTE: must be called within a work function being executed
* by the modification queue.
*/
removeStreamFromPeerConnection(stream) {
const actualStream
= stream && stream.getOriginalStream
? stream.getOriginalStream() : stream;
if (!this.peerconnection) {
return;
}
if (RTCBrowserType.getBrowserType()
=== RTCBrowserType.RTC_BROWSER_FIREFOX) {
this._handleFirefoxRemoveStream(actualStream);
} else if (actualStream) {
this.peerconnection.removeStream(actualStream);
}
}
/**
* Remove streams.
* @param stream stream that will be removed.
* @param callback callback executed after successful stream addition.
* @param errorCallback callback executed if stream addition fail.
* @param ssrcInfo object with information about the SSRCs associated with
* the stream.
*/
removeStream(stream, callback, errorCallback, ssrcInfo) {
const workFunction = finishedCallback => {
if (!this.peerconnection) {
finishedCallback();
return;
}
if (RTCBrowserType.getBrowserType()
=== RTCBrowserType.RTC_BROWSER_FIREFOX) {
this._handleFirefoxRemoveStream(stream);
} else if (stream) {
this.removeStreamFromPeerConnection(stream);
}
const oldSdp = new SDP(this.peerconnection.localDescription.sdp);
this._renegotiate()
.then(() => {
const newSdp
= new SDP(this.peerconnection.localDescription.sdp);
if (ssrcInfo) {
this.modifiedSSRCs[ssrcInfo.type]
= this.modifiedSSRCs[ssrcInfo.type] || [];
this.modifiedSSRCs[ssrcInfo.type].push(ssrcInfo);
}
logger.log('SDPs', oldSdp, newSdp);
this.notifyMySSRCUpdate(oldSdp, newSdp);
finishedCallback();
}, error => {
finishedCallback(error);
});
};
this.modificationQueue.push(
workFunction,
error => {
if (!error) {
callback();
} else {
errorCallback(error);
}
}
);
}
/**
* Figures out added/removed ssrcs and send update IQs.
* @param old_sdp SDP object for old description.
* @param new_sdp SDP object for new description.
*/
notifyMySSRCUpdate(old_sdp, new_sdp) {
if (this.state !== JingleSessionState.ACTIVE) {
logger.warn(`Skipping SSRC update in '${this.state} ' state.`);
return;
}
// send source-remove IQ.
let sdpDiffer = new SDPDiffer(new_sdp, old_sdp);
const remove = $iq({to: this.peerjid, type: 'set'})
.c('jingle', {
xmlns: 'urn:xmpp:jingle:1',
action: 'source-remove',
initiator: this.initiator,
sid: this.sid
}
);
sdpDiffer.toJingle(remove);
const removed = this.fixJingle(remove);
if (removed && remove) {
logger.info('Sending source-remove', remove.tree());
this.connection.sendIQ(
remove, null,
this.newJingleErrorHandler(remove, function(error) {
GlobalOnErrorHandler.callErrorHandler(
new Error(`Jingle error: ${JSON.stringify(error)}`));
}), IQ_TIMEOUT);
} else {
logger.log('removal not necessary');
}
// send source-add IQ.
sdpDiffer = new SDPDiffer(old_sdp, new_sdp);
const add = $iq({to: this.peerjid, type: 'set'})
.c('jingle', {
xmlns: 'urn:xmpp:jingle:1',
action: 'source-add',
initiator: this.initiator,
sid: this.sid
}
);
sdpDiffer.toJingle(add);
const added = this.fixJingle(add);
if (added && add) {
logger.info('Sending source-add', add.tree());
this.connection.sendIQ(
add, null, this.newJingleErrorHandler(add, function(error) {
GlobalOnErrorHandler.callErrorHandler(
new Error(`Jingle error: ${JSON.stringify(error)}`));
}), IQ_TIMEOUT);
} else {
logger.log('addition not necessary');
}
}
/**
* Method returns function(errorResponse) which is a callback to be passed
* to Strophe connection.sendIQ method. An 'error' structure is created that
* is passed as 1st argument to given failureCb. The format of this
* structure is as follows:
* {
* code: {XMPP error response code}
* reason: {the name of XMPP error reason element or 'timeout' if the
* request has timed out within IQ_TIMEOUT milliseconds}
* source: {request.tree() that provides original request}
* session: {JingleSessionPC instance on which the error occurred}
* }
* @param request Strophe IQ instance which is the request to be dumped into
* the error structure
* @param failureCb function(error) called when error response was returned
* or when a timeout has occurred.
* @returns {function(this:JingleSessionPC)}
*/
newJingleErrorHandler(request, failureCb) {
return function(errResponse) {
const error = {};
// Get XMPP error code and condition(reason)
const errorElSel = $(errResponse).find('error');
if (errorElSel.length) {
error.code = errorElSel.attr('code');
const errorReasonSel = $(errResponse).find('error :first');
if (errorReasonSel.length) {
error.reason = errorReasonSel[0].tagName;
}
}
if (!errResponse) {
error.reason = 'timeout';
}
error.source = null;
if (request && 'function' == typeof request.tree) {
error.source = request.tree();
}
// Commented to fix JSON.stringify(error) exception for circular
// dependancies when we print that error.
// FIXME: Maybe we can include part of the session object
// error.session = this;
logger.error('Jingle error', error);
if (failureCb) {
failureCb(error);
}
};
}
static onJingleFatalError(session, error) {
if (this.room) {
this.room.eventEmitter.emit(
XMPPEvents.CONFERENCE_SETUP_FAILED, error);
this.room.eventEmitter.emit(
XMPPEvents.JINGLE_FATAL_ERROR, session, error);
}
}
/**
* @inheritDoc
*/
getPeerMediaInfo(owner, mediaType) {
return this.room.getMediaPresenceInfo(owner, mediaType);
}
/**
* @inheritDoc
*/
getSSRCOwner(ssrc) {
return this.ssrcOwners[ssrc];
}
/**
* Returns the ice connection state for the peer connection.
* @returns the ice connection state for the peer connection.
*/
getIceConnectionState() {
return this.peerconnection.iceConnectionState;
}
/**
* Closes the peerconnection.
*/
close() {
this.closed = true;
// do not try to close if already closed.
this.peerconnection
&& ((this.peerconnection.signalingState
&& this.peerconnection.signalingState !== 'closed')
|| (this.peerconnection.connectionState
&& this.peerconnection.connectionState !== 'closed'))
&& this.peerconnection.close();
}
/**
* Fixes the outgoing jingle packets by removing the nodes related to the
* muted/unmuted streams, handles removing of muted stream, etc.
* @param jingle the jingle packet that is going to be sent
* @returns {boolean} true if the jingle has to be sent and false otherwise.
*/
fixJingle(jingle) {
/* eslint-disable no-case-declarations */
const action = $(jingle.nodeTree).find('jingle').attr('action');
switch (action) {
case 'source-add':
case 'session-accept':
this.fixSourceAddJingle(jingle);
break;
case 'source-remove':
this.fixSourceRemoveJingle(jingle);
break;
default:
const errmsg = 'Unknown jingle action!';
GlobalOnErrorHandler.callErrorHandler(errmsg);
logger.error(errmsg);
return false;
}/* eslint-enable no-case-declarations */
const sources
= $(jingle.tree()).find('>jingle>content>description>source');
return sources && sources.length > 0;
}
/**
* Fixes the outgoing jingle packets with action source-add by removing the
* nodes related to the unmuted streams
* @param jingle the jingle packet that is going to be sent
* @returns {boolean} true if the jingle has to be sent and false otherwise.
*/
fixSourceAddJingle(jingle) {
let ssrcs = this.modifiedSSRCs.unmute;
this.modifiedSSRCs.unmute = [];
if (ssrcs && ssrcs.length) {
ssrcs.forEach(function(ssrcObj) {
const desc
= $(jingle.tree()).find(
`>jingle>content[name="${ssrcObj.mtype}"]>description`);
if (!desc || !desc.length) {
return;
}
ssrcObj.ssrcs.forEach(function(ssrc) {
const sourceNode = desc.find(`>source[ssrc="${ssrc}"]`);
sourceNode.remove();
});
ssrcObj.groups.forEach(function(group) {
const groupNode = desc.find(`>ssrc-group[semantics="${
group.semantics}"]:has(source[ssrc="${
group.ssrcs[0]}"])`);
groupNode.remove();
});
});
}
ssrcs = this.modifiedSSRCs.addMuted;
this.modifiedSSRCs.addMuted = [];
if (ssrcs && ssrcs.length) {
ssrcs.forEach(function(ssrcObj) {
const desc
= JingleSessionPC.createDescriptionNode(
jingle, ssrcObj.mtype);
const cname = Math.random().toString(36).substring(2);
ssrcObj.ssrcs.forEach(function(ssrc) {
const sourceNode
= desc.find(`>source[ssrc="${ssrc}"]`);
sourceNode.remove();
const sourceXML
= '`
+ '`
+ '`
+ '';
desc.append(sourceXML);
});
ssrcObj.groups.forEach(function(group) {
const groupNode
= desc.find(
`>ssrc-group[semantics="${group.semantics
}"]:has(source[ssrc="${group.ssrcs[0]}"])`);
groupNode.remove();
desc.append(
``
+ ``);
});
});
}
}
/**
* Fixes the outgoing jingle packets with action source-remove by removing
* the nodes related to the muted streams, handles removing of muted stream
* @param jingle the jingle packet that is going to be sent
* @returns {boolean} true if the jingle has to be sent and false otherwise.
*/
fixSourceRemoveJingle(jingle) {
let ssrcs = this.modifiedSSRCs.mute;
this.modifiedSSRCs.mute = [];
if (ssrcs && ssrcs.length) {
ssrcs.forEach(function(ssrcObj) {
ssrcObj.ssrcs.forEach(function(ssrc) {
const sourceNode
= $(jingle.tree()).find(
`>jingle>content[name="${ssrcObj.mtype
}"]>description>source[ssrc="${ssrc}"]`);
sourceNode.remove();
});
ssrcObj.groups.forEach(function(group) {
const groupNode
= $(jingle.tree()).find(
`>jingle>content[name="${ssrcObj.mtype
}"]>description>ssrc-group[semantics="${
group.semantics}"]:has(source[ssrc="${
group.ssrcs[0]}"])`);
groupNode.remove();
});
});
}
ssrcs = this.modifiedSSRCs.remove;
this.modifiedSSRCs.remove = [];
if (ssrcs && ssrcs.length) {
ssrcs.forEach(function(ssrcObj) {
const desc
= JingleSessionPC.createDescriptionNode(
jingle, ssrcObj.mtype);
ssrcObj.ssrcs.forEach(function(ssrc) {
const sourceNode
= desc.find(`>source[ssrc="${ssrc}"]`);
if (!sourceNode || !sourceNode.length) {
// Maybe we have to include cname, msid, etc here?
desc.append(
'`);
}
});
ssrcObj.groups.forEach(function(group) {
const groupNode
= desc.find(`>ssrc-group[semantics="${
group.semantics}"]:has(source[ssrc="${
group.ssrcs[0]}"])`);
if (!groupNode || !groupNode.length) {
desc.append(
``
+ ``
+ '');
}
});
});
}
}
/**
* Returns the description node related to the passed content type. If the
* node doesn't exists it will be created.
* @param jingle - the jingle packet
* @param mtype - the content type(audio, video, etc.)
*/
static createDescriptionNode(jingle, mtype) {
let content = $(jingle.tree()).find(`>jingle>content[name="${mtype}"]`);
if (!content || !content.length) {
$(jingle.tree()).find('>jingle').append(
``);
content = $(jingle.tree()).find(`>jingle>content[name="${mtype}"]`);
}
let desc = content.find('>description');
if (!desc || !desc.length) {
content.append(
``);
desc = content.find('>description');
}
return desc;
}
/**
* Extracts the ice username fragment from an SDP string.
*/
static getUfrag(sdp) {
const ufragLines
= sdp.split('\n').filter(line => line.startsWith('a=ice-ufrag:'));
if (ufragLines.length > 0) {
return ufragLines[0].substr('a=ice-ufrag:'.length);
}
}
}