diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js b/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js index 6474a5f586c914c7df5251c80c88ffe6e3e97a96..4141f2a6633f03f7ab90c994e1f8d78833aae11d 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js @@ -193,6 +193,48 @@ export default class KurentoAudioBridge extends BaseAudioBridge { }); } + trickleIce() { + return new Promise((resolve, reject) => { + try { + fetchWebRTCMappedStunTurnServers(this.sessionToken) + .then((iceServers) => { + const options = { + userName: this.name, + caleeName: `${GLOBAL_AUDIO_PREFIX}${this.voiceBridge}`, + iceServers, + }; + + this.broker = new ListenOnlyBroker( + Auth.authenticateURL(SFU_URL), + this.voiceBridge, + this.userId, + this.internalMeetingID, + RECV_ROLE, + options, + ); + + this.broker.onstart = () => { + const { peerConnection } = this.broker.webRtcPeer; + + if (!peerConnection) return resolve(null); + + const selectedCandidatePair = peerConnection.getReceivers()[0] + .transport.iceTransport.getSelectedCandidatePair(); + + const validIceCandidate = [selectedCandidatePair.local]; + + this.broker.stop(); + return resolve(validIceCandidate); + }; + + this.broker.listen(); + }); + } catch (e) { + reject(e); + } + }); + } + joinAudio({ isListenOnly }, callback) { return new Promise(async (resolve, reject) => { if (!isListenOnly) return reject(new Error('Invalid bridge option')); diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js b/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js index 60427ddbe8596fb5d86b7c37b15f50eab6b8633b..2aaf7cf2272765aa9d49734604ae60407b6242f2 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js @@ -9,6 +9,7 @@ import { toUnifiedPlan, toPlanB, stripMDnsCandidates, + filterValidIceCandidates, analyzeSdp, logSelectedCandidate, } from '/imports/utils/sdpUtils'; @@ -227,6 +228,7 @@ class SIPSession { extension, inputDeviceId, outputDeviceId, + validIceCandidates, }, managerCallback) { return new Promise((resolve, reject) => { const callExtension = extension ? `${extension}${this.userData.voiceBridge}` : this.userData.voiceBridge; @@ -254,6 +256,8 @@ class SIPSession { // If there's an extension passed it means that we're joining the echo test first this.inEchoTest = !!extension; + this.validIceCandidates = validIceCandidates; + return this.doCall({ callExtension, isListenOnly, @@ -712,6 +716,58 @@ class SIPSession { }); } + isValidIceCandidate(event) { + return event.candidate + && this.validIceCandidates + && this.validIceCandidates.find((validCandidate) => ( + (validCandidate.address === event.candidate.address) + || (validCandidate.relatedAddress === event.candidate.address)) + && (validCandidate.protocol === event.candidate.protocol)); + } + + onIceGatheringStateChange(event) { + const secondsToGatherIce = (new Date() - this._sessionStartTime) / 1000; + + const iceGatheringState = event.target + ? event.target.iceGatheringState + : null; + + if (iceGatheringState === 'complete') { + logger.info({ + logCode: 'sip_js_ice_gathering_time', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, `ICE gathering candidates took (s): ${secondsToGatherIce}`); + } + } + + onIceCandidate(sessionDescriptionHandler, event) { + if (this.isValidIceCandidate(event)) { + logger.info({ + logCode: 'sip_js_found_valid_candidate_from_trickle_ice', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'Found a valid candidate from trickle ICE, finishing gathering'); + + if (sessionDescriptionHandler.iceGatheringCompleteResolve) { + sessionDescriptionHandler.iceGatheringCompleteResolve(); + } + } + } + + initSessionDescriptionHandler(sessionDescriptionHandler) { + /* eslint-disable no-param-reassign */ + sessionDescriptionHandler.peerConnectionDelegate = { + onicecandidate: + this.onIceCandidate.bind(this, sessionDescriptionHandler), + onicegatheringstatechange: + this.onIceGatheringStateChange.bind(this), + }; + /* eslint-enable no-param-reassign */ + } + inviteUserAgent(userAgent) { return new Promise((resolve, reject) => { if (this.userRequestedHangup === true) reject(); @@ -724,6 +780,7 @@ class SIPSession { isListenOnly, } = this.callOptions; + this._sessionStartTime = new Date(); const target = SIP.UserAgent.makeURI(`sip:${callExtension}@${hostname}`); @@ -739,11 +796,16 @@ class SIPSession { }, iceGatheringTimeout: ICE_GATHERING_TIMEOUT, }, - sessionDescriptionHandlerModifiersPostICEGathering: - [stripMDnsCandidates], + sessionDescriptionHandlerModifiersPostICEGathering: [ + stripMDnsCandidates, + filterValidIceCandidates.bind(this, this.validIceCandidates), + ], + delegate: { + onSessionDescriptionHandler: + this.initSessionDescriptionHandler.bind(this), + }, }; - if (isListenOnly) { inviterOptions.sessionDescriptionHandlerOptions.offerOptions = { offerToReceiveAudio: true, @@ -919,8 +981,8 @@ class SIPSession { }, }, 'Audio call session progress update'); - this.currentSession.sessionDescriptionHandler.peerConnectionDelegate = { - onconnectionstatechange: (event) => { + this.currentSession.sessionDescriptionHandler.peerConnectionDelegate + .onconnectionstatechange = (event) => { const peer = event.target; logger.info({ @@ -940,8 +1002,10 @@ class SIPSession { default: break; } - }, - oniceconnectionstatechange: (event) => { + }; + + this.currentSession.sessionDescriptionHandler.peerConnectionDelegate + .oniceconnectionstatechange = (event) => { const peer = event.target; switch (peer.iceConnectionState) { @@ -989,8 +1053,7 @@ class SIPSession { default: break; } - }, - }; + }; }; const handleSessionTerminated = (message) => { @@ -1255,7 +1318,7 @@ export default class SIPBridge extends BaseAudioBridge { return this.activeSession ? this.activeSession.inputStream : null; } - joinAudio({ isListenOnly, extension }, managerCallback) { + joinAudio({ isListenOnly, extension, validIceCandidates }, managerCallback) { const hasFallbackDomain = typeof IPV4_FALLBACK_DOMAIN === 'string' && IPV4_FALLBACK_DOMAIN !== ''; return new Promise((resolve, reject) => { @@ -1293,6 +1356,7 @@ export default class SIPBridge extends BaseAudioBridge { extension: fallbackExtension, inputDeviceId, outputDeviceId, + validIceCandidates, }, callback) .then((value) => { this.changeOutputDevice(outputDeviceId, true); @@ -1312,6 +1376,7 @@ export default class SIPBridge extends BaseAudioBridge { extension, inputDeviceId, outputDeviceId, + validIceCandidates, }, callback) .then((value) => { this.changeOutputDevice(outputDeviceId, true); diff --git a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js index 3c4324488bd2ae27019e3d544b0de160ea4bd8fa..972cefcc6a57e568f3e3d9ddb0078b7dcbf71e17 100755 --- a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js +++ b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js @@ -11,6 +11,7 @@ import iosWebviewAudioPolyfills from '/imports/utils/ios-webview-audio-polyfills import { monitorAudioConnection } from '/imports/utils/stats'; import AudioErrors from './error-codes'; import {Meteor} from "meteor/meteor"; +import browserInfo from '/imports/utils/browserInfo'; const STATS = Meteor.settings.public.stats; const MEDIA = Meteor.settings.public.media; @@ -20,6 +21,8 @@ const MAX_LISTEN_ONLY_RETRIES = 1; const LISTEN_ONLY_CALL_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 25000; const DEFAULT_INPUT_DEVICE_ID = 'default'; const DEFAULT_OUTPUT_DEVICE_ID = 'default'; +const EXPERIMENTAL_USE_KMS_TRICKLE_ICE_FOR_MICROPHONE = Meteor.settings + .public.app.experimentalUseKmsTrickleIceForMicrophone; const CALL_STATES = { STARTED: 'started', @@ -109,7 +112,29 @@ class AudioManager { }); } + async trickleIce() { + const { isFirefox, isIe, isSafari } = browserInfo; + + if (!this.listenOnlyBridge + || isFirefox + || isIe + || isSafari) return []; + + if (this.validIceCandidates && this.validIceCandidates.length) { + logger.info({ logCode: 'audiomanager_trickle_ice_reuse_candidate' }, + 'Reusing trickle-ice information before activating microphone'); + return this.validIceCandidates; + } + + logger.info({ logCode: 'audiomanager_trickle_ice_get_local_candidate' }, + 'Performing trickle-ice before activating microphone'); + this.validIceCandidates = await this.listenOnlyBridge.trickleIce() || []; + return this.validIceCandidates; + } + joinMicrophone() { + this.audioJoinStartTime = new Date(); + this.logAudioJoinTime = false; this.isListenOnly = false; this.isEchoTest = false; @@ -125,15 +150,23 @@ class AudioManager { } joinEchoTest() { + this.audioJoinStartTime = new Date(); + this.logAudioJoinTime = false; this.isListenOnly = false; this.isEchoTest = true; return this.onAudioJoining.bind(this)() - .then(() => { + .then(async () => { + let validIceCandidates = []; + if (EXPERIMENTAL_USE_KMS_TRICKLE_ICE_FOR_MICROPHONE) { + validIceCandidates = await this.trickleIce(); + } + const callOptions = { isListenOnly: false, extension: ECHO_TEST_NUMBER, inputStream: this.inputStream, + validIceCandidates, }; logger.info({ logCode: 'audiomanager_join_echotest', extraInfo: { logType: 'user_action' } }, 'User requested to join audio conference with mic'); return this.joinAudio(callOptions, this.callStateCallback.bind(this)); @@ -183,6 +216,8 @@ class AudioManager { } async joinListenOnly(r = 0) { + this.audioJoinStartTime = new Date(); + this.logAudioJoinTime = false; let retries = r; this.isListenOnly = true; this.isEchoTest = false; @@ -333,6 +368,13 @@ class AudioManager { changed: (id, fields) => this.onVoiceUserChanges(fields), }); } + const secondsToActivateAudio = (new Date() - this.audioJoinStartTime) / 1000; + + if (!this.logAudioJoinTime) { + this.logAudioJoinTime = true; + logger.info({ logCode: 'audio_mic_join_time' }, 'Time needed to ' + + `connect audio (seconds): ${secondsToActivateAudio}`); + } if (!this.isEchoTest) { window.parent.postMessage({ response: 'joinedAudio' }, '*'); diff --git a/bigbluebutton-html5/imports/utils/sdpUtils.js b/bigbluebutton-html5/imports/utils/sdpUtils.js index e1ac00b873a94251087f76c5ffbacdaf4a74db76..580e171ac80c12d2eca02a0b2eca967f224eb07c 100755 --- a/bigbluebutton-html5/imports/utils/sdpUtils.js +++ b/bigbluebutton-html5/imports/utils/sdpUtils.js @@ -65,6 +65,40 @@ const stripMDnsCandidates = (sdp) => { return { sdp: transform.write(parsedSDP), type: sdp.type }; }; +const filterValidIceCandidates = (validIceCandidates, sdp) => { + if (!validIceCandidates.length) return sdp; + + const matchCandidatesIp = (candidate, mediaCandidate) => ( + (candidate.address && candidate.address.includes(mediaCandidate.ip)) + || (candidate.relatedAddress + && candidate.relatedAddress.includes(mediaCandidate.ip)) + ); + + const parsedSDP = transform.parse(sdp.sdp); + let strippedCandidates = 0; + parsedSDP.media.forEach((media) => { + if (media.candidates) { + media.candidates = media.candidates.filter((candidate) => { + if (candidate.ip + && candidate.type + && candidate.transport + && validIceCandidates.find((c) => (c.protocol === candidate.transport) + && matchCandidatesIp(c, candidate)) + ) { + return true; + } + strippedCandidates += 1; + return false; + }); + } + }); + if (strippedCandidates > 0) { + logger.info({ logCode: 'sdp_utils_mdns_candidate_strip' }, + `Filtered ${strippedCandidates} invalid candidates from trickle SDP`); + } + return { sdp: transform.write(parsedSDP), type: sdp.type }; +}; + const isPublicIpv4 = (ip) => { const ipParts = ip.split('.'); switch (ipParts[0]) { @@ -305,6 +339,7 @@ export { toPlanB, toUnifiedPlan, stripMDnsCandidates, + filterValidIceCandidates, analyzeSdp, logSelectedCandidate, }; diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 8e6918b8daea4bddf6532da91c33b81416950bd2..462f9dc9d0d51215ca3952aa322a087274c695b3 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -79,6 +79,15 @@ public: showAudioFilters: true raiseHandActionButton: enabled: true + # If enabled, before joining microphone the client will perform a trickle + # ICE against Kurento and use the information about successfull + # candidate-pairs to filter out local candidates in SIP.js's SDP. + # Try enabling this setting in scenarios where the listenonly mode works, + # but microphone doesn't (for example, when using VPN). + # For compatibility check "Browser compatbility" section in: + # https://developer.mozilla.org/en-US/docs/Web/API/RTCDtlsTransport/iceTransport + # This is an EXPERIMENTAL setting and the default value is false + # experimentalUseKmsTrickleIceForMicrophone: false defaultSettings: application: animations: true