| /** |
| * Copyright 2014 The Chromium Authors |
| * Use of this source code is governed by a BSD-style license that can be |
| * found in the LICENSE file. |
| */ |
| |
| /** |
| * The one and only peer connection in this page. |
| * @private |
| */ |
| var gPeerConnection = null; |
| |
| /** |
| * This stores ICE candidates generated on this side. |
| * @private |
| */ |
| var gIceCandidates = []; |
| |
| /** |
| * This stores last ICE gathering state emitted on this side. |
| * @private |
| */ |
| var gIceGatheringState = 'no-gathering-state'; |
| |
| /** |
| * Keeps track of whether we have seen crypto information in the SDP. |
| * @private |
| */ |
| var gHasSeenCryptoInSdp = 'no-crypto-seen'; |
| |
| /** |
| * The default audio codec that should be used when creating an offer. |
| * @private |
| */ |
| var gDefaultAudioCodec = null; |
| |
| /** |
| * The default video codec that should be used when creating an offer. |
| * @private |
| */ |
| var gDefaultVideoCodec = null; |
| |
| /** |
| * The default video codec profile that should be used when creating an offer. |
| * @private |
| */ |
| var gDefaultVideoCodecProfile = null; |
| |
| /** |
| * The default video target bitrate that should be used when creating an offer. |
| * @private |
| */ |
| var gDefaultVideoTargetBitrate = null; |
| |
| /** |
| * Flag to indicate if HW or SW video codec is preferred. |
| * @private |
| */ |
| var gDefaultPreferHwVideoCodec = null; |
| |
| /** |
| * Flag to indicate if Opus Dtx should be enabled. |
| * @private |
| */ |
| var gOpusDtx = false; |
| |
| /** @private */ |
| var gNegotiationNeededCount = 0; |
| |
| /** @private */ |
| var gTrackEvents = []; |
| |
| // Public interface to tests. |
| |
| /** |
| * Creates a peer connection. Must be called before most other public functions |
| * in this file. Alternatively, see |preparePeerConnectionWithCertificate|. |
| * @param {Object} keygenAlgorithm Unless null, this is an |AlgorithmIdentifier| |
| * to be used as parameter to |RTCPeerConnection.generateCertificate|. The |
| * resulting certificate will be used by the peer connection. If null, a default |
| * certificate is generated by the |RTCPeerConnection| instead. |
| * @param {string} peerConnectionConstraints Unless null, this adds peer |
| * connection constraints when creating new RTCPeerConnection. |
| */ |
| function preparePeerConnection( |
| keygenAlgorithm = null, peerConnectionConstraints = null) { |
| if (gPeerConnection !== null) |
| throw new Error('Creating peer connection, but we already have one.'); |
| |
| if (keygenAlgorithm === null) { |
| gPeerConnection = createPeerConnection_(null, peerConnectionConstraints); |
| return logAndReturn('ok-peerconnection-created'); |
| } |
| return RTCPeerConnection.generateCertificate(keygenAlgorithm).then( |
| function(certificate) { |
| return preparePeerConnectionWithCertificate(certificate, |
| peerConnectionConstraints); |
| }, |
| function() { |
| throw new Error('Certificate generation failed. keygenAlgorithm: ' + |
| JSON.stringify(keygenAlgorithm)); |
| }) |
| .catch((err) => 'Test failed: ' + err.stack); |
| } |
| |
| /** |
| * Creates a peer connection. Must be called before most other public functions |
| * in this file. Alternatively, see |preparePeerConnection|. |
| * @param {!Object} certificate The |RTCCertificate| that will be used by the |
| * peer connection. |
| * @param {string} peerConnectionConstraints Unless null, this adds peer |
| * connection constraints when creating new RTCPeerConnection. |
| */ |
| function preparePeerConnectionWithCertificate( |
| certificate, peerConnectionConstraints = null) { |
| if (gPeerConnection !== null) |
| throw new Error('Creating peer connection, but we already have one.'); |
| gPeerConnection = createPeerConnection_( |
| {iceServers:[], certificates:[certificate]}, peerConnectionConstraints); |
| return logAndReturn('ok-peerconnection-created'); |
| } |
| |
| /** |
| * Sets the flag to force Opus Dtx to be used when creating an offer. |
| */ |
| function forceOpusDtx() { |
| gOpusDtx = true; |
| return logAndReturn('ok-forced'); |
| } |
| |
| /** |
| * Sets the default audio codec to be used when creating an offer and returns |
| * "ok" to test. |
| * @param {string} audioCodec promotes the specified codec to be the default |
| * audio codec, e.g. the first one in the list on the 'm=audio' SDP offer |
| * line. |audioCodec| is the case-sensitive codec name, e.g. 'opus' or |
| * 'ISAC'. |
| */ |
| function setDefaultAudioCodec(audioCodec) { |
| gDefaultAudioCodec = audioCodec; |
| return logAndReturn('ok'); |
| } |
| |
| /** |
| * Sets the default video codec to be used when creating an offer and returns |
| * "ok" to test. |
| * @param {string} videoCodec promotes the specified codec to be the default |
| * video codec, e.g. the first one in the list on the 'm=video' SDP offer |
| * line. |videoCodec| is the case-sensitive codec name, e.g. 'VP8' or |
| * 'H264'. |
| * @param {string} profile promotes the specified codec profile. |
| * @param {bool} preferHwVideoCodec specifies what codec to use from the |
| * 'm=video' line when there are multiple codecs with the name |videoCodec|. |
| * If true, it will return the last codec with that name, and if false, it |
| * will return the first codec with that name. |
| */ |
| function setDefaultVideoCodec(videoCodec, preferHwVideoCodec, profile) { |
| gDefaultVideoCodec = videoCodec; |
| gDefaultPreferHwVideoCodec = preferHwVideoCodec; |
| gDefaultVideoCodecProfile = profile; |
| return logAndReturn('ok'); |
| } |
| |
| /** |
| * Sets the default video target bitrate to be used when creating an offer and |
| * returns "ok" to test. |
| * @param {int} modifies "b=AS:" line with the given value. |
| */ |
| function setDefaultVideoTargetBitrate(bitrate) { |
| gDefaultVideoTargetBitrate = bitrate; |
| return logAndReturn('ok'); |
| } |
| |
| /** |
| * Creates a data channel with the specified label. |
| * Returns 'ok-created' to test. |
| */ |
| function createDataChannel(label) { |
| peerConnection_().createDataChannel(label); |
| return logAndReturn('ok-created'); |
| } |
| |
| /** |
| * Asks this page to create a local offer. |
| * |
| * Returns a string on the format ok-(JSON encoded session description). |
| * |
| * @param {!Object} constraints Any createOffer constraints. |
| */ |
| function createLocalOffer(constraints) { |
| return new Promise((resolve, reject) => { |
| peerConnection_().createOffer( |
| resolve, reject, constraints); |
| }).then(function(localOffer) { |
| success('createOffer'); |
| |
| setLocalDescription(peerConnection, localOffer); |
| if (gDefaultAudioCodec !== null) { |
| localOffer.sdp = setSdpDefaultAudioCodec(localOffer.sdp, |
| gDefaultAudioCodec); |
| } |
| if (gDefaultVideoCodec !== null) { |
| localOffer.sdp = setSdpDefaultVideoCodec( |
| localOffer.sdp, gDefaultVideoCodec, gDefaultPreferHwVideoCodec, |
| gDefaultVideoCodecProfile); |
| } |
| if (gOpusDtx) { |
| localOffer.sdp = setOpusDtxEnabled(localOffer.sdp); |
| } |
| if (gDefaultVideoTargetBitrate !== null) { |
| localOffer.sdp = setSdpVideoTargetBitrate(localOffer.sdp, |
| gDefaultVideoTargetBitrate); |
| } |
| return logAndReturn('ok-' + JSON.stringify(localOffer)); |
| }, |
| function(error) { return new MethodError('createOffer', error, false); }); |
| } |
| |
| /** |
| * Asks this page to accept an offer and generate an answer. |
| * |
| * Returns a string on the format ok-(JSON encoded session description). |
| * |
| * @param {!string} sessionDescJson A JSON-encoded session description of type |
| * 'offer'. |
| * @param {!Object} constraints Any createAnswer constraints. |
| */ |
| function receiveOfferFromPeer(sessionDescJson, constraints) { |
| offer = parseJson_(sessionDescJson); |
| if (!offer.type) |
| throw new Error('Got invalid session description from peer: ' |
| + sessionDescJson); |
| if (offer.type != 'offer') |
| throw new Error('Expected to receive offer from peer, got ' |
| + offer.type); |
| |
| var sessionDescription = new RTCSessionDescription(offer); |
| return Promise.all([ |
| new Promise((resolve, reject) => { |
| peerConnection_().setRemoteDescription( |
| sessionDescription, |
| resolve, reject); |
| }).then( |
| function() { success('setRemoteDescription'); }, |
| function(error) { |
| throw new MethodError('setRemoteDescription', error); |
| }), |
| |
| new Promise((resolve, reject) => { |
| peerConnection_().createAnswer(resolve, reject, constraints); |
| }).then( |
| function(answer) { |
| success('createAnswer'); |
| setLocalDescription(peerConnection, answer); |
| if (gOpusDtx) { |
| answer.sdp = setOpusDtxEnabled(answer.sdp); |
| } |
| return logAndReturn('ok-' + JSON.stringify(answer)); |
| }, |
| function(error) { throw new MethodError('createAnswer', error); }), |
| ]).then(([_, result]) => result); |
| } |
| |
| /** |
| * Verifies that the codec previously set using setDefault[Audio/Video]Codec() |
| * is the default audio/video codec, e.g. the first one in the list on the |
| * 'm=audio'/'m=video' SDP answer line. If this is not the case, a new |
| * |MethodError| is thrown. If no codec was previously set using |
| * setDefault[Audio/Video]Codec(), this function will return |
| * 'ok-no-defaults-set'. |
| * |
| * @param {!string} sessionDescJson A JSON-encoded session description. |
| */ |
| function verifyDefaultCodecs(sessionDescJson) { |
| let sessionDesc = parseJson_(sessionDescJson); |
| if (!sessionDesc.type) { |
| throw new MethodError('verifyDefaultCodecs', |
| 'Invalid session description: ' + sessionDescJson); |
| } |
| if (gDefaultAudioCodec !== null && gDefaultVideoCodec !== null) { |
| return logAndReturn('ok-no-defaults-set'); |
| } |
| if (gDefaultAudioCodec !== null) { |
| let defaultAudioCodec = getSdpDefaultAudioCodec(sessionDesc.sdp); |
| if (defaultAudioCodec === null) { |
| throw new MethodError('verifyDefaultCodecs', |
| 'Could not determine default audio codec.'); |
| } |
| if (gDefaultAudioCodec !== defaultAudioCodec) { |
| throw new MethodError('verifyDefaultCodecs', |
| 'Expected default audio codec ' + gDefaultAudioCodec + |
| ', got ' + defaultAudioCodec + '.'); |
| } |
| } |
| if (gDefaultVideoCodec !== null) { |
| let defaultVideoCodec = getSdpDefaultVideoCodec(sessionDesc.sdp); |
| if (defaultVideoCodec === null) { |
| throw new MethodError('verifyDefaultCodecs', |
| 'Could not determine default video codec.'); |
| } |
| if (gDefaultVideoCodec !== defaultVideoCodec) { |
| throw new MethodError('verifyDefaultCodecs', |
| 'Expected default video codec ' + gDefaultVideoCodec + |
| ', got ' + defaultVideoCodec + '.'); |
| } |
| } |
| return logAndReturn('ok-verified'); |
| } |
| |
| /** |
| * Verifies that the peer connection's local description contains one of |
| * |certificate|'s fingerprints. |
| * |
| * Returns 'ok-verified' on success. |
| */ |
| function verifyLocalDescriptionContainsCertificate(certificate) { |
| let localDescription = peerConnection_().localDescription; |
| if (localDescription == null) |
| throw new Error('localDescription is null.'); |
| for (let i = 0; i < certificate.getFingerprints().length; ++i) { |
| let fingerprintSdp = 'a=fingerprint:' + |
| certificate.getFingerprints()[i].algorithm + ' ' + |
| certificate.getFingerprints()[i].value.toUpperCase(); |
| if (localDescription.sdp.includes(fingerprintSdp)) { |
| return logAndReturn('ok-verified'); |
| } |
| } |
| if (!localDescription.sdp.includes('a=fingerprint')) |
| throw new Error('localDescription does not contain any fingerprints.'); |
| throw new Error('Certificate fingerprint not found in localDescription.'); |
| } |
| |
| /** |
| * Asks this page to accept an answer generated by the peer in response to a |
| * previous offer by this page |
| * |
| * Returns a string ok-accepted-answer on success. |
| * |
| * @param {!string} sessionDescJson A JSON-encoded session description of type |
| * 'answer'. |
| */ |
| function receiveAnswerFromPeer(sessionDescJson) { |
| answer = parseJson_(sessionDescJson); |
| if (!answer.type) |
| throw new Error('Got invalid session description from peer: ' |
| + sessionDescJson); |
| if (answer.type != 'answer') |
| throw new Error('Expected to receive answer from peer, got ' + answer.type); |
| |
| var sessionDescription = new RTCSessionDescription(answer); |
| return new Promise((resolve, reject) => { |
| peerConnection_().setRemoteDescription( |
| sessionDescription, resolve, reject); |
| }).then( |
| function() { |
| success('setRemoteDescription'); |
| return logAndReturn('ok-accepted-answer'); |
| }, |
| function(error) { |
| throw new MethodError('setRemoteDescription', error); |
| }); |
| } |
| |
| /** |
| * Adds the local stream to the peer connection. You will have to re-negotiate |
| * the call for this to take effect in the call. |
| */ |
| function addLocalStream() { |
| addLocalStreamToPeerConnection(peerConnection_()); |
| return logAndReturn('ok-added'); |
| } |
| |
| /** |
| * Loads a file with WebAudio and connects it to the peer connection. |
| * |
| * The loadAudioAndAddToPeerConnection will return ok-added to the test when |
| * the sound is loaded and added to the peer connection. The sound will start |
| * playing when you call playAudioFile. |
| * |
| * @param url URL pointing to the file to play. You can assume that you can |
| * serve files from the repository's file system. For instance, to serve a |
| * file from chrome/test/data/pyauto_private/webrtc/file.wav, pass in a path |
| * relative to this directory (e.g. ../pyauto_private/webrtc/file.wav). |
| */ |
| function addAudioFile(url) { |
| return loadAudioAndAddToPeerConnection(url, peerConnection_()); |
| } |
| |
| /** |
| * Must be called after addAudioFile. |
| */ |
| function playAudioFile() { |
| playPreviouslyLoadedAudioFile(peerConnection_()); |
| return logAndReturn('ok-playing'); |
| } |
| |
| /** |
| * Hangs up a started call. Returns ok-call-hung-up on success. |
| */ |
| function hangUp() { |
| peerConnection_().close(); |
| gPeerConnection = null; |
| return logAndReturn('ok-call-hung-up'); |
| } |
| |
| /** |
| * Retrieves all ICE candidates generated on this side. Must be called after |
| * ICE candidate generation is triggered (for instance by running a call |
| * negotiation). This function will wait if necessary if we're not done |
| * generating ICE candidates on this side. |
| * |
| * Returns a JSON-encoded array of RTCIceCandidate instances to the test. |
| */ |
| async function getAllIceCandidates() { |
| while (peerConnection_().iceGatheringState != 'complete') { |
| console.log('Still ICE gathering - waiting...'); |
| await new Promise(resolve => { |
| setTimeout(resolve, 100); |
| }); |
| } |
| |
| return logAndReturn(JSON.stringify(gIceCandidates)); |
| } |
| |
| /** |
| * Receives ICE candidates from the peer. |
| * |
| * Returns ok-received-candidates to the test on success. |
| * |
| * @param iceCandidatesJson a JSON-encoded array of RTCIceCandidate instances. |
| */ |
| function receiveIceCandidates(iceCandidatesJson) { |
| var iceCandidates = parseJson_(iceCandidatesJson); |
| if (!iceCandidates.length) |
| throw new Error('Received invalid ICE candidate list from peer: ' + |
| iceCandidatesJson); |
| |
| return new Promise((resolve, reject) => { |
| for (const iceCandidate of iceCandidates) { |
| if (!iceCandidate.candidate) |
| throw new Error('Received invalid ICE candidate from peer: ' + |
| iceCandidatesJson); |
| |
| peerConnection_().addIceCandidate(new RTCIceCandidate(iceCandidate, |
| function() { success('addIceCandidate'); }, |
| function(error) { reject(new MethodError('addIceCandidate', error)); } |
| )); |
| } |
| resolve(logAndReturn('ok-received-candidates')); |
| }); |
| } |
| |
| /** |
| * Sets the mute state of the selected media element. |
| * |
| * Returns ok-muted on success. |
| * |
| * @param elementId The id of the element to mute. |
| * @param muted The mute state to set. |
| */ |
| function setMediaElementMuted(elementId, muted) { |
| var element = document.getElementById(elementId); |
| if (!element) |
| throw new Error('Cannot mute ' + elementId + '; does not exist.'); |
| element.muted = muted; |
| return logAndReturn('ok-muted'); |
| } |
| |
| /** |
| * Returns |
| */ |
| function hasSeenCryptoInSdp() { |
| return logAndReturn(gHasSeenCryptoInSdp); |
| } |
| |
| /** |
| * Measures the performance of the legacy (callback-based) |
| * |RTCPeerConnection.getStats| and returns the time it took in milliseconds as |
| * a double (DOMHighResTimeStamp, accurate to one thousandth of a millisecond). |
| * |
| * Returns "ok-" followed by a double. |
| */ |
| function measureGetStatsCallbackPerformance() { |
| let t0 = performance.now(); |
| return new Promise(resolve => { |
| peerConnection_().getStats(resolve); |
| }).then( |
| function(response) { |
| let t1 = performance.now(); |
| return logAndReturn('ok-' + (t1 - t0)); |
| }); |
| } |
| |
| /** |
| * Returns the last iceGatheringState emitted from icegatheringstatechange. |
| */ |
| function getLastGatheringState() { |
| return logAndReturn(gIceGatheringState); |
| } |
| |
| /** |
| * Returns "ok-negotiation-count-is-" followed by the number of times |
| * onnegotiationneeded has fired. This will include any currently queued |
| * negotiationneeded events. |
| */ |
| function getNegotiationNeededCount() { |
| return new Promise(resolve => { |
| window.setTimeout(resolve, 0); |
| }).then(function() { |
| return logAndReturn('ok-negotiation-count-is-' + gNegotiationNeededCount); |
| }); |
| } |
| |
| /** |
| * Gets the track and stream IDs of each "ontrack" event that has been fired on |
| * the peer connection in chronological order. |
| * |
| * Returns "ok-" followed by a series of space-separated |
| * "RTCTrackEvent <track id> <stream ids>". |
| */ |
| function getTrackEvents() { |
| let result = ''; |
| for (const event of gTrackEvents) { |
| if (event.receiver.track != event.track) |
| throw new Error('RTCTrackEvent\'s track does not match its receiver\'s.'); |
| let eventString = 'RTCTrackEvent ' + event.track.id; |
| event.streams.forEach(function(stream) { |
| eventString += ' ' + stream.id; |
| }); |
| if (result.length) |
| result += ' '; |
| result += eventString; |
| } |
| return logAndReturn('ok-' + result); |
| } |
| |
| // Internals. |
| |
| /** @private */ |
| function createPeerConnection_(rtcConfig, peerConnectionConstraints) { |
| try { |
| peerConnection = |
| new RTCPeerConnection(rtcConfig, peerConnectionConstraints); |
| } catch (exception) { |
| throw new Error('Failed to create peer connection: ' + exception); |
| } |
| peerConnection.onaddstream = addStreamCallback_; |
| peerConnection.onremovestream = removeStreamCallback_; |
| peerConnection.onicecandidate = iceCallback_; |
| peerConnection.onicegatheringstatechange = iceGatheringCallback_; |
| peerConnection.onnegotiationneeded = negotiationNeededCallback_; |
| peerConnection.ontrack = onTrackCallback_; |
| return peerConnection; |
| } |
| |
| /** @private */ |
| function peerConnection_() { |
| if (gPeerConnection == null) |
| throw new Error('Trying to use peer connection, but none was created.'); |
| return gPeerConnection; |
| } |
| |
| /** @private */ |
| function iceCallback_(event) { |
| if (event.candidate) |
| gIceCandidates.push(event.candidate); |
| } |
| |
| /** @private */ |
| function iceGatheringCallback_() { |
| gIceGatheringState = peerConnection.iceGatheringState; |
| } |
| |
| /** @private */ |
| function negotiationNeededCallback_() { |
| ++gNegotiationNeededCount; |
| } |
| |
| /** @private */ |
| function onTrackCallback_(event) { |
| gTrackEvents.push(event); |
| } |
| |
| /** @private */ |
| function setLocalDescription(peerConnection, sessionDescription) { |
| if (sessionDescription.sdp.search('a=crypto') != -1 || |
| sessionDescription.sdp.search('a=fingerprint') != -1) |
| gHasSeenCryptoInSdp = 'crypto-seen'; |
| |
| peerConnection.setLocalDescription( |
| sessionDescription, |
| function() { success('setLocalDescription'); }, |
| function(error) { new MethodError('setLocalDescription', error); }); |
| } |
| |
| /** @private */ |
| function addStreamCallback_(event) { |
| debug('Receiving remote stream...'); |
| var videoTag = document.getElementById('remote-view'); |
| videoTag.srcObject = event.stream; |
| } |
| |
| /** @private */ |
| function removeStreamCallback_(event) { |
| debug('Call ended.'); |
| document.getElementById('remote-view').src = ''; |
| } |
| |
| /** |
| * Parses JSON-encoded session descriptions and ICE candidates. |
| * @private |
| */ |
| function parseJson_(json) { |
| // Escape since the \r\n in the SDP tend to get unescaped. |
| jsonWithEscapedLineBreaks = json.replace(/\r\n/g, '\\r\\n'); |
| try { |
| return JSON.parse(jsonWithEscapedLineBreaks); |
| } catch (exception) { |
| throw new Error('Failed to parse JSON: ' + jsonWithEscapedLineBreaks + |
| ', got ' + exception); |
| } |
| } |