/**
* 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);
}
}