/* global $, __filename */
import {
ACTION_JINGLE_TR_RECEIVED,
ACTION_JINGLE_TR_SUCCESS,
createJingleEvent
} from '../../service/statistics/AnalyticsEvents';
import { getLogger } from 'jitsi-meet-logger';
import { $iq, Strophe } from 'strophe.js';
import XMPPEvents from '../../service/xmpp/XMPPEvents';
import GlobalOnErrorHandler from '../util/GlobalOnErrorHandler';
import RandomUtil from '../util/RandomUtil';
import Statistics from '../statistics/statistics';
import JingleSessionPC from './JingleSessionPC';
import ConnectionPlugin from './ConnectionPlugin';
const logger = getLogger(__filename);
// XXX Strophe is build around the idea of chaining function calls so allow long
// function call chains.
/* eslint-disable newline-per-chained-call */
/**
*
*/
class JingleConnectionPlugin extends ConnectionPlugin {
/**
* Creates new JingleConnectionPlugin
* @param {XMPP} xmpp
* @param {EventEmitter} eventEmitter
* @param {Object} iceConfig an object that holds the iceConfig to be passed
* to the p2p and the jvb PeerConnection.
*/
constructor(xmpp, eventEmitter, iceConfig) {
super();
this.xmpp = xmpp;
this.eventEmitter = eventEmitter;
this.sessions = {};
this.jvbIceConfig = iceConfig.jvb;
this.p2pIceConfig = iceConfig.p2p;
this.mediaConstraints = {
mandatory: {
'OfferToReceiveAudio': true,
'OfferToReceiveVideo': true
}
// MozDontOfferDataChannel: true when this is firefox
};
}
/**
*
* @param connection
*/
init(connection) {
super.init(connection);
this.connection.addHandler(this.onJingle.bind(this),
'urn:xmpp:jingle:1', 'iq', 'set', null, null);
}
/**
*
* @param iq
*/
onJingle(iq) {
const sid = $(iq).find('jingle').attr('sid');
const action = $(iq).find('jingle').attr('action');
const fromJid = iq.getAttribute('from');
// send ack first
const ack = $iq({ type: 'result',
to: fromJid,
id: iq.getAttribute('id')
});
logger.log(`on jingle ${action} from ${fromJid}`, iq);
let sess = this.sessions[sid];
if (action !== 'session-initiate') {
if (!sess) {
ack.attrs({ type: 'error' });
ack.c('error', { type: 'cancel' })
.c('item-not-found', {
xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas'
})
.up()
.c('unknown-session', {
xmlns: 'urn:xmpp:jingle:errors:1'
});
logger.warn('invalid session id', iq);
this.connection.send(ack);
return true;
}
// local jid is not checked
if (fromJid !== sess.remoteJid) {
logger.warn(
'jid mismatch for session id', sid, sess.remoteJid, iq);
ack.attrs({ type: 'error' });
ack.c('error', { type: 'cancel' })
.c('item-not-found', {
xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas'
})
.up()
.c('unknown-session', {
xmlns: 'urn:xmpp:jingle:errors:1'
});
this.connection.send(ack);
return true;
}
} else if (sess !== undefined) {
// Existing session with same session id. This might be out-of-order
// if the sess.remoteJid is the same as from.
ack.attrs({ type: 'error' });
ack.c('error', { type: 'cancel' })
.c('service-unavailable', {
xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas'
})
.up();
logger.warn('duplicate session id', sid, iq);
this.connection.send(ack);
return true;
}
const now = window.performance.now();
// FIXME that should work most of the time, but we'd have to
// think how secure it is to assume that user with "focus"
// nickname is Jicofo.
const isP2P = Strophe.getResourceFromJid(fromJid) !== 'focus';
// see http://xmpp.org/extensions/xep-0166.html#concepts-session
switch (action) {
case 'session-initiate': {
logger.log('(TIME) received session-initiate:\t', now);
const startMuted = $(iq).find('jingle>startmuted');
if (startMuted && startMuted.length > 0) {
const audioMuted = startMuted.attr('audio');
const videoMuted = startMuted.attr('video');
this.eventEmitter.emit(
XMPPEvents.START_MUTED_FROM_FOCUS,
audioMuted === 'true',
videoMuted === 'true');
}
logger.info(
`Marking session from ${fromJid
} as ${isP2P ? '' : '*not*'} P2P`);
sess
= new JingleSessionPC(
$(iq).find('jingle').attr('sid'),
$(iq).attr('to'),
fromJid,
this.connection,
this.mediaConstraints,
isP2P ? this.p2pIceConfig : this.jvbIceConfig,
isP2P,
/* initiator */ false,
this.xmpp.options);
this.sessions[sess.sid] = sess;
this.eventEmitter.emit(XMPPEvents.CALL_INCOMING,
sess, $(iq).find('>jingle'), now);
break;
}
case 'session-accept': {
this.eventEmitter.emit(
XMPPEvents.CALL_ACCEPTED, sess, $(iq).find('>jingle'));
break;
}
case 'content-modify': {
sess.modifyContents($(iq).find('>jingle'));
break;
}
case 'transport-info': {
this.eventEmitter.emit(
XMPPEvents.TRANSPORT_INFO, sess, $(iq).find('>jingle'));
break;
}
case 'session-terminate': {
logger.log('terminating...', sess.sid);
let reasonCondition = null;
let reasonText = null;
if ($(iq).find('>jingle>reason').length) {
reasonCondition
= $(iq).find('>jingle>reason>:first')[0].tagName;
reasonText = $(iq).find('>jingle>reason>text').text();
}
this.terminate(sess.sid, reasonCondition, reasonText);
this.eventEmitter.emit(XMPPEvents.CALL_ENDED,
sess, reasonCondition, reasonText);
break;
}
case 'transport-replace':
logger.info('(TIME) Start transport replace', now);
Statistics.sendAnalytics(createJingleEvent(
ACTION_JINGLE_TR_RECEIVED,
{
p2p: isP2P,
value: now
}));
sess.replaceTransport($(iq).find('>jingle'), () => {
const successTime = window.performance.now();
logger.info('(TIME) Transport replace success!', successTime);
Statistics.sendAnalytics(createJingleEvent(
ACTION_JINGLE_TR_SUCCESS,
{
p2p: isP2P,
value: successTime
}));
}, error => {
GlobalOnErrorHandler.callErrorHandler(error);
logger.error('Transport replace failed', error);
sess.sendTransportReject();
});
break;
case 'addsource': // FIXME: proprietary, un-jingleish
case 'source-add': // FIXME: proprietary
sess.addRemoteStream($(iq).find('>jingle>content'));
break;
case 'removesource': // FIXME: proprietary, un-jingleish
case 'source-remove': // FIXME: proprietary
sess.removeRemoteStream($(iq).find('>jingle>content'));
break;
default:
logger.warn('jingle action not implemented', action);
ack.attrs({ type: 'error' });
ack.c('error', { type: 'cancel' })
.c('bad-request',
{ xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' })
.up();
break;
}
this.connection.send(ack);
return true;
}
/**
* Creates new JingleSessionPC meant to be used in a direct P2P
* connection, configured as 'initiator'.
* @param {string} me our JID
* @param {string} peer remote participant's JID
* @return {JingleSessionPC}
*/
newP2PJingleSession(me, peer) {
const sess
= new JingleSessionPC(
RandomUtil.randomHexString(12),
me,
peer,
this.connection,
this.mediaConstraints,
this.p2pIceConfig,
/* P2P */ true,
/* initiator */ true,
this.xmpp.options);
this.sessions[sess.sid] = sess;
return sess;
}
/**
*
* @param sid
* @param reasonCondition
* @param reasonText
*/
terminate(sid, reasonCondition, reasonText) {
if (this.sessions.hasOwnProperty(sid)) {
if (this.sessions[sid].state !== 'ended') {
this.sessions[sid].onTerminated(reasonCondition, reasonText);
}
delete this.sessions[sid];
}
}
/**
*
*/
getStunAndTurnCredentials() {
// get stun and turn configuration from server via xep-0215
// uses time-limited credentials as described in
// http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
//
// See https://code.google.com/p/prosody-modules/source/browse/
// mod_turncredentials/mod_turncredentials.lua
// for a prosody module which implements this.
//
// Currently, this doesn't work with updateIce and therefore credentials
// with a long validity have to be fetched before creating the
// peerconnection.
// TODO: implement refresh via updateIce as described in
// https://code.google.com/p/webrtc/issues/detail?id=1650
this.connection.sendIQ(
$iq({ type: 'get',
to: this.connection.domain })
.c('services', { xmlns: 'urn:xmpp:extdisco:1' })
.c('service', { host: `turn.${this.connection.domain}` }),
res => {
const iceservers = [];
$(res).find('>services>service').each((idx, el) => {
// eslint-disable-next-line no-param-reassign
el = $(el);
const dict = {};
const type = el.attr('type');
switch (type) {
case 'stun':
dict.url = `stun:${el.attr('host')}`;
if (el.attr('port')) {
dict.url += `:${el.attr('port')}`;
}
iceservers.push(dict);
break;
case 'turn':
case 'turns': {
dict.url = `${type}:`;
const username = el.attr('username');
// https://code.google.com/p/webrtc/issues/detail
// ?id=1508
if (username) {
const match
= navigator.userAgent.match(
/Chrom(e|ium)\/([0-9]+)\./);
if (match && parseInt(match[2], 10) < 28) {
dict.url += `${username}@`;
} else {
// only works in M28
dict.username = username;
}
}
dict.url += el.attr('host');
const port = el.attr('port');
if (port && port !== '3478') {
dict.url += `:${el.attr('port')}`;
}
const transport = el.attr('transport');
if (transport && transport !== 'udp') {
dict.url += `?transport=${transport}`;
}
dict.credential = el.attr('password')
|| dict.credential;
iceservers.push(dict);
break;
}
}
});
const options = this.xmpp.options;
if (options.useStunTurn) {
this.jvbIceConfig.iceServers = iceservers;
}
if (options.p2p && options.p2p.useStunTurn) {
this.p2pIceConfig.iceServers = iceservers;
}
}, err => {
logger.warn('getting turn credentials failed', err);
logger.warn('is mod_turncredentials or similar installed?');
});
// implement push?
}
/**
* Returns the data saved in 'updateLog' in a format to be logged.
*/
getLog() {
const data = {};
Object.keys(this.sessions).forEach(sid => {
const session = this.sessions[sid];
const pc = session.peerconnection;
if (pc && pc.updateLog) {
// FIXME: should probably be a .dump call
data[`jingle_${sid}`] = {
updateLog: pc.updateLog,
stats: pc.stats,
url: window.location.href
};
}
});
return data;
}
}
/* eslint-enable newline-per-chained-call */
/**
*
* @param XMPP
* @param eventEmitter
* @param iceConfig
*/
export default function initJingle(XMPP, eventEmitter, iceConfig) {
Strophe.addConnectionPlugin(
'jingle',
new JingleConnectionPlugin(XMPP, eventEmitter, iceConfig));
}