diff --git a/modules/xmpp/JingleSessionPC.js b/modules/xmpp/JingleSessionPC.js index 2ef4590fa2..d6a8f8dbea 100644 --- a/modules/xmpp/JingleSessionPC.js +++ b/modules/xmpp/JingleSessionPC.js @@ -360,551 +360,457 @@ export default class JingleSessionPC extends JingleSession { this._removeSenderVideoConstraintsChangeListener = undefined; } - /* eslint-enable max-params */ - /** - * Checks whether or not this session instance is still operational. + * Handles either Jingle 'source-add' or 'source-remove' message for this + * Jingle session. + * @param {boolean} isAdd true for 'source-add' or false + * otherwise. + * @param {Array} elem an array of Jingle "content" elements. * @private - * @returns {boolean} {@code true} if operation or {@code false} otherwise. */ - _assertNotEnded() { - return this.state !== JingleSessionState.ENDED; - } + _addOrRemoveRemoteStream(isAdd, elem) { + const logPrefix = isAdd ? 'addRemoteStream' : 'removeRemoteStream'; - /** - * @inheritDoc - * @param {JingleSessionPCOptions} options - a set of config options. - */ - doInitialize(options) { - this.failICE = Boolean(options.failICE); - this.lasticecandidate = false; - this.options = options; + if (isAdd) { + this.readSsrcInfo(elem); + } - /** - * {@code true} if reconnect is in progress. - * @type {boolean} - */ - this.isReconnect = false; + const workFunction = finishedCallback => { + if (!this.peerconnection.localDescription + || !this.peerconnection.localDescription.sdp) { + const errMsg = `${logPrefix} - localDescription not ready yet`; - /** - * Set to {@code true} if the connection was ever stable - * @type {boolean} - */ - this.wasstable = false; - this.webrtcIceUdpDisable = Boolean(options.webrtcIceUdpDisable); - this.webrtcIceTcpDisable = Boolean(options.webrtcIceTcpDisable); + logger.error(errMsg); + finishedCallback(errMsg); - const pcOptions = { disableRtx: options.disableRtx }; + return; + } - if (options.gatherStats) { - pcOptions.maxstats = DEFAULT_MAX_STATS; - } - pcOptions.capScreenshareBitrate = false; - pcOptions.codecSettings = options.codecSettings; - pcOptions.enableInsertableStreams = options.enableInsertableStreams; - pcOptions.usesCodecSelectionAPI = this.usesCodecSelectionAPI - = browser.supportsCodecSelectionAPI() && options.testing?.enableCodecSelectionAPI && !this.isP2P; + logger.log(`${this} Processing ${logPrefix}`); - if (options.videoQuality) { - const settings = Object.entries(options.videoQuality) - .map(entry => { - entry[0] = entry[0].toLowerCase(); + const sdp = new SDP(this.peerconnection.remoteDescription.sdp); + const addOrRemoveSsrcInfo + = isAdd + ? this._parseSsrcInfoFromSourceAdd(elem, sdp) + : this._parseSsrcInfoFromSourceRemove(elem, sdp); + const newRemoteSdp + = isAdd + ? this._processRemoteAddSource(addOrRemoveSsrcInfo) + : this._processRemoteRemoveSource(addOrRemoveSsrcInfo); - return entry; + this._renegotiate(newRemoteSdp.raw).then(() => { + logger.log(`${this} ${logPrefix} - OK`); + finishedCallback(); + }, error => { + logger.error(`${this} ${logPrefix} failed:`, error); + finishedCallback(error); }); + }; - pcOptions.videoQuality = Object.fromEntries(settings); - } - pcOptions.forceTurnRelay = options.forceTurnRelay; - pcOptions.audioQuality = options.audioQuality; - pcOptions.disableSimulcast = this.isP2P ? true : options.disableSimulcast; + logger.debug(`${this} Queued ${logPrefix} task`); - if (!this.isP2P) { - // Do not send lower spatial layers for low fps screenshare and enable them only for high fps screenshare. - pcOptions.capScreenshareBitrate = !(options.desktopSharingFrameRate?.max > SS_DEFAULT_FRAME_RATE); - } + // Queue and execute + this.modificationQueue.push(workFunction); + } - if (options.startSilent) { - pcOptions.startSilent = true; + /** + * See {@link addTrackToPc} and {@link removeTrackFromPc}. + * @param {boolean} isRemove true for "remove" operation or false for "add" operation. + * @param {JitsiLocalTrack} track the track that will be added/removed + * @private + */ + _addRemoveTrack(isRemove, track) { + if (!track) { + return Promise.reject('invalid "track" argument value'); } + const operationName = isRemove ? 'removeTrack' : 'addTrack'; + const workFunction = finishedCallback => { + const tpc = this.peerconnection; - this.peerconnection - = this.rtc.createPeerConnection( - this._signalingLayer, - this.pcConfig, - this.isP2P, - pcOptions); + if (!tpc) { + finishedCallback(`Error: tried ${operationName} track with no active peer connection`); - 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; } + const operationPromise + = isRemove + ? tpc.removeTrackFromPc(track) + : tpc.addTrackToPc(track); - // XXX this is broken, candidate is not parsed. - const candidate = ev.candidate; - const now = window.performance.now(); + operationPromise + .then(shouldRenegotiate => { + if (shouldRenegotiate) { + this._renegotiate().then(finishedCallback); + } else { + finishedCallback(); + } + }, + finishedCallback /* will be called with an error */); + }; - if (candidate) { - if (this._gatheringStartedTimestamp === null) { - this._gatheringStartedTimestamp = now; - } + logger.debug(`${this} Queued ${operationName} task`); - // Discard candidates of disabled protocols. - let protocol = candidate.protocol; + return new Promise((resolve, reject) => { + this.modificationQueue.push( + workFunction, + error => { + if (error) { + if (error instanceof ClearedQueueError) { + // The session might have been terminated before the task was executed, making it obsolete. + logger.debug(`${this} ${operationName} aborted: session terminated`); + resolve(); - if (typeof protocol === 'string') { - protocol = protocol.toLowerCase(); - if (protocol === 'tcp' || protocol === 'ssltcp') { - if (this.webrtcIceTcpDisable) { - return; - } - } else if (protocol === 'udp') { - if (this.webrtcIceUdpDisable) { return; } + logger.error(`${this} ${operationName} failed`); + reject(error); + } else { + logger.debug(`${this} ${operationName} done`); + resolve(); } - } - } else if (!this._gatheringReported) { - // End of gathering - Statistics.sendAnalytics( - ICE_DURATION, - { - phase: 'gathering', - value: now - this._gatheringStartedTimestamp, - p2p: this.isP2P, - initiator: this.isInitiator - }); - this._gatheringReported = true; - } - if (this.isP2P) { - 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.signalingState === 'stable') { - this.wasstable = true; - } else if (this.peerconnection.signalingState === 'closed' - || this.peerconnection.connectionState === 'closed') { - this.room.eventEmitter.emit(XMPPEvents.SUSPEND_DETECTED, this); - } - }; + }); + }); + } - /** - * 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 = () => { - const now = window.performance.now(); - let isStable = false; + /** + * Checks whether or not this session instance is still operational. + * @private + * @returns {boolean} {@code true} if operation or {@code false} otherwise. + */ + _assertNotEnded() { + return this.state !== JingleSessionState.ENDED; + } - if (!this.isP2P) { - this.room.connectionTimes[ - `ice.state.${this.peerconnection.iceConnectionState}`] - = now; - } - logger.log(`(TIME) ICE ${this.peerconnection.iceConnectionState} ${this.isP2P ? 'P2P' : 'JVB'}:\t`, now); + /** + * 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 = []; + const self = this; - Statistics.sendAnalytics( - ICE_STATE_CHANGED, - { - p2p: this.isP2P, - state: this.peerconnection.iceConnectionState, - 'signaling_state': this.peerconnection.signalingState, - reconnect: this.isReconnect, - value: now - }); + $(sourceAddElem).each((i1, content) => { + const name = $(content).attr('name'); + let lines = ''; - this.room.eventEmitter.emit( - XMPPEvents.ICE_CONNECTION_STATE_CHANGED, - this, - this.peerconnection.iceConnectionState); - switch (this.peerconnection.iceConnectionState) { - case 'checking': - this._iceCheckingStartedTimestamp = now; - break; - case 'connected': - case 'completed': - // Informs interested parties that the connection has been restored. This includes the case when - // media connection to the bridge has been restored after an ICE failure by using session-terminate. - if (this.peerconnection.signalingState === 'stable') { - isStable = true; - this.room.eventEmitter.emit(XMPPEvents.CONNECTION_RESTORED, this); - } + $(content) + .find('ssrc-group[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]') + .each(function() { + // eslint-disable-next-line no-invalid-this + const semantics = this.getAttribute('semantics'); + const ssrcs + = $(this) // eslint-disable-line no-invalid-this + .find('>source') + .map(function() { + // eslint-disable-next-line no-invalid-this + return this.getAttribute('ssrc'); + }) + .get(); - // Add a workaround for an issue on chrome in Unified plan when the local endpoint is the offerer. - // The 'signalingstatechange' event for 'stable' is handled after the 'iceconnectionstatechange' event - // for 'completed' is handled by the client. This prevents the client from firing a - // CONNECTION_ESTABLISHED event for the p2p session. As a result, the offerer continues to stay on the - // jvb connection while the remote peer switches to the p2p connection breaking the media flow between - // the endpoints. - // TODO - file a chromium bug and add the information here. - if (!this.wasConnected - && (this.wasstable - || isStable - || (this.isInitiator && (browser.isChromiumBased() || browser.isReactNative())))) { + if (ssrcs.length) { + lines += `a=ssrc-group:${semantics} ${ssrcs.join(' ')}\r\n`; + } + }); - Statistics.sendAnalytics( - ICE_DURATION, - { - phase: 'checking', - value: now - this._iceCheckingStartedTimestamp, - p2p: this.isP2P, - initiator: this.isInitiator - }); + // handles both >source and >description>source + const tmp + = $(content).find( + 'source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]'); - // Switch between ICE gathering and ICE checking whichever - // started first (scenarios are different for initiator - // vs responder) - const iceStarted - = Math.min( - this._iceCheckingStartedTimestamp, - this._gatheringStartedTimestamp); + /* eslint-disable no-invalid-this */ + tmp.each(function() { + const ssrc = $(this).attr('ssrc'); - this.establishmentDuration = now - iceStarted; + if (currentRemoteSdp.containsSSRC(ssrc)) { - Statistics.sendAnalytics( - ICE_DURATION, - { - phase: 'establishment', - value: this.establishmentDuration, - p2p: this.isP2P, - initiator: this.isInitiator - }); + // Do not print the warning for unified plan p2p case since ssrcs are never removed from the SDP. + !self.isP2P && logger.warn(`${self} Source-add request for existing SSRC: ${ssrc}`); - this.wasConnected = true; - this.room.eventEmitter.emit( - XMPPEvents.CONNECTION_ESTABLISHED, this); + return; } - this.isReconnect = false; - break; - case 'disconnected': - this.isReconnect = true; - // Informs interested parties that the connection has been - // interrupted. - if (this.wasstable) { - this.room.eventEmitter.emit( - XMPPEvents.CONNECTION_INTERRUPTED, this); + // eslint-disable-next-line newline-per-chained-call + $(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'; + }); + }); + + let midFound = false; + + /* eslint-enable no-invalid-this */ + currentRemoteSdp.media.forEach((media, i2) => { + if (!SDPUtil.findLine(media, `a=mid:${name}`)) { + return; } - break; - case 'failed': - this.room.eventEmitter.emit( - XMPPEvents.CONNECTION_ICE_FAILED, this); - break; + if (!addSsrcInfo[i2]) { + addSsrcInfo[i2] = ''; + } + addSsrcInfo[i2] += lines; + midFound = true; + }); + + // In p2p unified mode with multi-stream enabled, the new sources will have content name that doesn't exist + // in the current remote description. Add a new m-line for this newly signaled source. + if (!midFound && this.isP2P) { + addSsrcInfo[name] = lines; } - }; + }); + return addSsrcInfo; + } - /** - * The connection state event is fired whenever the aggregate of underlying - * transports change their state. - */ - this.peerconnection.onconnectionstatechange = () => { - const icestate = this.peerconnection.iceConnectionState; + /** + * 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 = []; - switch (this.peerconnection.connectionState) { - case 'failed': - // Since version 76 Chrome no longer switches ICE connection - // state to failed (see - // https://bugs.chromium.org/p/chromium/issues/detail?id=982793 - // for details) we use this workaround to recover from lost connections - if (icestate === 'disconnected') { - this.room.eventEmitter.emit( - XMPPEvents.CONNECTION_ICE_FAILED, this); - } - break; - } - }; + $(sourceRemoveElem).each((i1, content) => { + const name = $(content).attr('name'); + let lines = ''; - /** - * The negotiationneeded event is fired whenever we shake the media on the - * RTCPeerConnection object. - */ - this.peerconnection.onnegotiationneeded = () => { - const state = this.peerconnection.signalingState; - const remoteDescription = this.peerconnection.remoteDescription; + $(content) + .find('ssrc-group[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]') + .each(function() { + /* eslint-disable no-invalid-this */ + const semantics = this.getAttribute('semantics'); + const ssrcs + = $(this) + .find('>source') + .map(function() { + return this.getAttribute('ssrc'); + }) + .get(); - if (!this.isP2P - && state === 'stable' - && remoteDescription - && typeof remoteDescription.sdp === 'string') { - logger.info(`${this} onnegotiationneeded fired on ${this.peerconnection}`); + if (ssrcs.length) { + lines + += `a=ssrc-group:${semantics} ${ + ssrcs.join(' ')}\r\n`; + } - const workFunction = finishedCallback => { - this._renegotiate() - .then(() => this.peerconnection.configureAudioSenderEncodings()) - .then(() => finishedCallback(), error => finishedCallback(error)); - }; + /* eslint-enable no-invalid-this */ + }); + const ssrcs = []; - this.modificationQueue.push( - workFunction, - error => { - if (error) { - logger.error(`${this} onnegotiationneeded error`, error); - } else { - logger.debug(`${this} onnegotiationneeded executed - OK`); - } - }); - } - }; + // handles both >source and >description>source versions + const tmp + = $(content).find( + 'source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]'); + + tmp.each(function() { + // eslint-disable-next-line no-invalid-this + const ssrc = $(this).attr('ssrc'); + + ssrcs.push(ssrc); + }); + currentRemoteSdp.media.forEach((media, i2) => { + if (!SDPUtil.findLine(media, `a=mid:${name}`)) { + return; + } + if (!removeSsrcInfo[i2]) { + removeSsrcInfo[i2] = ''; + } + ssrcs.forEach(ssrc => { + const ssrcLines + = SDPUtil.findLines(media, `a=ssrc:${ssrc}`); + + if (ssrcLines.length) { + removeSsrcInfo[i2] += `${ssrcLines.join('\r\n')}\r\n`; + } + }); + removeSsrcInfo[i2] += lines; + }); + }); + + return removeSsrcInfo; } /** - * Remote preference for receive video max frame height. - * - * @returns {Number|undefined} + * 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 */ - getRemoteRecvMaxFrameHeight() { - if (this.isP2P) { - return this.remoteRecvMaxFrameHeight; + _processNewJingleOfferIq(offerIq) { + const remoteSdp = new SDP(''); + + if (this.webrtcIceTcpDisable) { + remoteSdp.removeTcpCandidates = true; + } + if (this.webrtcIceUdpDisable) { + remoteSdp.removeUdpCandidates = true; + } + if (this.failICE) { + remoteSdp.failICE = true; } - return undefined; + remoteSdp.fromJingle(offerIq); + this.readSsrcInfo($(offerIq).find('>content')); + + return remoteSdp; } /** - * Remote preference for receive video max frame heights when source-name signaling is enabled. - * - * @returns {Map|undefined} - */ - getRemoteSourcesRecvMaxFrameHeight() { - if (this.isP2P) { - return this.remoteSourceMaxFrameHeights; - } - - return undefined; - } - - /** - * Sends given candidate in Jingle 'transport-info' message. - * @param {RTCIceCandidate} candidate the WebRTC ICE candidate instance - * @private + * 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 */ - sendIceCandidate(candidate) { - const localSDP = new SDP(this.peerconnection.localDescription.sdp); - - if (candidate && candidate.candidate.length && !this.lasticecandidate) { - const ice = SDPUtil.iceparams(localSDP.media[candidate.sdpMLineIndex], localSDP.session); - const jcand = SDPUtil.candidateToJingle(candidate.candidate); + _processRemoteAddSource(addSsrcInfo) { + let remoteSdp = new SDP(this.peerconnection.remoteDescription.sdp); - if (!(ice && jcand)) { - logger.error('failed to get ice && jcand'); + // Add a new m-line in the remote description if the source info for a secondary video source is recceived from + // the remote p2p peer when multi-stream support is enabled. + if (addSsrcInfo.length > remoteSdp.media.length && this.isP2P) { + remoteSdp.addMlineForNewLocalSource(MediaType.VIDEO); + remoteSdp = new SDP(remoteSdp.raw); + } + addSsrcInfo.forEach((lines, idx) => { + remoteSdp.media[idx] += lines; - return; - } - ice.xmlns = XEP.ICE_UDP_TRANSPORT; + // Make sure to change the direction to 'sendrecv/sendonly' only for p2p connections. For jvb connections, + // a new m-line is added for the new remote sources. + if (this.isP2P) { + const mediaType = SDPUtil.parseMLine(remoteSdp.media[idx].split('\r\n')[0])?.media; + const desiredDirection = this.peerconnection.getDesiredMediaDirection(mediaType, true); - if (this.usedrip) { - if (this.dripContainer.length === 0) { - setTimeout(() => { - if (this.dripContainer.length === 0) { - return; - } - this.sendIceCandidates(this.dripContainer); - this.dripContainer = []; - }, ICE_CAND_GATHERING_TIMEOUT); - } - this.dripContainer.push(candidate); - } else { - this.sendIceCandidates([ candidate ]); + [ MediaDirection.RECVONLY, MediaDirection.INACTIVE ].forEach(direction => { + remoteSdp.media[idx] = remoteSdp.media[idx] + .replace(`a=${direction}`, `a=${desiredDirection}`); + }); } - } else { - logger.log(`${this} sendIceCandidate: last candidate`); + }); + remoteSdp.raw = remoteSdp.session + remoteSdp.media.join(''); - // FIXME: remember to re-think in ICE-restart - this.lasticecandidate = true; - } + return remoteSdp; } /** - * Sends given candidates in Jingle 'transport-info' message. - * @param {Array} candidates an array of the WebRTC ICE - * candidate instances - * @private + * 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 */ - sendIceCandidates(candidates) { - if (!this._assertNotEnded('sendIceCandidates')) { - - return; - } - - logger.log(`${this} sendIceCandidates ${JSON.stringify(candidates)}`); - const cand = $iq({ to: this.remoteJid, - type: 'set' }) - .c('jingle', { xmlns: 'urn:xmpp:jingle:1', - action: 'transport-info', - initiator: this.initiatorJid, - sid: this.sid }); + _processRemoteRemoveSource(removeSsrcInfo) { + const remoteSdp = new SDP(this.peerconnection.peerconnection.remoteDescription.sdp); + let ssrcs; - const localSDP = new SDP(this.peerconnection.localDescription.sdp); + removeSsrcInfo.forEach(lines => { + // eslint-disable-next-line no-param-reassign + lines = lines.split('\r\n'); + lines.pop(); // remove empty last element; + ssrcs = lines.map(line => Number(line.split('a=ssrc:')[1]?.split(' ')[0])); - for (let mid = 0; mid < localSDP.media.length; mid++) { - const cands = candidates.filter(el => el.sdpMLineIndex === mid); - const mline - = SDPUtil.parseMLine(localSDP.media[mid].split('\r\n')[0]); + let mid; - if (cands.length > 0) { - const ice - = SDPUtil.iceparams(localSDP.media[mid], localSDP.session); + lines.forEach(line => { + mid = remoteSdp.media.findIndex(mLine => mLine.includes(line)); + if (mid > -1) { + remoteSdp.media[mid] = remoteSdp.media[mid].replace(`${line}\r\n`, ''); + if (this.isP2P) { + const mediaType = SDPUtil.parseMLine(remoteSdp.media[mid].split('\r\n')[0])?.media; + const desiredDirection = this.peerconnection.getDesiredMediaDirection(mediaType, false); - ice.xmlns = XEP.ICE_UDP_TRANSPORT; - cand.c('content', { - creator: this.initiatorJid === 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); + [ MediaDirection.SENDRECV, MediaDirection.SENDONLY ].forEach(direction => { + remoteSdp.media[mid] = remoteSdp.media[mid] + .replace(`a=${direction}`, `a=${desiredDirection}`); + }); + } else { + // Jvb connections will have direction set to 'sendonly' for the remote sources. + remoteSdp.media[mid] = remoteSdp.media[mid] + .replace(`a=${MediaDirection.SENDONLY}`, `a=${MediaDirection.INACTIVE}`); - // Mangle ICE candidate if 'failICE' test option is enabled + // Reject the m-line so that the browser removes the associated transceiver from the list + // of available transceivers. This will prevent the client from trying to re-use these + // inactive transceivers when additional video sources are added to the peerconnection. + const { media, port } = SDPUtil.parseMLine(remoteSdp.media[mid].split('\r\n')[0]); - if (this.failICE) { - candidate.ip = '1.1.1.1'; + remoteSdp.media[mid] = remoteSdp.media[mid].replace(`m=${media} ${port}`, `m=${media} 0`); } - cand.c('candidate', candidate).up(); } + }); + }); - // add fingerprint - const fingerprintLine - = SDPUtil.findLine( - localSDP.media[mid], - 'a=fingerprint:', localSDP.session); - - if (fingerprintLine) { - const tmp = SDPUtil.parseFingerprint(fingerprintLine); + // Update the ssrc owners list. + ssrcs?.length && this._signalingLayer.removeSSRCOwners(ssrcs); - 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 - } - } + remoteSdp.raw = remoteSdp.session + remoteSdp.media.join(''); - // 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), IQ_TIMEOUT); + return remoteSdp; } /** - * {@inheritDoc} + * Does a new offer/answer flow using the existing remote description (if not provided) and signals any new sources + * to Jicofo or the remote peer. + * + * @param {string} [optionalRemoteSdp] optional, raw 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} */ - addIceCandidates(elem) { + _renegotiate(optionalRemoteSdp) { if (this.peerconnection.signalingState === 'closed') { - logger.warn(`${this} Ignored add ICE candidate when in closed state`); - - return; - } - - const iceCandidates = []; - - elem.find('>content>transport>candidate') - .each((idx, candidate) => { - let line = SDPUtil.candidateFromJingle(candidate); + const error = new Error('Attempted to renegotiate in state closed'); - line = line.replace('\r\n', '').replace('a=', ''); + this.room.eventEmitter.emit(XMPPEvents.RENEGOTIATION_FAILED, error, this); - // FIXME this code does not care to handle - // non-bundle transport - const rtcCandidate = new RTCIceCandidate({ - sdpMLineIndex: 0, + return Promise.reject(error); + } - // FF comes up with more complex names like audio-23423, - // Given that it works on both Chrome and FF without - // providing it, let's leave it like this for the time - // being... - // sdpMid: 'audio', - sdpMid: '', - candidate: line - }); + const remoteSdp = optionalRemoteSdp || this.peerconnection.remoteDescription.sdp; - iceCandidates.push(rtcCandidate); - }); + if (!remoteSdp) { + const error = new Error(`Can not renegotiate without remote description, current state: ${this.state}`); - if (!iceCandidates.length) { - logger.error(`${this} No ICE candidates to add ?`, elem[0] && elem[0].outerHTML); + this.room.eventEmitter.emit(XMPPEvents.RENEGOTIATION_FAILED, error, this); - return; + return Promise.reject(error); } - // We want to have this task queued, so that we know it is executed, - // after the initial sRD/sLD offer/answer cycle was done (based on - // the assumption that candidates are spawned after the offer/answer - // and XMPP preserves order). - const workFunction = finishedCallback => { - for (const iceCandidate of iceCandidates) { - this.peerconnection.addIceCandidate(iceCandidate) - .then( - () => logger.debug(`${this} addIceCandidate ok!`), - err => logger.error(`${this} addIceCandidate failed!`, err)); - } + const remoteDescription = new RTCSessionDescription({ + type: 'offer', + sdp: remoteSdp + }); - finishedCallback(); - logger.debug(`${this} ICE candidates task finished`); - }; + const oldLocalSDP = this.peerconnection.localDescription.sdp; - logger.debug(`${this} Queued add (${iceCandidates.length}) ICE candidates task`); - this.modificationQueue.push(workFunction); - } + logger.debug(`${this} Renegotiate: setting remote description`); - /** - * - * @param contents - */ - readSsrcInfo(contents) { - const ssrcs = $(contents).find('>description>source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]'); + return this.peerconnection.setRemoteDescription(remoteDescription) + .then(() => { + logger.debug(`${this} Renegotiate: creating answer`); - ssrcs.each((i, ssrcElement) => { - const ssrc = Number(ssrcElement.getAttribute('ssrc')); - let sourceName; - - if (ssrcElement.hasAttribute('name')) { - sourceName = ssrcElement.getAttribute('name'); - } - - if (this.isP2P) { - // In P2P all SSRCs are owner by the remote peer - this._signalingLayer.setSSRCOwner(ssrc, Strophe.getResourceFromJid(this.remoteJid), sourceName); - } else { - $(ssrcElement) - .find('>ssrc-info[xmlns="http://jitsi.org/jitmeet"]') - .each((i3, ssrcInfoElement) => { - const owner = ssrcInfoElement.getAttribute('owner'); + return this.peerconnection.createAnswer(this.mediaConstraints); + }) + .then(answer => { + logger.debug(`${this} Renegotiate: setting local description`); - if (owner?.length) { - if (isNaN(ssrc) || ssrc < 0) { - logger.warn(`${this} Invalid SSRC ${ssrc} value received for ${owner}`); - } else { - this._signalingLayer.setSSRCOwner(ssrc, getEndpointId(owner), sourceName); - } - } - }); - } - }); + return this.peerconnection.setLocalDescription(answer); + }) + .then(() => { + if (oldLocalSDP) { + // Send the source updates after every renegotiation cycle. + this.notifyMySSRCUpdate(new SDP(oldLocalSDP), new SDP(this.peerconnection.localDescription.sdp)); + } + }); } /** @@ -956,1061 +862,1005 @@ export default class JingleSessionPC extends JingleSession { } /** - * Creates an offer and sends Jingle 'session-initiate' to the remote peer. - * - * @param {Array} localTracks the local tracks that will be added, before the offer/answer cycle - * executes (for the local track addition to be an atomic operation together with the offer/answer). + * {@inheritDoc} */ - invite(localTracks = []) { - if (!this.isInitiator) { - throw new Error('Trying to invite from the responder session'); + addIceCandidates(elem) { + if (this.peerconnection.signalingState === 'closed') { + logger.warn(`${this} Ignored add ICE candidate when in closed state`); + + return; } - logger.debug(`${this} Executing invite task`); - const addTracks = []; + const iceCandidates = []; - for (const track of localTracks) { - addTracks.push(this.peerconnection.addTrack(track, this.isInitiator)); - } + elem.find('>content>transport>candidate') + .each((idx, candidate) => { + let line = SDPUtil.candidateFromJingle(candidate); - Promise.all(addTracks) - .then(() => this.peerconnection.createOffer(this.mediaConstraints)) - .then(offerSdp => this.peerconnection.setLocalDescription(offerSdp)) - .then(() => { - this.peerconnection.processLocalSdpForTransceiverInfo(localTracks); - this.sendSessionInitiate(this.peerconnection.localDescription.sdp); - }) - .then(() => { - logger.debug(`${this} invite executed - OK`); - }) - .catch(error => { - logger.error(`${this} invite error`, error); - }); - } + line = line.replace('\r\n', '').replace('a=', ''); - /** - * Sends 'session-initiate' to the remote peer. - * - * NOTE this method is synchronous and we're not waiting for the RESULT - * response which would delay the startup process. - * - * @param {string} offerSdp - The local session description which will be - * used to generate an offer. - * @private - */ - sendSessionInitiate(offerSdp) { - let init = $iq({ - to: this.remoteJid, - type: 'set' - }).c('jingle', { - xmlns: 'urn:xmpp:jingle:1', - action: 'session-initiate', - initiator: this.initiatorJid, - sid: this.sid - }); + // FIXME this code does not care to handle + // non-bundle transport + const rtcCandidate = new RTCIceCandidate({ + sdpMLineIndex: 0, - new SDP(offerSdp, this.isP2P).toJingle( - init, - this.isInitiator ? 'initiator' : 'responder'); - init = init.tree(); - logger.debug(`${this} Session-initiate: `, init); - this.connection.sendIQ(init, - () => { - logger.info(`${this} Got RESULT for "session-initiate"`); - }, - error => { - logger.error(`${this} "session-initiate" error`, error); - }, - IQ_TIMEOUT); - } + // FF comes up with more complex names like audio-23423, + // Given that it works on both Chrome and FF without + // providing it, let's leave it like this for the time + // being... + // sdpMid: 'audio', + sdpMid: '', + candidate: line + }); - /** - * Sets the answer received from the remote peer as the remote description. - * - * @param jingleAnswer - */ - setAnswer(jingleAnswer) { - if (!this.isInitiator) { - throw new Error('Trying to set an answer on the responder session'); + iceCandidates.push(rtcCandidate); + }); + + if (!iceCandidates.length) { + logger.error(`${this} No ICE candidates to add ?`, elem[0] && elem[0].outerHTML); + + return; } - logger.debug(`${this} Executing setAnswer task`); - const newRemoteSdp = this._processNewJingleOfferIq(jingleAnswer); - const oldLocalSdp = new SDP(this.peerconnection.localDescription.sdp); - const remoteDescription = new RTCSessionDescription({ - type: 'answer', - sdp: newRemoteSdp.raw - }); + // We want to have this task queued, so that we know it is executed, + // after the initial sRD/sLD offer/answer cycle was done (based on + // the assumption that candidates are spawned after the offer/answer + // and XMPP preserves order). + const workFunction = finishedCallback => { + for (const iceCandidate of iceCandidates) { + this.peerconnection.addIceCandidate(iceCandidate) + .then( + () => logger.debug(`${this} addIceCandidate ok!`), + err => logger.error(`${this} addIceCandidate failed!`, err)); + } - this.peerconnection.setRemoteDescription(remoteDescription) - .then(() => { - if (this.state === JingleSessionState.PENDING) { - this.state = JingleSessionState.ACTIVE; + finishedCallback(); + logger.debug(`${this} ICE candidates task finished`); + }; - // Start processing tasks on the modification queue. - logger.debug(`${this} Resuming the modification queue after session is established!`); - this.modificationQueue.resume(); - const newLocalSdp = new SDP(this.peerconnection.localDescription.sdp); + logger.debug(`${this} Queued add (${iceCandidates.length}) ICE candidates task`); + this.modificationQueue.push(workFunction); + } - this.sendContentModify(); - this.notifyMySSRCUpdate(oldLocalSdp, newLocalSdp); - } - }) - .then(() => { - logger.debug(`${this} setAnswer task done`); - }) - .catch(error => { - logger.error(`${this} setAnswer task failed: ${error}`); - }); + /** + * Handles a Jingle source-add message for this Jingle session. + * @param elem An array of Jingle "content" elements. + */ + addRemoteStream(elem) { + this._addOrRemoveRemoteStream(true /* add */, elem); } /** - * 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. + * Adds a new track to the peerconnection. This method needs to be called only when a secondary JitsiLocalTrack is + * being added to the peerconnection for the first time. * - * @param jingleOfferAnswerIq jQuery selector pointing to the jingle element of the offer (or answer) 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. - * @param {Array} [localTracks] the optional list of the local tracks that will be added, before - * the offer/answer cycle executes (for the local track addition to be an atomic operation together with the - * offer/answer). + * @param {Array} localTracks - Tracks to be added to the peer connection. + * @returns {Promise} that resolves when the track is successfully added to the peerconnection, rejected + * otherwise. */ - setOfferAnswerCycle(jingleOfferAnswerIq, success, failure, localTracks = []) { - logger.debug(`${this} Executing setOfferAnswerCycle task`); - - const addTracks = []; - const audioTracks = localTracks.filter(track => track.getType() === MediaType.AUDIO); - const videoTracks = localTracks.filter(track => track.getType() === MediaType.VIDEO); - let tracks = localTracks; - - // Add only 1 video track at a time. Adding 2 or more video tracks to the peerconnection at the same time - // makes the browser go into a renegotiation loop by firing 'negotiationneeded' event after every - // renegotiation. - if (videoTracks.length > 1) { - tracks = [ ...audioTracks, videoTracks[0] ]; - } - for (const track of tracks) { - addTracks.push(this.peerconnection.addTrack(track, this.isInitiator)); + addTracks(localTracks = null) { + if (!localTracks?.length) { + Promise.reject(new Error('No tracks passed')); } - const newRemoteSdp = this._processNewJingleOfferIq(jingleOfferAnswerIq); - const bridgeSession = $(jingleOfferAnswerIq).find('>bridge-session[xmlns="http://jitsi.org/protocol/focus"]'); - const bridgeSessionId = bridgeSession.attr('id'); - if (bridgeSessionId !== this._bridgeSessionId) { - this._bridgeSessionId = bridgeSessionId; + if (localTracks.find(track => track.getType() !== MediaType.VIDEO)) { + return Promise.reject(new Error('Multiple tracks of the given media type are not supported')); } - Promise.all(addTracks) - .then(() => this._renegotiate(newRemoteSdp.raw)) - .then(() => { - this.peerconnection.processLocalSdpForTransceiverInfo(tracks); - if (this.state === JingleSessionState.PENDING) { - this.state = JingleSessionState.ACTIVE; + const replaceTracks = []; + const workFunction = finishedCallback => { + const remoteSdp = new SDP(this.peerconnection.peerconnection.remoteDescription.sdp); + const recvOnlyTransceiver = this.peerconnection.peerconnection.getTransceivers() + .find(t => t.receiver.track.kind === MediaType.VIDEO + && t.direction === MediaDirection.RECVONLY + && t.currentDirection === MediaDirection.RECVONLY); - // #1 Sync up video transfer active/inactive only after the initial O/A cycle. We want to - // adjust the video media direction only in the local SDP and the Jingle contents direction - // included in the initial offer/answer is mapped to the remote SDP. Jingle 'content-modify' - // IQ is processed in a way that it will only modify local SDP when remote peer is no longer - // interested in receiving video content. Changing media direction in the remote SDP will mess - // up our SDP translation chain (simulcast, video mute, RTX etc.) - // #2 Sends the max frame height if it was set, before the session-initiate/accept - if (this.isP2P && (!this._localSendReceiveVideoActive || this._sourceReceiverConstraints)) { - this.sendContentModify(); - } + // Add transceivers by adding a new mline in the remote description for each track. Do not create a new + // m-line if a recv-only transceiver exists in the p2p case. The new track will be attached to the + // existing one in that case. + for (const track of localTracks) { + if (!this.isP2P || !recvOnlyTransceiver) { + remoteSdp.addMlineForNewLocalSource(track.getType()); } + } - }) - .then(() => { - logger.debug(`${this} setOfferAnswerCycle task done`); - success(); - }) - .catch(error => { - logger.error(`${this} setOfferAnswerCycle task failed: ${error}`); - failure(error); - }); - } + this._renegotiate(remoteSdp.raw) + .then(() => { + // Replace the tracks on the newly generated transceivers. + for (const track of localTracks) { + replaceTracks.push(this.peerconnection.replaceTrack(null, track)); + } - /** - * Updates the codecs on the peerconnection and initiates a renegotiation for the - * new codec config to take effect. - * - * @param {Array} codecList - Preferred codecs for video. - * @param {CodecMimeType} screenshareCodec - The preferred screenshare codec. - */ - setVideoCodecs(codecList, screenshareCodec) { - if (this._assertNotEnded()) { - logger.info(`${this} setVideoCodecs: codecList=${codecList}, screenshareCodec=${screenshareCodec}`); - this.peerconnection.setVideoCodecs(codecList, screenshareCodec); + return Promise.all(replaceTracks); + }) - // Browser throws an error when H.264 is set on the encodings. Therefore, munge the SDP when H.264 needs to - // be selected. - // TODO: Remove this check when the above issue is fixed. - if (this.usesCodecSelectionAPI && codecList[0] !== CodecMimeType.H264) { - return; - } + // Trigger a renegotiation here since renegotiations are suppressed at TPC.replaceTrack for screenshare + // tracks. This is done here so that presence for screenshare tracks is sent before signaling. + .then(() => this._renegotiate()) + .then(() => finishedCallback(), error => finishedCallback(error)); + }; - // Skip renegotiation when the selected codec order matches with that of the remote SDP. - const currentCodecOrder = this.peerconnection.getConfiguredVideoCodecs(); + return new Promise((resolve, reject) => { + logger.debug(`${this} Queued renegotiation after addTrack`); - if (codecList.every((val, index) => val === currentCodecOrder[index])) { - return; - } + this.modificationQueue.push( + workFunction, + error => { + if (error) { + if (error instanceof ClearedQueueError) { + // The session might have been terminated before the task was executed, making it obsolete. + logger.debug(`${this} renegotiation after addTrack aborted: session terminated`); + resolve(); - Statistics.sendAnalytics( - VIDEO_CODEC_CHANGED, - { - value: codecList[0], - videoType: VideoType.CAMERA + return; + } + logger.error(`${this} renegotiation after addTrack error`, error); + reject(error); + } else { + logger.debug(`${this} renegotiation after addTrack executed - OK`); + resolve(); + } }); - - // Initiate a renegotiate for the codec setting to take effect. - const workFunction = finishedCallback => { - this._renegotiate() - .then(() => this.peerconnection.configureVideoSenderEncodings()) - .then( - () => { - logger.debug(`${this} setVideoCodecs task is done`); - - return finishedCallback(); - }, error => { - logger.error(`${this} setVideoCodecs task failed: ${error}`); - - return finishedCallback(error); - }); - }; - - logger.debug(`${this} Queued setVideoCodecs task`); - - // Queue and execute - this.modificationQueue.push(workFunction); - } + }); } /** - * 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. - * @private + * Adds local track back to the peerconnection associated with this session. + * @param {JitsiLocalTrack} track + * @return {Promise} a promise that will resolve once the local track is added back to this session and + * renegotiation succeeds (if its warranted). Will be rejected with a string that provides some error + * details in case something goes wrong. */ - 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, this.isP2P); - const accept = $iq({ to: this.remoteJid, - type: 'set' }) - .c('jingle', { xmlns: 'urn:xmpp:jingle:1', - action: 'session-accept', - initiator: this.initiatorJid, - responder: this.responderJid, - sid: this.sid }); - - if (this.webrtcIceTcpDisable) { - localSDP.removeTcpCandidates = true; - } - if (this.webrtcIceUdpDisable) { - localSDP.removeUdpCandidates = true; - } - if (this.failICE) { - localSDP.failICE = true; - } - if (typeof this.options.channelLastN === 'number' && this.options.channelLastN >= 0) { - localSDP.initialLastN = this.options.channelLastN; - } - localSDP.toJingle( - accept, - this.initiatorJid === this.localJid ? 'initiator' : 'responder'); - - logger.info(`${this} Sending session-accept`); - logger.debug(accept.tree()); - 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, this); - }), - 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(); + addTrackToPc(track) { + return this._addRemoveTrack(false /* add */, track) + .then(() => { + // Configure the video encodings after the track is unmuted. If the user joins the call muted and + // unmutes it the first time, all the parameters need to be configured. + if (track.isVideoTrack()) { + return this.peerconnection.configureVideoSenderEncodings(track); + } + }); } /** - * Will send 'content-modify' IQ in order to ask the remote peer to - * either stop or resume sending video media or to adjust sender's video constraints. - * @private + * Closes the peerconnection. */ - sendContentModify() { - const senders = this._localSendReceiveVideoActive ? 'both' : 'none'; - const sessionModify - = $iq({ - to: this.remoteJid, - type: 'set' - }) - .c('jingle', { - xmlns: 'urn:xmpp:jingle:1', - action: 'content-modify', - initiator: this.initiatorJid, - sid: this.sid - }) - .c('content', { - name: MediaType.VIDEO, - senders - }); - - if (typeof this._sourceReceiverConstraints !== 'undefined') { - this._sourceReceiverConstraints.forEach((maxHeight, sourceName) => { - sessionModify - .c('source-frame-height', { xmlns: 'http://jitsi.org/jitmeet/video' }) - .attrs({ - sourceName, - maxHeight - }); + close() { + this.state = JingleSessionState.ENDED; + this.establishmentDuration = undefined; - sessionModify.up(); - logger.info(`${this} sending content-modify for source-name: ${sourceName}, maxHeight: ${maxHeight}`); - }); + if (this.peerconnection) { + this.peerconnection.onicecandidate = null; + this.peerconnection.oniceconnectionstatechange = null; + this.peerconnection.onnegotiationneeded = null; + this.peerconnection.onsignalingstatechange = null; } - logger.debug(sessionModify.tree()); + logger.debug(`${this} Clearing modificationQueue`); - this.connection.sendIQ( - sessionModify, - null, - this.newJingleErrorHandler(sessionModify), - IQ_TIMEOUT); - } + // Remove any pending tasks from the queue + this.modificationQueue.clear(); - /** - * Adjust the preference for max video frame height that the local party is willing to receive. Signals - * the remote p2p peer. - * - * @param {Map} sourceReceiverConstraints - The receiver constraints per source. - */ - setReceiverVideoConstraint(sourceReceiverConstraints) { - logger.info(`${this} setReceiverVideoConstraint - constraints: ${JSON.stringify(sourceReceiverConstraints)}`); - this._sourceReceiverConstraints = sourceReceiverConstraints; + logger.debug(`${this} Queued PC close task`); + this.modificationQueue.push(finishCallback => { + // do not try to close if already closed. + this.peerconnection && this.peerconnection.close(); + finishCallback(); + logger.debug(`${this} PC close task done!`); + }); - if (this.isP2P) { - // Tell the remote peer about our receive constraint. If Jingle session is not yet active the state will - // be synced after offer/answer. - if (this.state === JingleSessionState.ACTIVE) { - this.sendContentModify(); - } - } + logger.debug(`${this} Shutdown modificationQueue!`); + + // No more tasks can go in after the close task + this.modificationQueue.shutdown(); } /** - * Sets the resolution constraint on the local camera track. - * @param {number} maxFrameHeight - The user preferred max frame height. - * @param {string} sourceName - The source name of the track. - * @returns {Promise} promise that will be resolved when the operation is - * successful and rejected otherwise. + * @inheritDoc + * @param {JingleSessionPCOptions} options - a set of config options. */ - setSenderVideoConstraint(maxFrameHeight, sourceName = null) { - if (this._assertNotEnded()) { - logger.info(`${this} setSenderVideoConstraint: ${maxFrameHeight}, sourceName: ${sourceName}`); + doInitialize(options) { + this.failICE = Boolean(options.failICE); + this.lasticecandidate = false; + this.options = options; - const jitsiLocalTrack = sourceName - ? this.rtc.getLocalVideoTracks().find(track => track.getSourceName() === sourceName) - : this.rtc.getLocalVideoTrack(); + /** + * {@code true} if reconnect is in progress. + * @type {boolean} + */ + this.isReconnect = false; - return this.peerconnection.setSenderVideoConstraints(maxFrameHeight, jitsiLocalTrack); - } + /** + * Set to {@code true} if the connection was ever stable + * @type {boolean} + */ + this.wasstable = false; + this.webrtcIceUdpDisable = Boolean(options.webrtcIceUdpDisable); + this.webrtcIceTcpDisable = Boolean(options.webrtcIceTcpDisable); - return Promise.resolve(); - } + const pcOptions = { disableRtx: options.disableRtx }; - /** - * @inheritDoc - */ - terminate(success, failure, options) { - if (this.state === JingleSessionState.ENDED) { - return; + if (options.gatherStats) { + pcOptions.maxstats = DEFAULT_MAX_STATS; } + pcOptions.capScreenshareBitrate = false; + pcOptions.codecSettings = options.codecSettings; + pcOptions.enableInsertableStreams = options.enableInsertableStreams; + pcOptions.usesCodecSelectionAPI = this.usesCodecSelectionAPI + = browser.supportsCodecSelectionAPI() && options.testing?.enableCodecSelectionAPI && !this.isP2P; - if (!options || Boolean(options.sendSessionTerminate)) { - const sessionTerminate - = $iq({ - to: this.remoteJid, - type: 'set' - }) - .c('jingle', { - xmlns: 'urn:xmpp:jingle:1', - action: 'session-terminate', - initiator: this.initiatorJid, - sid: this.sid - }) - .c('reason') - .c((options && options.reason) || 'success') - .up(); + if (options.videoQuality) { + const settings = Object.entries(options.videoQuality) + .map(entry => { + entry[0] = entry[0].toLowerCase(); - if (options && options.reasonDescription) { - sessionTerminate - .c('text') - .t(options.reasonDescription) - .up() - .up(); - } else { - sessionTerminate.up(); - } + return entry; + }); - this._bridgeSessionId - && sessionTerminate.c( - 'bridge-session', { - xmlns: 'http://jitsi.org/protocol/focus', - id: this._bridgeSessionId, - restart: options && options.requestRestart === true - }).up(); + pcOptions.videoQuality = Object.fromEntries(settings); + } + pcOptions.forceTurnRelay = options.forceTurnRelay; + pcOptions.audioQuality = options.audioQuality; + pcOptions.disableSimulcast = this.isP2P ? true : options.disableSimulcast; - logger.info(`${this} Sending session-terminate`); - logger.debug(sessionTerminate.tree()); + if (!this.isP2P) { + // Do not send lower spatial layers for low fps screenshare and enable them only for high fps screenshare. + pcOptions.capScreenshareBitrate = !(options.desktopSharingFrameRate?.max > SS_DEFAULT_FRAME_RATE); + } - this.connection.sendIQ( - sessionTerminate, - success, - this.newJingleErrorHandler(sessionTerminate, failure), - IQ_TIMEOUT); - } else { - logger.info(`${this} Skipped sending session-terminate`); + if (options.startSilent) { + pcOptions.startSilent = true; } - // this should result in 'onTerminated' being called by strope.jingle.js - this.connection.jingle.terminate(this.sid); - } + this.peerconnection + = this.rtc.createPeerConnection( + this._signalingLayer, + this.pcConfig, + this.isP2P, + pcOptions); - /** - * - * @param reasonCondition - * @param reasonText - */ - onTerminated(reasonCondition, reasonText) { - // Do something with reason and reasonCondition when we start to care - // this.reasonCondition = reasonCondition; - // this.reasonText = reasonText; - logger.info(`${this} Session terminated`, reasonCondition, reasonText); + 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; + } - this._xmppListeners.forEach(removeListener => removeListener()); - this._xmppListeners = []; + // XXX this is broken, candidate is not parsed. + const candidate = ev.candidate; + const now = window.performance.now(); - if (this._removeSenderVideoConstraintsChangeListener) { - this._removeSenderVideoConstraintsChangeListener(); - } + if (candidate) { + if (this._gatheringStartedTimestamp === null) { + this._gatheringStartedTimestamp = now; + } - if (FeatureFlags.isSsrcRewritingSupported() && this.peerconnection) { - this.peerconnection.getRemoteTracks().forEach(track => { - this.room.eventEmitter.emit(JitsiTrackEvents.TRACK_REMOVED, track); - }); - } + // Discard candidates of disabled protocols. + let protocol = candidate.protocol; - this.close(); - } + if (typeof protocol === 'string') { + protocol = protocol.toLowerCase(); + if (protocol === 'tcp' || protocol === 'ssltcp') { + if (this.webrtcIceTcpDisable) { + return; + } + } else if (protocol === 'udp') { + if (this.webrtcIceUdpDisable) { + return; + } + } + } + } else if (!this._gatheringReported) { + // End of gathering + Statistics.sendAnalytics( + ICE_DURATION, + { + phase: 'gathering', + value: now - this._gatheringStartedTimestamp, + p2p: this.isP2P, + initiator: this.isInitiator + }); + this._gatheringReported = true; + } + if (this.isP2P) { + this.sendIceCandidate(candidate); + } + }; - /** - * Handles XMPP connection state changes. - * - * @param {XmppConnection.Status} status - The new status. - */ - onXmppStatusChanged(status) { - if (status === XmppConnection.Status.CONNECTED && this._cachedOldLocalSdp) { - logger.info(`${this} Sending SSRC update on reconnect`); - this.notifyMySSRCUpdate( - this._cachedOldLocalSdp, - this._cachedNewLocalSdp); - } - } + // 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.signalingState === 'stable') { + this.wasstable = true; + } else if (this.peerconnection.signalingState === 'closed' + || this.peerconnection.connectionState === 'closed') { + this.room.eventEmitter.emit(XMPPEvents.SUSPEND_DETECTED, this); + } + }; - /** - * 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 = []; - const self = this; + /** + * 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 = () => { + const now = window.performance.now(); + let isStable = false; + + if (!this.isP2P) { + this.room.connectionTimes[ + `ice.state.${this.peerconnection.iceConnectionState}`] + = now; + } + logger.log(`(TIME) ICE ${this.peerconnection.iceConnectionState} ${this.isP2P ? 'P2P' : 'JVB'}:\t`, now); + + Statistics.sendAnalytics( + ICE_STATE_CHANGED, + { + p2p: this.isP2P, + state: this.peerconnection.iceConnectionState, + 'signaling_state': this.peerconnection.signalingState, + reconnect: this.isReconnect, + value: now + }); - $(sourceAddElem).each((i1, content) => { - const name = $(content).attr('name'); - let lines = ''; + this.room.eventEmitter.emit( + XMPPEvents.ICE_CONNECTION_STATE_CHANGED, + this, + this.peerconnection.iceConnectionState); + switch (this.peerconnection.iceConnectionState) { + case 'checking': + this._iceCheckingStartedTimestamp = now; + break; + case 'connected': + case 'completed': + // Informs interested parties that the connection has been restored. This includes the case when + // media connection to the bridge has been restored after an ICE failure by using session-terminate. + if (this.peerconnection.signalingState === 'stable') { + isStable = true; + this.room.eventEmitter.emit(XMPPEvents.CONNECTION_RESTORED, this); + } - $(content) - .find('ssrc-group[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]') - .each(function() { - // eslint-disable-next-line no-invalid-this - const semantics = this.getAttribute('semantics'); - const ssrcs - = $(this) // eslint-disable-line no-invalid-this - .find('>source') - .map(function() { - // eslint-disable-next-line no-invalid-this - return this.getAttribute('ssrc'); - }) - .get(); + // Add a workaround for an issue on chrome in Unified plan when the local endpoint is the offerer. + // The 'signalingstatechange' event for 'stable' is handled after the 'iceconnectionstatechange' event + // for 'completed' is handled by the client. This prevents the client from firing a + // CONNECTION_ESTABLISHED event for the p2p session. As a result, the offerer continues to stay on the + // jvb connection while the remote peer switches to the p2p connection breaking the media flow between + // the endpoints. + // TODO - file a chromium bug and add the information here. + if (!this.wasConnected + && (this.wasstable + || isStable + || (this.isInitiator && (browser.isChromiumBased() || browser.isReactNative())))) { - if (ssrcs.length) { - lines += `a=ssrc-group:${semantics} ${ssrcs.join(' ')}\r\n`; - } - }); + Statistics.sendAnalytics( + ICE_DURATION, + { + phase: 'checking', + value: now - this._iceCheckingStartedTimestamp, + p2p: this.isP2P, + initiator: this.isInitiator + }); - // handles both >source and >description>source - const tmp - = $(content).find( - 'source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]'); + // Switch between ICE gathering and ICE checking whichever + // started first (scenarios are different for initiator + // vs responder) + const iceStarted + = Math.min( + this._iceCheckingStartedTimestamp, + this._gatheringStartedTimestamp); - /* eslint-disable no-invalid-this */ - tmp.each(function() { - const ssrc = $(this).attr('ssrc'); + this.establishmentDuration = now - iceStarted; - if (currentRemoteSdp.containsSSRC(ssrc)) { + Statistics.sendAnalytics( + ICE_DURATION, + { + phase: 'establishment', + value: this.establishmentDuration, + p2p: this.isP2P, + initiator: this.isInitiator + }); - // Do not print the warning for unified plan p2p case since ssrcs are never removed from the SDP. - !self.isP2P && logger.warn(`${self} Source-add request for existing SSRC: ${ssrc}`); + this.wasConnected = true; + this.room.eventEmitter.emit( + XMPPEvents.CONNECTION_ESTABLISHED, this); + } + this.isReconnect = false; + break; + case 'disconnected': + this.isReconnect = true; - return; + // Informs interested parties that the connection has been + // interrupted. + if (this.wasstable) { + this.room.eventEmitter.emit( + XMPPEvents.CONNECTION_INTERRUPTED, this); } + break; + case 'failed': + this.room.eventEmitter.emit( + XMPPEvents.CONNECTION_ICE_FAILED, this); + break; + } + }; - // eslint-disable-next-line newline-per-chained-call - $(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'; - }); - }); - let midFound = false; + /** + * The connection state event is fired whenever the aggregate of underlying + * transports change their state. + */ + this.peerconnection.onconnectionstatechange = () => { + const icestate = this.peerconnection.iceConnectionState; - /* eslint-enable no-invalid-this */ - currentRemoteSdp.media.forEach((media, i2) => { - if (!SDPUtil.findLine(media, `a=mid:${name}`)) { - return; - } - if (!addSsrcInfo[i2]) { - addSsrcInfo[i2] = ''; + switch (this.peerconnection.connectionState) { + case 'failed': + // Since version 76 Chrome no longer switches ICE connection + // state to failed (see + // https://bugs.chromium.org/p/chromium/issues/detail?id=982793 + // for details) we use this workaround to recover from lost connections + if (icestate === 'disconnected') { + this.room.eventEmitter.emit( + XMPPEvents.CONNECTION_ICE_FAILED, this); } - addSsrcInfo[i2] += lines; - midFound = true; - }); - - // In p2p unified mode with multi-stream enabled, the new sources will have content name that doesn't exist - // in the current remote description. Add a new m-line for this newly signaled source. - if (!midFound && this.isP2P) { - addSsrcInfo[name] = lines; + break; } - }); + }; - return addSsrcInfo; + /** + * The negotiationneeded event is fired whenever we shake the media on the + * RTCPeerConnection object. + */ + this.peerconnection.onnegotiationneeded = () => { + const state = this.peerconnection.signalingState; + const remoteDescription = this.peerconnection.remoteDescription; + + if (!this.isP2P + && state === 'stable' + && remoteDescription + && typeof remoteDescription.sdp === 'string') { + logger.info(`${this} onnegotiationneeded fired on ${this.peerconnection}`); + + const workFunction = finishedCallback => { + this._renegotiate() + .then(() => this.peerconnection.configureAudioSenderEncodings()) + .then(() => finishedCallback(), error => finishedCallback(error)); + }; + + this.modificationQueue.push( + workFunction, + error => { + if (error) { + logger.error(`${this} onnegotiationneeded error`, error); + } else { + logger.debug(`${this} onnegotiationneeded executed - OK`); + } + }); + } + }; } /** - * Handles a Jingle source-add message for this Jingle session. - * @param elem An array of Jingle "content" elements. + * Returns the ice connection state for the peer connection. + * @returns the ice connection state for the peer connection. */ - addRemoteStream(elem) { - this._addOrRemoveRemoteStream(true /* add */, elem); + getIceConnectionState() { + return this.peerconnection.getConnectionState(); } /** - * Handles a Jingle source-remove message for this Jingle session. - * @param elem An array of Jingle "content" elements. + * Remote preference for receive video max frame height. + * + * @returns {Number|undefined} */ - removeRemoteStream(elem) { - this._addOrRemoveRemoteStream(false /* remove */, elem); + getRemoteRecvMaxFrameHeight() { + if (this.isP2P) { + return this.remoteRecvMaxFrameHeight; + } + + return undefined; } /** - * Processes the source map message received from the bridge and creates a new remote track for newly signaled - * SSRCs or updates the source-name and owner on the remote track for an existing SSRC. + * Remote preference for receive video max frame heights when source-name signaling is enabled. * - * @param {Object} message - The source map message. - * @param {string} mediaType - The media type, 'audio' or 'video'. - * @returns {void} + * @returns {Map|undefined} */ - processSourceMap(message, mediaType) { - if (!FeatureFlags.isSsrcRewritingSupported()) { - return; + getRemoteSourcesRecvMaxFrameHeight() { + if (this.isP2P) { + return this.remoteSourceMaxFrameHeights; } - const newSsrcs = []; - - for (const src of message.mappedSources) { - const { owner, source, ssrc } = src; - const isNewSsrc = this.peerconnection.addRemoteSsrc(ssrc, source); - - if (isNewSsrc) { - newSsrcs.push(src); - logger.debug(`New SSRC signaled ${ssrc}: owner=${owner}, source-name=${source}`); - // Check if there is an old mapping for the given source and clear the owner on the associated track. - const oldSsrc = this.peerconnection.remoteSources.get(source); + return undefined; + } - if (oldSsrc) { - this._signalingLayer.removeSSRCOwners([ oldSsrc ]); - const track = this.peerconnection.getTrackBySSRC(oldSsrc); + /** + * Creates an offer and sends Jingle 'session-initiate' to the remote peer. + * + * @param {Array} localTracks the local tracks that will be added, before the offer/answer cycle + * executes (for the local track addition to be an atomic operation together with the offer/answer). + */ + invite(localTracks = []) { + if (!this.isInitiator) { + throw new Error('Trying to invite from the responder session'); + } + logger.debug(`${this} Executing invite task`); - if (track) { - this.room.eventEmitter.emit(JitsiTrackEvents.TRACK_OWNER_SET, track); - } - } - } else { - const track = this.peerconnection.getTrackBySSRC(ssrc); + const addTracks = []; - if (!track || (track.getParticipantId() === owner && track.getSourceName() === source)) { - !track && logger.warn(`Remote track for SSRC=${ssrc} hasn't been created yet,` - + 'not processing the source map'); - continue; // eslint-disable-line no-continue - } - logger.debug(`Existing SSRC re-mapped ${ssrc}: new owner=${owner}, source-name=${source}`); + for (const track of localTracks) { + addTracks.push(this.peerconnection.addTrack(track, this.isInitiator)); + } - this._signalingLayer.setSSRCOwner(ssrc, owner, source); + Promise.all(addTracks) + .then(() => this.peerconnection.createOffer(this.mediaConstraints)) + .then(offerSdp => this.peerconnection.setLocalDescription(offerSdp)) + .then(() => { + this.peerconnection.processLocalSdpForTransceiverInfo(localTracks); + this.sendSessionInitiate(this.peerconnection.localDescription.sdp); + }) + .then(() => { + logger.debug(`${this} invite executed - OK`); + }) + .catch(error => { + logger.error(`${this} invite error`, error); + }); + } - // Update the muted state and the video type on the track since the presence for this track could have - // been received before the updated source map is received on the bridge channel. - const { muted, videoType } = this._signalingLayer.getPeerMediaInfo(owner, mediaType, source); + /** + * Enables/disables local video based on 'senders' attribute of the video conent in 'content-modify' IQ sent by the + * remote peer. Also, checks if the sourceMaxFrameHeight (as requested by the p2p peer) or the senders attribute of + * the video content has changed and modifies the local video resolution accordingly. + */ + modifyContents(jingleContents) { + const newVideoSenders = JingleSessionPC.parseVideoSenders(jingleContents); + const sourceMaxFrameHeights = JingleSessionPC.parseSourceMaxFrameHeight(jingleContents); - muted && this.peerconnection._sourceMutedChanged(source, muted); - this.room.eventEmitter.emit(JitsiTrackEvents.TRACK_OWNER_SET, track, owner, source, videoType); - } + if (sourceMaxFrameHeights) { + this.remoteSourceMaxFrameHeights = sourceMaxFrameHeights; + this.eventEmitter.emit(MediaSessionEvents.REMOTE_SOURCE_CONSTRAINTS_CHANGED, this, sourceMaxFrameHeights); } - // Add the new SSRCs to the remote description by generating a source message. - if (newSsrcs.length) { - let node = $build('content', { - xmlns: 'urn:xmpp:jingle:1', - name: mediaType - }).c('description', { - xmlns: XEP.RTP_MEDIA, - media: mediaType - }); + if (newVideoSenders === null) { + logger.error(`${this} - failed to parse video "senders" attribute in "content-modify" action`); - for (const src of newSsrcs) { - const { rtx, ssrc, source } = src; - let msid; + return; + } - if (mediaType === MediaType.VIDEO) { - const idx = ++this.numRemoteVideoSources; + if (!this._assertNotEnded()) { + return; + } - msid = `remote-video-${idx} remote-video-${idx}`; + const isRemoteVideoActive + = newVideoSenders === 'both' + || (newVideoSenders === 'initiator' && this.isInitiator) + || (newVideoSenders === 'responder' && !this.isInitiator); - if (rtx !== '-1') { - _addSourceElement(node, src, rtx, msid); - node.c('ssrc-group', { - xmlns: XEP.SOURCE_ATTRIBUTES, - semantics: 'FID' - }) - .c('source', { - xmlns: XEP.SOURCE_ATTRIBUTES, - ssrc - }) - .up() - .c('source', { - xmlns: XEP.SOURCE_ATTRIBUTES, - ssrc: rtx - }) - .up() - .up(); - } - } else { - const idx = ++this.numRemoteAudioSources; + if (isRemoteVideoActive !== this._remoteSendReceiveVideoActive) { + logger.debug(`${this} new remote video active: ${isRemoteVideoActive}`); + this._remoteSendReceiveVideoActive = isRemoteVideoActive; - msid = `remote-audio-${idx} remote-audio-${idx}`; - } - _addSourceElement(node, src, ssrc, msid); - this.peerconnection.remoteSources.set(source, ssrc); - } - node = node.up(); - this._addOrRemoveRemoteStream(true /* add */, node.node); + this.peerconnection + .setVideoTransferActive(this._localSendReceiveVideoActive && this._remoteSendReceiveVideoActive); } } /** - * Handles the deletion of SSRCs associated with a remote user from the remote description when the user leaves. - * - * @param {string} id Endpoint id of the participant that has left the call. - * @returns {void} + * 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: {this JingleSessionPC.toString()} + * } + * @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)} */ - removeRemoteStreamsOnLeave(id) { - const workFunction = finishCallback => { - const removeSsrcInfo = this.peerconnection.getRemoteSourceInfoByParticipant(id); + newJingleErrorHandler(request, failureCb) { + return errResponse => { - if (removeSsrcInfo.length) { - const newRemoteSdp = this._processRemoteRemoveSource(removeSsrcInfo); + const error = {}; - this._renegotiate(newRemoteSdp.raw) - .then(() => finishCallback(), error => finishCallback(error)); - } else { - finishCallback(); - } - }; + // Get XMPP error code and condition(reason) + const errorElSel = $(errResponse).find('error'); - logger.debug(`${this} Queued removeRemoteStreamsOnLeave task for participant ${id}`); + if (errorElSel.length) { + error.code = errorElSel.attr('code'); + const errorReasonSel = $(errResponse).find('error :first'); - this.modificationQueue.push( - workFunction, - error => { - if (error) { - logger.error(`${this} removeRemoteStreamsOnLeave error:`, error); - } else { - logger.info(`${this} removeRemoteStreamsOnLeave done!`); + if (errorReasonSel.length) { + error.reason = errorReasonSel[0].tagName; } - }); - } - - /** - * Handles either Jingle 'source-add' or 'source-remove' message for this - * Jingle session. - * @param {boolean} isAdd true for 'source-add' or false - * otherwise. - * @param {Array} elem an array of Jingle "content" elements. - * @private - */ - _addOrRemoveRemoteStream(isAdd, elem) { - const logPrefix = isAdd ? 'addRemoteStream' : 'removeRemoteStream'; - - if (isAdd) { - this.readSsrcInfo(elem); - } - - const workFunction = finishedCallback => { - if (!this.peerconnection.localDescription - || !this.peerconnection.localDescription.sdp) { - const errMsg = `${logPrefix} - localDescription not ready yet`; - logger.error(errMsg); - finishedCallback(errMsg); + const errorMsgSel = errorElSel.find('>text'); - return; + if (errorMsgSel.length) { + error.msg = errorMsgSel.text(); + } } - logger.log(`${this} Processing ${logPrefix}`); + if (!errResponse) { + error.reason = 'timeout'; + } - const sdp = new SDP(this.peerconnection.remoteDescription.sdp); - const addOrRemoveSsrcInfo - = isAdd - ? this._parseSsrcInfoFromSourceAdd(elem, sdp) - : this._parseSsrcInfoFromSourceRemove(elem, sdp); - const newRemoteSdp - = isAdd - ? this._processRemoteAddSource(addOrRemoveSsrcInfo) - : this._processRemoteRemoveSource(addOrRemoveSsrcInfo); + error.session = this.toString(); - this._renegotiate(newRemoteSdp.raw).then(() => { - logger.log(`${this} ${logPrefix} - OK`); - finishedCallback(); - }, error => { - logger.error(`${this} ${logPrefix} failed:`, error); - finishedCallback(error); - }); + if (failureCb) { + failureCb(error); + } else if (this.state === JingleSessionState.ENDED + && error.reason === 'item-not-found') { + // When remote peer decides to terminate the session, but it + // still have few messages on the queue for processing, + // it will first send us 'session-terminate' (we enter ENDED) + // and then follow with 'item-not-found' for the queued requests + // We don't want to have that logged on error level. + logger.debug(`${this} Jingle error: ${JSON.stringify(error)}`); + } else { + logger.error(`Jingle error: ${JSON.stringify(error)}`); + } }; - - logger.debug(`${this} Queued ${logPrefix} task`); - - // Queue and execute - this.modificationQueue.push(workFunction); } /** - * 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 + * Figures out added/removed ssrcs and send update IQs. + * @param oldSDP SDP object for old description. + * @param newSDP SDP object for new description. */ - _processNewJingleOfferIq(offerIq) { - const remoteSdp = new SDP(''); + notifyMySSRCUpdate(oldSDP, newSDP) { + if (this.state !== JingleSessionState.ACTIVE) { + logger.warn(`${this} Skipping SSRC update in '${this.state} ' state.`); - if (this.webrtcIceTcpDisable) { - remoteSdp.removeTcpCandidates = true; - } - if (this.webrtcIceUdpDisable) { - remoteSdp.removeUdpCandidates = true; + return; } - if (this.failICE) { - remoteSdp.failICE = true; + + if (!this.connection.connected) { + // The goal is to compare the oldest SDP with the latest one upon reconnect + if (!this._cachedOldLocalSdp) { + this._cachedOldLocalSdp = oldSDP; + } + this._cachedNewLocalSdp = newSDP; + logger.warn(`${this} Not sending SSRC update while the signaling is disconnected`); + + return; } - remoteSdp.fromJingle(offerIq); - this.readSsrcInfo($(offerIq).find('>content')); + this._cachedOldLocalSdp = undefined; + this._cachedNewLocalSdp = undefined; - return remoteSdp; - } + const getSignaledSourceInfo = sdpDiffer => { + const newMedia = sdpDiffer.getNewMedia(); + let ssrcs = []; + let mediaType = null; - /** - * 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.peerconnection.remoteDescription.sdp); - let ssrcs; + // It is assumed that sources are signaled one at a time. + Object.keys(newMedia).forEach(mediaIndex => { + const signaledSsrcs = Object.keys(newMedia[mediaIndex].ssrcs); - removeSsrcInfo.forEach(lines => { - // eslint-disable-next-line no-param-reassign - lines = lines.split('\r\n'); - lines.pop(); // remove empty last element; - ssrcs = lines.map(line => Number(line.split('a=ssrc:')[1]?.split(' ')[0])); + mediaType = newMedia[mediaIndex].mediaType; + if (signaledSsrcs?.length) { + ssrcs = ssrcs.concat(signaledSsrcs); + } + }); - let mid; + return { + mediaType, + ssrcs + }; + }; - lines.forEach(line => { - mid = remoteSdp.media.findIndex(mLine => mLine.includes(line)); - if (mid > -1) { - remoteSdp.media[mid] = remoteSdp.media[mid].replace(`${line}\r\n`, ''); - if (this.isP2P) { - const mediaType = SDPUtil.parseMLine(remoteSdp.media[mid].split('\r\n')[0])?.media; - const desiredDirection = this.peerconnection.getDesiredMediaDirection(mediaType, false); + // send source-remove IQ. + let sdpDiffer = new SDPDiffer(newSDP, oldSDP, this.isP2P); + const remove = $iq({ to: this.remoteJid, + type: 'set' }) + .c('jingle', { + xmlns: 'urn:xmpp:jingle:1', + action: 'source-remove', + initiator: this.initiatorJid, + sid: this.sid + } + ); - [ MediaDirection.SENDRECV, MediaDirection.SENDONLY ].forEach(direction => { - remoteSdp.media[mid] = remoteSdp.media[mid] - .replace(`a=${direction}`, `a=${desiredDirection}`); - }); - } else { - // Jvb connections will have direction set to 'sendonly' for the remote sources. - remoteSdp.media[mid] = remoteSdp.media[mid] - .replace(`a=${MediaDirection.SENDONLY}`, `a=${MediaDirection.INACTIVE}`); + sdpDiffer.toJingle(remove); - // Reject the m-line so that the browser removes the associated transceiver from the list - // of available transceivers. This will prevent the client from trying to re-use these - // inactive transceivers when additional video sources are added to the peerconnection. - const { media, port } = SDPUtil.parseMLine(remoteSdp.media[mid].split('\r\n')[0]); + // context a common object for one run of ssrc update (source-add and source-remove) so we can match them if we + // need to + const ctx = {}; + const removedSsrcInfo = getSignaledSourceInfo(sdpDiffer); - remoteSdp.media[mid] = remoteSdp.media[mid].replace(`m=${media} ${port}`, `m=${media} 0`); - } - } - }); - }); + if (removedSsrcInfo.ssrcs.length) { + // Log only the SSRCs instead of the full IQ. + logger.info(`${this} Sending source-remove for ${removedSsrcInfo.mediaType}` + + ` ssrcs=${removedSsrcInfo.ssrcs}`); + this.connection.sendIQ( + remove, + () => { + this.room.eventEmitter.emit(XMPPEvents.SOURCE_REMOVE, this, ctx); + }, + this.newJingleErrorHandler(remove, error => { + this.room.eventEmitter.emit(XMPPEvents.SOURCE_REMOVE_ERROR, this, error, ctx); + }), + IQ_TIMEOUT); + } - // Update the ssrc owners list. - ssrcs?.length && this._signalingLayer.removeSSRCOwners(ssrcs); + // send source-add IQ. + sdpDiffer = new SDPDiffer(oldSDP, newSDP, this.isP2P); + const add = $iq({ to: this.remoteJid, + type: 'set' }) + .c('jingle', { + xmlns: 'urn:xmpp:jingle:1', + action: 'source-add', + initiator: this.initiatorJid, + sid: this.sid + } + ); - remoteSdp.raw = remoteSdp.session + remoteSdp.media.join(''); + sdpDiffer.toJingle(add); + const addedSsrcInfo = getSignaledSourceInfo(sdpDiffer); - return remoteSdp; + if (addedSsrcInfo.ssrcs.length) { + // Log only the SSRCs instead of the full IQ. + logger.info(`${this} Sending source-add for ${addedSsrcInfo.mediaType} ssrcs=${addedSsrcInfo.ssrcs}`); + this.connection.sendIQ( + add, + () => { + this.room.eventEmitter.emit(XMPPEvents.SOURCE_ADD, this, ctx); + }, + this.newJingleErrorHandler(add, error => { + this.room.eventEmitter.emit(XMPPEvents.SOURCE_ADD_ERROR, this, error, addedSsrcInfo.mediaType, ctx); + }), + IQ_TIMEOUT); + } } /** - * 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 + * + * @param reasonCondition + * @param reasonText */ - _processRemoteAddSource(addSsrcInfo) { - let remoteSdp = new SDP(this.peerconnection.remoteDescription.sdp); + onTerminated(reasonCondition, reasonText) { + // Do something with reason and reasonCondition when we start to care + // this.reasonCondition = reasonCondition; + // this.reasonText = reasonText; + logger.info(`${this} Session terminated`, reasonCondition, reasonText); - // Add a new m-line in the remote description if the source info for a secondary video source is recceived from - // the remote p2p peer when multi-stream support is enabled. - if (addSsrcInfo.length > remoteSdp.media.length && this.isP2P) { - remoteSdp.addMlineForNewLocalSource(MediaType.VIDEO); - remoteSdp = new SDP(remoteSdp.raw); + this._xmppListeners.forEach(removeListener => removeListener()); + this._xmppListeners = []; + + if (this._removeSenderVideoConstraintsChangeListener) { + this._removeSenderVideoConstraintsChangeListener(); } - addSsrcInfo.forEach((lines, idx) => { - remoteSdp.media[idx] += lines; - // Make sure to change the direction to 'sendrecv/sendonly' only for p2p connections. For jvb connections, - // a new m-line is added for the new remote sources. - if (this.isP2P) { - const mediaType = SDPUtil.parseMLine(remoteSdp.media[idx].split('\r\n')[0])?.media; - const desiredDirection = this.peerconnection.getDesiredMediaDirection(mediaType, true); + if (FeatureFlags.isSsrcRewritingSupported() && this.peerconnection) { + this.peerconnection.getRemoteTracks().forEach(track => { + this.room.eventEmitter.emit(JitsiTrackEvents.TRACK_REMOVED, track); + }); + } - [ MediaDirection.RECVONLY, MediaDirection.INACTIVE ].forEach(direction => { - remoteSdp.media[idx] = remoteSdp.media[idx] - .replace(`a=${direction}`, `a=${desiredDirection}`); - }); - } - }); - remoteSdp.raw = remoteSdp.session + remoteSdp.media.join(''); + this.close(); + } - return remoteSdp; + /** + * Handles XMPP connection state changes. + * + * @param {XmppConnection.Status} status - The new status. + */ + onXmppStatusChanged(status) { + if (status === XmppConnection.Status.CONNECTED && this._cachedOldLocalSdp) { + logger.info(`${this} Sending SSRC update on reconnect`); + this.notifyMySSRCUpdate( + this._cachedOldLocalSdp, + this._cachedNewLocalSdp); + } } /** - * Does a new offer/answer flow using the existing remote description (if not provided) and signals any new sources - * to Jicofo or the remote peer. + * Processes the source map message received from the bridge and creates a new remote track for newly signaled + * SSRCs or updates the source-name and owner on the remote track for an existing SSRC. * - * @param {string} [optionalRemoteSdp] optional, raw 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} + * @param {Object} message - The source map message. + * @param {string} mediaType - The media type, 'audio' or 'video'. + * @returns {void} */ - _renegotiate(optionalRemoteSdp) { - if (this.peerconnection.signalingState === 'closed') { - const error = new Error('Attempted to renegotiate in state closed'); + processSourceMap(message, mediaType) { + if (!FeatureFlags.isSsrcRewritingSupported()) { + return; + } + const newSsrcs = []; - this.room.eventEmitter.emit(XMPPEvents.RENEGOTIATION_FAILED, error, this); + for (const src of message.mappedSources) { + const { owner, source, ssrc } = src; + const isNewSsrc = this.peerconnection.addRemoteSsrc(ssrc, source); - return Promise.reject(error); - } + if (isNewSsrc) { + newSsrcs.push(src); + logger.debug(`New SSRC signaled ${ssrc}: owner=${owner}, source-name=${source}`); - const remoteSdp = optionalRemoteSdp || this.peerconnection.remoteDescription.sdp; + // Check if there is an old mapping for the given source and clear the owner on the associated track. + const oldSsrc = this.peerconnection.remoteSources.get(source); - if (!remoteSdp) { - const error = new Error(`Can not renegotiate without remote description, current state: ${this.state}`); + if (oldSsrc) { + this._signalingLayer.removeSSRCOwners([ oldSsrc ]); + const track = this.peerconnection.getTrackBySSRC(oldSsrc); - this.room.eventEmitter.emit(XMPPEvents.RENEGOTIATION_FAILED, error, this); + if (track) { + this.room.eventEmitter.emit(JitsiTrackEvents.TRACK_OWNER_SET, track); + } + } + } else { + const track = this.peerconnection.getTrackBySSRC(ssrc); - return Promise.reject(error); + if (!track || (track.getParticipantId() === owner && track.getSourceName() === source)) { + !track && logger.warn(`Remote track for SSRC=${ssrc} hasn't been created yet,` + + 'not processing the source map'); + continue; // eslint-disable-line no-continue + } + logger.debug(`Existing SSRC re-mapped ${ssrc}: new owner=${owner}, source-name=${source}`); + + this._signalingLayer.setSSRCOwner(ssrc, owner, source); + + // Update the muted state and the video type on the track since the presence for this track could have + // been received before the updated source map is received on the bridge channel. + const { muted, videoType } = this._signalingLayer.getPeerMediaInfo(owner, mediaType, source); + + muted && this.peerconnection._sourceMutedChanged(source, muted); + this.room.eventEmitter.emit(JitsiTrackEvents.TRACK_OWNER_SET, track, owner, source, videoType); + } } - const remoteDescription = new RTCSessionDescription({ - type: 'offer', - sdp: remoteSdp - }); + // Add the new SSRCs to the remote description by generating a source message. + if (newSsrcs.length) { + let node = $build('content', { + xmlns: 'urn:xmpp:jingle:1', + name: mediaType + }).c('description', { + xmlns: XEP.RTP_MEDIA, + media: mediaType + }); - const oldLocalSDP = this.peerconnection.localDescription.sdp; + for (const src of newSsrcs) { + const { rtx, ssrc, source } = src; + let msid; - logger.debug(`${this} Renegotiate: setting remote description`); + if (mediaType === MediaType.VIDEO) { + const idx = ++this.numRemoteVideoSources; - return this.peerconnection.setRemoteDescription(remoteDescription) - .then(() => { - logger.debug(`${this} Renegotiate: creating answer`); + msid = `remote-video-${idx} remote-video-${idx}`; - return this.peerconnection.createAnswer(this.mediaConstraints); - }) - .then(answer => { - logger.debug(`${this} Renegotiate: setting local description`); + if (rtx !== '-1') { + _addSourceElement(node, src, rtx, msid); + node.c('ssrc-group', { + xmlns: XEP.SOURCE_ATTRIBUTES, + semantics: 'FID' + }) + .c('source', { + xmlns: XEP.SOURCE_ATTRIBUTES, + ssrc + }) + .up() + .c('source', { + xmlns: XEP.SOURCE_ATTRIBUTES, + ssrc: rtx + }) + .up() + .up(); + } + } else { + const idx = ++this.numRemoteAudioSources; - return this.peerconnection.setLocalDescription(answer); - }) - .then(() => { - if (oldLocalSDP) { - // Send the source updates after every renegotiation cycle. - this.notifyMySSRCUpdate(new SDP(oldLocalSDP), new SDP(this.peerconnection.localDescription.sdp)); + msid = `remote-audio-${idx} remote-audio-${idx}`; } - }); + _addSourceElement(node, src, ssrc, msid); + this.peerconnection.remoteSources.set(source, ssrc); + } + node = node.up(); + this._addOrRemoveRemoteStream(true /* add */, node.node); + } } /** - * Adds a new track to the peerconnection. This method needs to be called only when a secondary JitsiLocalTrack is - * being added to the peerconnection for the first time. * - * @param {Array} localTracks - Tracks to be added to the peer connection. - * @returns {Promise} that resolves when the track is successfully added to the peerconnection, rejected - * otherwise. + * @param contents */ - addTracks(localTracks = null) { - if (!localTracks?.length) { - Promise.reject(new Error('No tracks passed')); - } - - if (localTracks.find(track => track.getType() !== MediaType.VIDEO)) { - return Promise.reject(new Error('Multiple tracks of the given media type are not supported')); - } + readSsrcInfo(contents) { + const ssrcs = $(contents).find('>description>source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]'); - const replaceTracks = []; - const workFunction = finishedCallback => { - const remoteSdp = new SDP(this.peerconnection.peerconnection.remoteDescription.sdp); - const recvOnlyTransceiver = this.peerconnection.peerconnection.getTransceivers() - .find(t => t.receiver.track.kind === MediaType.VIDEO - && t.direction === MediaDirection.RECVONLY - && t.currentDirection === MediaDirection.RECVONLY); + ssrcs.each((i, ssrcElement) => { + const ssrc = Number(ssrcElement.getAttribute('ssrc')); + let sourceName; - // Add transceivers by adding a new mline in the remote description for each track. Do not create a new - // m-line if a recv-only transceiver exists in the p2p case. The new track will be attached to the - // existing one in that case. - for (const track of localTracks) { - if (!this.isP2P || !recvOnlyTransceiver) { - remoteSdp.addMlineForNewLocalSource(track.getType()); - } + if (ssrcElement.hasAttribute('name')) { + sourceName = ssrcElement.getAttribute('name'); } - this._renegotiate(remoteSdp.raw) - .then(() => { - // Replace the tracks on the newly generated transceivers. - for (const track of localTracks) { - replaceTracks.push(this.peerconnection.replaceTrack(null, track)); - } - - return Promise.all(replaceTracks); - }) - - // Trigger a renegotiation here since renegotiations are suppressed at TPC.replaceTrack for screenshare - // tracks. This is done here so that presence for screenshare tracks is sent before signaling. - .then(() => this._renegotiate()) - .then(() => finishedCallback(), error => finishedCallback(error)); - }; - - return new Promise((resolve, reject) => { - logger.debug(`${this} Queued renegotiation after addTrack`); - - this.modificationQueue.push( - workFunction, - error => { - if (error) { - if (error instanceof ClearedQueueError) { - // The session might have been terminated before the task was executed, making it obsolete. - logger.debug(`${this} renegotiation after addTrack aborted: session terminated`); - resolve(); + if (this.isP2P) { + // In P2P all SSRCs are owner by the remote peer + this._signalingLayer.setSSRCOwner(ssrc, Strophe.getResourceFromJid(this.remoteJid), sourceName); + } else { + $(ssrcElement) + .find('>ssrc-info[xmlns="http://jitsi.org/jitmeet"]') + .each((i3, ssrcInfoElement) => { + const owner = ssrcInfoElement.getAttribute('owner'); - return; + if (owner?.length) { + if (isNaN(ssrc) || ssrc < 0) { + logger.warn(`${this} Invalid SSRC ${ssrc} value received for ${owner}`); + } else { + this._signalingLayer.setSSRCOwner(ssrc, getEndpointId(owner), sourceName); + } } - logger.error(`${this} renegotiation after addTrack error`, error); - reject(error); - } else { - logger.debug(`${this} renegotiation after addTrack executed - OK`); - resolve(); - } - }); + }); + } }); } /** - * Resumes or suspends media transfer over the underlying peer connection. - * - * @param {boolean} active - true to enable media transfer or false to suspend media transmission - * @returns {Promise} + * Handles a Jingle source-remove message for this Jingle session. + * @param elem An array of Jingle "content" elements. */ - setMediaTransferActive(active) { - const changed = this.peerconnection.audioTransferActive !== active - || this.peerconnection.videoTransferActive !== active; + removeRemoteStream(elem) { + this._addOrRemoveRemoteStream(false /* remove */, elem); + } - if (!changed) { - return Promise.resolve(); - } + /** + * Handles the deletion of SSRCs associated with a remote user from the remote description when the user leaves. + * + * @param {string} id Endpoint id of the participant that has left the call. + * @returns {void} + */ + removeRemoteStreamsOnLeave(id) { + const workFunction = finishCallback => { + const removeSsrcInfo = this.peerconnection.getRemoteSourceInfoByParticipant(id); - return this.peerconnection.tpcUtils.setMediaTransferActive(active) - .then(() => { - this.peerconnection.audioTransferActive = active; - this.peerconnection.videoTransferActive = active; + if (removeSsrcInfo.length) { + const newRemoteSdp = this._processRemoteRemoveSource(removeSsrcInfo); - // Reconfigure the audio and video tracks so that only the correct encodings are active. - const promises = []; + this._renegotiate(newRemoteSdp.raw) + .then(() => finishCallback(), error => finishCallback(error)); + } else { + finishCallback(); + } + }; - promises.push(this.peerconnection.configureVideoSenderEncodings()); - promises.push(this.peerconnection.configureAudioSenderEncodings()); + logger.debug(`${this} Queued removeRemoteStreamsOnLeave task for participant ${id}`); - return Promise.allSettled(promises); + this.modificationQueue.push( + workFunction, + error => { + if (error) { + logger.error(`${this} removeRemoteStreamsOnLeave error:`, error); + } else { + logger.info(`${this} removeRemoteStreamsOnLeave done!`); + } }); } + /** + * Remove local track as part of the mute operation. + * @param {JitsiLocalTrack} track the local track to be removed + * @return {Promise} a promise which will be resolved once the local track + * is removed from this session and the renegotiation is performed. + * The promise will be rejected with a string that the describes + * the error if anything goes wrong. + */ + removeTrackFromPc(track) { + return this._addRemoveTrack(true /* remove */, track); + } + /** * Replaces oldTrack with newTrack and performs a single * offer/answer cycle after both operations are done. Either @@ -2073,445 +1923,593 @@ export default class JingleSessionPC extends JingleSession { } /** - * 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 + * Will send 'content-modify' IQ in order to ask the remote peer to + * either stop or resume sending video media or to adjust sender's video constraints. + * @private + */ + sendContentModify() { + const senders = this._localSendReceiveVideoActive ? 'both' : 'none'; + const sessionModify + = $iq({ + to: this.remoteJid, + type: 'set' + }) + .c('jingle', { + xmlns: 'urn:xmpp:jingle:1', + action: 'content-modify', + initiator: this.initiatorJid, + sid: this.sid + }) + .c('content', { + name: MediaType.VIDEO, + senders + }); + + if (typeof this._sourceReceiverConstraints !== 'undefined') { + this._sourceReceiverConstraints.forEach((maxHeight, sourceName) => { + sessionModify + .c('source-frame-height', { xmlns: 'http://jitsi.org/jitmeet/video' }) + .attrs({ + sourceName, + maxHeight + }); + + sessionModify.up(); + logger.info(`${this} sending content-modify for source-name: ${sourceName}, maxHeight: ${maxHeight}`); + }); + } + + logger.debug(sessionModify.tree()); + + this.connection.sendIQ( + sessionModify, + null, + this.newJingleErrorHandler(sessionModify), + IQ_TIMEOUT); + } + + /** + * Sends given candidate in Jingle 'transport-info' message. + * @param {RTCIceCandidate} candidate the WebRTC ICE candidate instance + * @private + */ + sendIceCandidate(candidate) { + const localSDP = new SDP(this.peerconnection.localDescription.sdp); + + if (candidate && candidate.candidate.length && !this.lasticecandidate) { + const ice = SDPUtil.iceparams(localSDP.media[candidate.sdpMLineIndex], localSDP.session); + const jcand = SDPUtil.candidateToJingle(candidate.candidate); + + if (!(ice && jcand)) { + logger.error('failed to get ice && jcand'); + + return; + } + ice.xmlns = XEP.ICE_UDP_TRANSPORT; + + if (this.usedrip) { + if (this.dripContainer.length === 0) { + setTimeout(() => { + if (this.dripContainer.length === 0) { + return; + } + this.sendIceCandidates(this.dripContainer); + this.dripContainer = []; + }, ICE_CAND_GATHERING_TIMEOUT); + } + this.dripContainer.push(candidate); + } else { + this.sendIceCandidates([ candidate ]); + } + } else { + logger.log(`${this} sendIceCandidate: last candidate`); + + // FIXME: remember to re-think in ICE-restart + this.lasticecandidate = true; + } + } + + /** + * Sends given candidates in Jingle 'transport-info' message. + * @param {Array} candidates an array of the WebRTC ICE + * candidate instances + * @private */ - _parseSsrcInfoFromSourceRemove(sourceRemoveElem, currentRemoteSdp) { - const removeSsrcInfo = []; + sendIceCandidates(candidates) { + if (!this._assertNotEnded('sendIceCandidates')) { - $(sourceRemoveElem).each((i1, content) => { - const name = $(content).attr('name'); - let lines = ''; + return; + } - $(content) - .find('ssrc-group[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]') - .each(function() { - /* eslint-disable no-invalid-this */ - const semantics = this.getAttribute('semantics'); - const ssrcs - = $(this) - .find('>source') - .map(function() { - return this.getAttribute('ssrc'); - }) - .get(); + logger.log(`${this} sendIceCandidates ${JSON.stringify(candidates)}`); + const cand = $iq({ to: this.remoteJid, + type: 'set' }) + .c('jingle', { xmlns: 'urn:xmpp:jingle:1', + action: 'transport-info', + initiator: this.initiatorJid, + sid: this.sid }); - if (ssrcs.length) { - lines - += `a=ssrc-group:${semantics} ${ - ssrcs.join(' ')}\r\n`; - } + const localSDP = new SDP(this.peerconnection.localDescription.sdp); - /* eslint-enable no-invalid-this */ - }); - const ssrcs = []; + for (let mid = 0; mid < localSDP.media.length; mid++) { + const cands = candidates.filter(el => el.sdpMLineIndex === mid); + const mline + = SDPUtil.parseMLine(localSDP.media[mid].split('\r\n')[0]); - // handles both >source and >description>source versions - const tmp - = $(content).find( - 'source[xmlns="urn:xmpp:jingle:apps:rtp:ssma:0"]'); + if (cands.length > 0) { + const ice + = SDPUtil.iceparams(localSDP.media[mid], localSDP.session); - tmp.each(function() { - // eslint-disable-next-line no-invalid-this - const ssrc = $(this).attr('ssrc'); + ice.xmlns = XEP.ICE_UDP_TRANSPORT; + cand.c('content', { + creator: this.initiatorJid === 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); - ssrcs.push(ssrc); - }); - currentRemoteSdp.media.forEach((media, i2) => { - if (!SDPUtil.findLine(media, `a=mid:${name}`)) { - return; - } - if (!removeSsrcInfo[i2]) { - removeSsrcInfo[i2] = ''; - } - ssrcs.forEach(ssrc => { - const ssrcLines - = SDPUtil.findLines(media, `a=ssrc:${ssrc}`); + // Mangle ICE candidate if 'failICE' test option is enabled - if (ssrcLines.length) { - removeSsrcInfo[i2] += `${ssrcLines.join('\r\n')}\r\n`; + if (this.failICE) { + candidate.ip = '1.1.1.1'; } - }); - removeSsrcInfo[i2] += lines; - }); - }); + cand.c('candidate', candidate).up(); + } - return removeSsrcInfo; - } + // add fingerprint + const fingerprintLine + = SDPUtil.findLine( + localSDP.media[mid], + 'a=fingerprint:', localSDP.session); - /** - * Adds local track back to the peerconnection associated with this session. - * @param {JitsiLocalTrack} track - * @return {Promise} a promise that will resolve once the local track is added back to this session and - * renegotiation succeeds (if its warranted). Will be rejected with a string that provides some error - * details in case something goes wrong. - */ - addTrackToPc(track) { - return this._addRemoveTrack(false /* add */, track) - .then(() => { - // Configure the video encodings after the track is unmuted. If the user joins the call muted and - // unmutes it the first time, all the parameters need to be configured. - if (track.isVideoTrack()) { - return this.peerconnection.configureVideoSenderEncodings(track); + if (fingerprintLine) { + const tmp = SDPUtil.parseFingerprint(fingerprintLine); + + 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 + } + } - /** - * Remove local track as part of the mute operation. - * @param {JitsiLocalTrack} track the local track to be removed - * @return {Promise} a promise which will be resolved once the local track - * is removed from this session and the renegotiation is performed. - * The promise will be rejected with a string that the describes - * the error if anything goes wrong. - */ - removeTrackFromPc(track) { - return this._addRemoveTrack(true /* remove */, track); + // 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), IQ_TIMEOUT); } /** - * See {@link addTrackToPc} and {@link removeTrackFromPc}. - * @param {boolean} isRemove true for "remove" operation or false for "add" operation. - * @param {JitsiLocalTrack} track the track that will be added/removed + * 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. * @private */ - _addRemoveTrack(isRemove, track) { - if (!track) { - return Promise.reject('invalid "track" argument value'); - } - const operationName = isRemove ? 'removeTrack' : 'addTrack'; - const workFunction = finishedCallback => { - const tpc = this.peerconnection; - - if (!tpc) { - finishedCallback(`Error: tried ${operationName} track with no active peer connection`); + 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, this.isP2P); + const accept = $iq({ to: this.remoteJid, + type: 'set' }) + .c('jingle', { xmlns: 'urn:xmpp:jingle:1', + action: 'session-accept', + initiator: this.initiatorJid, + responder: this.responderJid, + sid: this.sid }); - return; - } - const operationPromise - = isRemove - ? tpc.removeTrackFromPc(track) - : tpc.addTrackToPc(track); + if (this.webrtcIceTcpDisable) { + localSDP.removeTcpCandidates = true; + } + if (this.webrtcIceUdpDisable) { + localSDP.removeUdpCandidates = true; + } + if (this.failICE) { + localSDP.failICE = true; + } + if (typeof this.options.channelLastN === 'number' && this.options.channelLastN >= 0) { + localSDP.initialLastN = this.options.channelLastN; + } + localSDP.toJingle( + accept, + this.initiatorJid === this.localJid ? 'initiator' : 'responder'); - operationPromise - .then(shouldRenegotiate => { - if (shouldRenegotiate) { - this._renegotiate().then(finishedCallback); - } else { - finishedCallback(); - } - }, - finishedCallback /* will be called with an error */); - }; + logger.info(`${this} Sending session-accept`); + logger.debug(accept.tree()); + this.connection.sendIQ(accept, + success, + this.newJingleErrorHandler(accept, error => { + failure(error); - logger.debug(`${this} Queued ${operationName} task`); + // 'session-accept' is a critical timeout and we'll + // have to restart + this.room.eventEmitter.emit( + XMPPEvents.SESSION_ACCEPT_TIMEOUT, this); + }), + IQ_TIMEOUT); - return new Promise((resolve, reject) => { - this.modificationQueue.push( - workFunction, - error => { - if (error) { - if (error instanceof ClearedQueueError) { - // The session might have been terminated before the task was executed, making it obsolete. - logger.debug(`${this} ${operationName} aborted: session terminated`); - resolve(); + // 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(); + } - return; - } - logger.error(`${this} ${operationName} failed`); - reject(error); - } else { - logger.debug(`${this} ${operationName} done`); - resolve(); - } - }); + /** + * Sends 'session-initiate' to the remote peer. + * + * NOTE this method is synchronous and we're not waiting for the RESULT + * response which would delay the startup process. + * + * @param {string} offerSdp - The local session description which will be + * used to generate an offer. + * @private + */ + sendSessionInitiate(offerSdp) { + let init = $iq({ + to: this.remoteJid, + type: 'set' + }).c('jingle', { + xmlns: 'urn:xmpp:jingle:1', + action: 'session-initiate', + initiator: this.initiatorJid, + sid: this.sid }); + + new SDP(offerSdp, this.isP2P).toJingle( + init, + this.isInitiator ? 'initiator' : 'responder'); + init = init.tree(); + logger.debug(`${this} Session-initiate: `, init); + this.connection.sendIQ(init, + () => { + logger.info(`${this} Got RESULT for "session-initiate"`); + }, + error => { + logger.error(`${this} "session-initiate" error`, error); + }, + IQ_TIMEOUT); } /** - * Resumes or suspends video media transfer over the p2p peer connection. + * Sets the answer received from the remote peer as the remote description. * - * @param {boolean} videoActive true to enable video media transfer or false to suspend video - * media transmission. - * @return {Promise} a Promise which will resolve once the operation is done. It will be rejected with - * an error description as a string in case anything goes wrong. + * @param jingleAnswer */ - setP2pVideoTransferActive(videoActive) { - if (!this.peerconnection) { - return Promise.reject('Can not modify video transfer active state,' - + ' before "initialize" is called'); + setAnswer(jingleAnswer) { + if (!this.isInitiator) { + throw new Error('Trying to set an answer on the responder session'); } + logger.debug(`${this} Executing setAnswer task`); - if (this._localSendReceiveVideoActive !== videoActive) { - this._localSendReceiveVideoActive = videoActive; - if (this.isP2P && this.state === JingleSessionState.ACTIVE) { - this.sendContentModify(); - } + const newRemoteSdp = this._processNewJingleOfferIq(jingleAnswer); + const oldLocalSdp = new SDP(this.peerconnection.localDescription.sdp); + const remoteDescription = new RTCSessionDescription({ + type: 'answer', + sdp: newRemoteSdp.raw + }); - return this.peerconnection - .setVideoTransferActive(this._localSendReceiveVideoActive && this._remoteSendReceiveVideoActive); - } + this.peerconnection.setRemoteDescription(remoteDescription) + .then(() => { + if (this.state === JingleSessionState.PENDING) { + this.state = JingleSessionState.ACTIVE; - return Promise.resolve(); + // Start processing tasks on the modification queue. + logger.debug(`${this} Resuming the modification queue after session is established!`); + this.modificationQueue.resume(); + const newLocalSdp = new SDP(this.peerconnection.localDescription.sdp); + + this.sendContentModify(); + this.notifyMySSRCUpdate(oldLocalSdp, newLocalSdp); + } + }) + .then(() => { + logger.debug(`${this} setAnswer task done`); + }) + .catch(error => { + logger.error(`${this} setAnswer task failed: ${error}`); + }); } /** - * Enables/disables local video based on 'senders' attribute of the video conent in 'content-modify' IQ sent by the - * remote peer. Also, checks if the sourceMaxFrameHeight (as requested by the p2p peer) or the senders attribute of - * the video content has changed and modifies the local video resolution accordingly. + * Resumes or suspends media transfer over the underlying peer connection. + * + * @param {boolean} active - true to enable media transfer or false to suspend media transmission + * @returns {Promise} */ - modifyContents(jingleContents) { - const newVideoSenders = JingleSessionPC.parseVideoSenders(jingleContents); - const sourceMaxFrameHeights = JingleSessionPC.parseSourceMaxFrameHeight(jingleContents); - - if (sourceMaxFrameHeights) { - this.remoteSourceMaxFrameHeights = sourceMaxFrameHeights; - this.eventEmitter.emit(MediaSessionEvents.REMOTE_SOURCE_CONSTRAINTS_CHANGED, this, sourceMaxFrameHeights); - } - - if (newVideoSenders === null) { - logger.error(`${this} - failed to parse video "senders" attribute in "content-modify" action`); + setMediaTransferActive(active) { + const changed = this.peerconnection.audioTransferActive !== active + || this.peerconnection.videoTransferActive !== active; - return; + if (!changed) { + return Promise.resolve(); } - if (!this._assertNotEnded()) { - return; - } + return this.peerconnection.tpcUtils.setMediaTransferActive(active) + .then(() => { + this.peerconnection.audioTransferActive = active; + this.peerconnection.videoTransferActive = active; - const isRemoteVideoActive - = newVideoSenders === 'both' - || (newVideoSenders === 'initiator' && this.isInitiator) - || (newVideoSenders === 'responder' && !this.isInitiator); + // Reconfigure the audio and video tracks so that only the correct encodings are active. + const promises = []; - if (isRemoteVideoActive !== this._remoteSendReceiveVideoActive) { - logger.debug(`${this} new remote video active: ${isRemoteVideoActive}`); - this._remoteSendReceiveVideoActive = isRemoteVideoActive; + promises.push(this.peerconnection.configureVideoSenderEncodings()); + promises.push(this.peerconnection.configureAudioSenderEncodings()); - this.peerconnection - .setVideoTransferActive(this._localSendReceiveVideoActive && this._remoteSendReceiveVideoActive); - } + return Promise.allSettled(promises); + }); } /** - * Figures out added/removed ssrcs and send update IQs. - * @param oldSDP SDP object for old description. - * @param newSDP SDP object for new description. + * 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 jingleOfferAnswerIq jQuery selector pointing to the jingle element of the offer (or answer) 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. + * @param {Array} [localTracks] the optional list of the local tracks that will be added, before + * the offer/answer cycle executes (for the local track addition to be an atomic operation together with the + * offer/answer). */ - notifyMySSRCUpdate(oldSDP, newSDP) { - if (this.state !== JingleSessionState.ACTIVE) { - logger.warn(`${this} Skipping SSRC update in '${this.state} ' state.`); - - return; - } + setOfferAnswerCycle(jingleOfferAnswerIq, success, failure, localTracks = []) { + logger.debug(`${this} Executing setOfferAnswerCycle task`); - if (!this.connection.connected) { - // The goal is to compare the oldest SDP with the latest one upon reconnect - if (!this._cachedOldLocalSdp) { - this._cachedOldLocalSdp = oldSDP; - } - this._cachedNewLocalSdp = newSDP; - logger.warn(`${this} Not sending SSRC update while the signaling is disconnected`); + const addTracks = []; + const audioTracks = localTracks.filter(track => track.getType() === MediaType.AUDIO); + const videoTracks = localTracks.filter(track => track.getType() === MediaType.VIDEO); + let tracks = localTracks; - return; + // Add only 1 video track at a time. Adding 2 or more video tracks to the peerconnection at the same time + // makes the browser go into a renegotiation loop by firing 'negotiationneeded' event after every + // renegotiation. + if (videoTracks.length > 1) { + tracks = [ ...audioTracks, videoTracks[0] ]; } + for (const track of tracks) { + addTracks.push(this.peerconnection.addTrack(track, this.isInitiator)); + } + const newRemoteSdp = this._processNewJingleOfferIq(jingleOfferAnswerIq); + const bridgeSession = $(jingleOfferAnswerIq).find('>bridge-session[xmlns="http://jitsi.org/protocol/focus"]'); + const bridgeSessionId = bridgeSession.attr('id'); - this._cachedOldLocalSdp = undefined; - this._cachedNewLocalSdp = undefined; - - const getSignaledSourceInfo = sdpDiffer => { - const newMedia = sdpDiffer.getNewMedia(); - let ssrcs = []; - let mediaType = null; + if (bridgeSessionId !== this._bridgeSessionId) { + this._bridgeSessionId = bridgeSessionId; + } - // It is assumed that sources are signaled one at a time. - Object.keys(newMedia).forEach(mediaIndex => { - const signaledSsrcs = Object.keys(newMedia[mediaIndex].ssrcs); + Promise.all(addTracks) + .then(() => this._renegotiate(newRemoteSdp.raw)) + .then(() => { + this.peerconnection.processLocalSdpForTransceiverInfo(tracks); + if (this.state === JingleSessionState.PENDING) { + this.state = JingleSessionState.ACTIVE; - mediaType = newMedia[mediaIndex].mediaType; - if (signaledSsrcs?.length) { - ssrcs = ssrcs.concat(signaledSsrcs); + // #1 Sync up video transfer active/inactive only after the initial O/A cycle. We want to + // adjust the video media direction only in the local SDP and the Jingle contents direction + // included in the initial offer/answer is mapped to the remote SDP. Jingle 'content-modify' + // IQ is processed in a way that it will only modify local SDP when remote peer is no longer + // interested in receiving video content. Changing media direction in the remote SDP will mess + // up our SDP translation chain (simulcast, video mute, RTX etc.) + // #2 Sends the max frame height if it was set, before the session-initiate/accept + if (this.isP2P && (!this._localSendReceiveVideoActive || this._sourceReceiverConstraints)) { + this.sendContentModify(); + } } - }); - return { - mediaType, - ssrcs - }; - }; - - // send source-remove IQ. - let sdpDiffer = new SDPDiffer(newSDP, oldSDP, this.isP2P); - const remove = $iq({ to: this.remoteJid, - type: 'set' }) - .c('jingle', { - xmlns: 'urn:xmpp:jingle:1', - action: 'source-remove', - initiator: this.initiatorJid, - sid: this.sid - } - ); + }) + .then(() => { + logger.debug(`${this} setOfferAnswerCycle task done`); + success(); + }) + .catch(error => { + logger.error(`${this} setOfferAnswerCycle task failed: ${error}`); + failure(error); + }); + } - sdpDiffer.toJingle(remove); + /** + * Resumes or suspends video media transfer over the p2p peer connection. + * + * @param {boolean} videoActive true to enable video media transfer or false to suspend video + * media transmission. + * @return {Promise} a Promise which will resolve once the operation is done. It will be rejected with + * an error description as a string in case anything goes wrong. + */ + setP2pVideoTransferActive(videoActive) { + if (!this.peerconnection) { + return Promise.reject('Can not modify video transfer active state,' + + ' before "initialize" is called'); + } - // context a common object for one run of ssrc update (source-add and source-remove) so we can match them if we - // need to - const ctx = {}; - const removedSsrcInfo = getSignaledSourceInfo(sdpDiffer); + if (this._localSendReceiveVideoActive !== videoActive) { + this._localSendReceiveVideoActive = videoActive; + if (this.isP2P && this.state === JingleSessionState.ACTIVE) { + this.sendContentModify(); + } - if (removedSsrcInfo.ssrcs.length) { - // Log only the SSRCs instead of the full IQ. - logger.info(`${this} Sending source-remove for ${removedSsrcInfo.mediaType}` - + ` ssrcs=${removedSsrcInfo.ssrcs}`); - this.connection.sendIQ( - remove, - () => { - this.room.eventEmitter.emit(XMPPEvents.SOURCE_REMOVE, this, ctx); - }, - this.newJingleErrorHandler(remove, error => { - this.room.eventEmitter.emit(XMPPEvents.SOURCE_REMOVE_ERROR, this, error, ctx); - }), - IQ_TIMEOUT); + return this.peerconnection + .setVideoTransferActive(this._localSendReceiveVideoActive && this._remoteSendReceiveVideoActive); } - // send source-add IQ. - sdpDiffer = new SDPDiffer(oldSDP, newSDP, this.isP2P); - const add = $iq({ to: this.remoteJid, - type: 'set' }) - .c('jingle', { - xmlns: 'urn:xmpp:jingle:1', - action: 'source-add', - initiator: this.initiatorJid, - sid: this.sid - } - ); + return Promise.resolve(); + } - sdpDiffer.toJingle(add); - const addedSsrcInfo = getSignaledSourceInfo(sdpDiffer); + /** + * Adjust the preference for max video frame height that the local party is willing to receive. Signals + * the remote p2p peer. + * + * @param {Map} sourceReceiverConstraints - The receiver constraints per source. + */ + setReceiverVideoConstraint(sourceReceiverConstraints) { + logger.info(`${this} setReceiverVideoConstraint - constraints: ${JSON.stringify(sourceReceiverConstraints)}`); + this._sourceReceiverConstraints = sourceReceiverConstraints; - if (addedSsrcInfo.ssrcs.length) { - // Log only the SSRCs instead of the full IQ. - logger.info(`${this} Sending source-add for ${addedSsrcInfo.mediaType} ssrcs=${addedSsrcInfo.ssrcs}`); - this.connection.sendIQ( - add, - () => { - this.room.eventEmitter.emit(XMPPEvents.SOURCE_ADD, this, ctx); - }, - this.newJingleErrorHandler(add, error => { - this.room.eventEmitter.emit(XMPPEvents.SOURCE_ADD_ERROR, this, error, addedSsrcInfo.mediaType, ctx); - }), - IQ_TIMEOUT); + if (this.isP2P) { + // Tell the remote peer about our receive constraint. If Jingle session is not yet active the state will + // be synced after offer/answer. + if (this.state === JingleSessionState.ACTIVE) { + this.sendContentModify(); + } } } /** - * 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: {this JingleSessionPC.toString()} - * } - * @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)} + * Sets the resolution constraint on the local camera track. + * @param {number} maxFrameHeight - The user preferred max frame height. + * @param {string} sourceName - The source name of the track. + * @returns {Promise} promise that will be resolved when the operation is + * successful and rejected otherwise. */ - newJingleErrorHandler(request, failureCb) { - return errResponse => { - - const error = {}; + setSenderVideoConstraint(maxFrameHeight, sourceName = null) { + if (this._assertNotEnded()) { + logger.info(`${this} setSenderVideoConstraint: ${maxFrameHeight}, sourceName: ${sourceName}`); - // Get XMPP error code and condition(reason) - const errorElSel = $(errResponse).find('error'); + const jitsiLocalTrack = sourceName + ? this.rtc.getLocalVideoTracks().find(track => track.getSourceName() === sourceName) + : this.rtc.getLocalVideoTrack(); - if (errorElSel.length) { - error.code = errorElSel.attr('code'); - const errorReasonSel = $(errResponse).find('error :first'); + return this.peerconnection.setSenderVideoConstraints(maxFrameHeight, jitsiLocalTrack); + } - if (errorReasonSel.length) { - error.reason = errorReasonSel[0].tagName; - } + return Promise.resolve(); + } - const errorMsgSel = errorElSel.find('>text'); + /** + * Updates the codecs on the peerconnection and initiates a renegotiation for the + * new codec config to take effect. + * + * @param {Array} codecList - Preferred codecs for video. + * @param {CodecMimeType} screenshareCodec - The preferred screenshare codec. + */ + setVideoCodecs(codecList, screenshareCodec) { + if (this._assertNotEnded()) { + logger.info(`${this} setVideoCodecs: codecList=${codecList}, screenshareCodec=${screenshareCodec}`); + this.peerconnection.setVideoCodecs(codecList, screenshareCodec); - if (errorMsgSel.length) { - error.msg = errorMsgSel.text(); - } + // Browser throws an error when H.264 is set on the encodings. Therefore, munge the SDP when H.264 needs to + // be selected. + // TODO: Remove this check when the above issue is fixed. + if (this.usesCodecSelectionAPI && codecList[0] !== CodecMimeType.H264) { + return; } - if (!errResponse) { - error.reason = 'timeout'; + // Skip renegotiation when the selected codec order matches with that of the remote SDP. + const currentCodecOrder = this.peerconnection.getConfiguredVideoCodecs(); + + if (codecList.every((val, index) => val === currentCodecOrder[index])) { + return; } - error.session = this.toString(); + Statistics.sendAnalytics( + VIDEO_CODEC_CHANGED, + { + value: codecList[0], + videoType: VideoType.CAMERA + }); - if (failureCb) { - failureCb(error); - } else if (this.state === JingleSessionState.ENDED - && error.reason === 'item-not-found') { - // When remote peer decides to terminate the session, but it - // still have few messages on the queue for processing, - // it will first send us 'session-terminate' (we enter ENDED) - // and then follow with 'item-not-found' for the queued requests - // We don't want to have that logged on error level. - logger.debug(`${this} Jingle error: ${JSON.stringify(error)}`); - } else { - logger.error(`Jingle error: ${JSON.stringify(error)}`); - } - }; - } + // Initiate a renegotiate for the codec setting to take effect. + const workFunction = finishedCallback => { + this._renegotiate() + .then(() => this.peerconnection.configureVideoSenderEncodings()) + .then( + () => { + logger.debug(`${this} setVideoCodecs task is done`); - /** - * Returns the ice connection state for the peer connection. - * @returns the ice connection state for the peer connection. - */ - getIceConnectionState() { - return this.peerconnection.getConnectionState(); + return finishedCallback(); + }, error => { + logger.error(`${this} setVideoCodecs task failed: ${error}`); + + return finishedCallback(error); + }); + }; + + logger.debug(`${this} Queued setVideoCodecs task`); + + // Queue and execute + this.modificationQueue.push(workFunction); + } } /** - * Closes the peerconnection. + * @inheritDoc */ - close() { - this.state = JingleSessionState.ENDED; - this.establishmentDuration = undefined; - - if (this.peerconnection) { - this.peerconnection.onicecandidate = null; - this.peerconnection.oniceconnectionstatechange = null; - this.peerconnection.onnegotiationneeded = null; - this.peerconnection.onsignalingstatechange = null; + terminate(success, failure, options) { + if (this.state === JingleSessionState.ENDED) { + return; } - logger.debug(`${this} Clearing modificationQueue`); + if (!options || Boolean(options.sendSessionTerminate)) { + const sessionTerminate + = $iq({ + to: this.remoteJid, + type: 'set' + }) + .c('jingle', { + xmlns: 'urn:xmpp:jingle:1', + action: 'session-terminate', + initiator: this.initiatorJid, + sid: this.sid + }) + .c('reason') + .c((options && options.reason) || 'success') + .up(); - // Remove any pending tasks from the queue - this.modificationQueue.clear(); + if (options && options.reasonDescription) { + sessionTerminate + .c('text') + .t(options.reasonDescription) + .up() + .up(); + } else { + sessionTerminate.up(); + } - logger.debug(`${this} Queued PC close task`); - this.modificationQueue.push(finishCallback => { - // do not try to close if already closed. - this.peerconnection && this.peerconnection.close(); - finishCallback(); - logger.debug(`${this} PC close task done!`); - }); + this._bridgeSessionId + && sessionTerminate.c( + 'bridge-session', { + xmlns: 'http://jitsi.org/protocol/focus', + id: this._bridgeSessionId, + restart: options && options.requestRestart === true + }).up(); - logger.debug(`${this} Shutdown modificationQueue!`); + logger.info(`${this} Sending session-terminate`); + logger.debug(sessionTerminate.tree()); - // No more tasks can go in after the close task - this.modificationQueue.shutdown(); + this.connection.sendIQ( + sessionTerminate, + success, + this.newJingleErrorHandler(sessionTerminate, failure), + IQ_TIMEOUT); + } else { + logger.info(`${this} Skipped sending session-terminate`); + } + + // this should result in 'onTerminated' being called by strope.jingle.js + this.connection.jingle.terminate(this.sid); } /**