Bug 1073410 - get gUM perms earlier for Loop calls (paired with jaws), r=jaws,me

This commit is contained in:
Dan Mosedale 2014-11-03 14:48:16 -08:00
parent dbfd76eadc
commit de0015f514
8 changed files with 603 additions and 5 deletions

View File

@ -27,6 +27,7 @@
window.OTProperties.configURL = window.OTProperties.assetURL + 'js/dynamic_config.min.js';
window.OTProperties.cssURL = window.OTProperties.assetURL + 'css/ot.css';
</script>
<script type="text/javascript" src="js/multiplexGum.js"></script>
<script type="text/javascript" src="shared/libs/sdk.js"></script>
<script type="text/javascript" src="libs/l10n-gaia-02ca67948fe8.js"></script>
<script type="text/javascript" src="shared/libs/react-0.11.2.js"></script>

View File

@ -0,0 +1,150 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
var loop = loop || {};
/**
* Monkeypatch getUserMedia in a way that prevents additional camera and
* microphone prompts, at the cost of ignoring all constraints other than
* the first set passed in.
*
* The first call to navigator.getUserMedia (also now aliased to
* multiplexGum.getPermsAndCacheMedia to allow for explicit calling code)
* will cause the underlying gUM implementation to be called.
*
* While permission is pending, subsequent calls will result in the callbacks
* being queued. Once the call succeeds or fails, all queued success or
* failure callbacks will be invoked. Subsequent calls to either function will
* cause the success or failure callback to be invoked immediately.
*/
loop.standaloneMedia = (function() {
"use strict";
function patchSymbolIfExtant(objectName, propertyName, replacement) {
var object;
if (window[objectName]) {
object = window[objectName];
}
if (object && object[propertyName]) {
object[propertyName] = replacement;
}
}
// originalGum _must_ be on navigator; otherwise things blow up
navigator.originalGum = navigator.getUserMedia ||
navigator.mozGetUserMedia ||
navigator.webkitGetUserMedia ||
(window["TBPlugin"] && TBPlugin.getUserMedia);
function _MultiplexGum() {
this.reset();
}
_MultiplexGum.prototype = {
/**
* @see The docs at the top of this file for overall semantics,
* & http://developer.mozilla.org/en-US/docs/NavigatorUserMedia.getUserMedia
* for params, since this is intended to be purely a passthrough to gUM.
*/
getPermsAndCacheMedia: function(constraints, onSuccess, onError) {
function handleResult(callbacks, param) {
// Operate on a copy of the array in case any of the callbacks
// calls reset, which would cause an infinite-recursion.
this.userMedia.successCallbacks = [];
this.userMedia.errorCallbacks = [];
callbacks.forEach(function(cb) {
if (typeof cb == "function") {
cb(param);
}
})
}
function handleSuccess(localStream) {
this.userMedia.pending = false;
this.userMedia.localStream = localStream;
this.userMedia.error = null;
handleResult.call(this, this.userMedia.successCallbacks.slice(0), localStream);
}
function handleError(error) {
this.userMedia.pending = false;
this.userMedia.error = error;
handleResult.call(this, this.userMedia.errorCallbacks.slice(0), error);
this.error = null;
}
if (this.userMedia.localStream &&
this.userMedia.localStream.ended) {
this.userMedia.localStream = null;
}
this.userMedia.errorCallbacks.push(onError);
this.userMedia.successCallbacks.push(onSuccess);
if (this.userMedia.localStream) {
handleSuccess.call(this, this.userMedia.localStream);
return;
} else if (this.userMedia.error) {
handleError.call(this, this.userMedia.error);
return;
}
if (this.userMedia.pending) {
return;
}
this.userMedia.pending = true;
navigator.originalGum(constraints, handleSuccess.bind(this),
handleError.bind(this));
},
/**
* Reset the cached permissions, callbacks, and media to their default
* state and call any error callbacks to let any waiting callers know
* not to ever expect any more callbacks. We use "PERMISSION_DENIED",
* for lack of a better, more specific gUM code that callers are likely
* to be prepared to handle.
*/
reset: function() {
// When called from the ctor, userMedia is not created yet.
if (this.userMedia) {
this.userMedia.errorCallbacks.forEach(function(cb) {
if (typeof cb == "function") {
cb("PERMISSION_DENIED");
}
});
if (this.userMedia.localStream &&
typeof this.userMedia.localStream.stop == "function") {
this.userMedia.localStream.stop();
}
}
this.userMedia = {
error: null,
localStream: null,
pending: false,
errorCallbacks: [],
successCallbacks: [],
};
}
};
var singletonMultiplexGum = new _MultiplexGum();
function myGetUserMedia() {
// This function is needed to pull in the instance
// of the singleton for tests to overwrite the used instance.
singletonMultiplexGum.getPermsAndCacheMedia.apply(singletonMultiplexGum, arguments);
};
patchSymbolIfExtant("navigator", "mozGetUserMedia", myGetUserMedia);
patchSymbolIfExtant("navigator", "webkitGetUserMedia", myGetUserMedia);
patchSymbolIfExtant("navigator", "getUserMedia", myGetUserMedia);
patchSymbolIfExtant("TBPlugin", "getUserMedia", myGetUserMedia);
return {
multiplexGum: singletonMultiplexGum,
_MultiplexGum: _MultiplexGum,
setSingleton: function(singleton) {
singletonMultiplexGum = singleton;
},
};
})();

View File

@ -19,11 +19,14 @@ loop.webapp = (function($, _, OT, mozL10n) {
var sharedViews = loop.shared.views;
var sharedUtils = loop.shared.utils;
var multiplexGum = loop.standaloneMedia.multiplexGum;
/**
* Homepage view.
*/
var HomeView = React.createClass({displayName: 'HomeView',
render: function() {
loop.standaloneMedia.multiplexGum.reset();
return (
React.DOM.p(null, mozL10n.get("welcome", {clientShortname: mozL10n.get("clientShortname2")}))
);
@ -287,6 +290,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
},
_cancelOutgoingCall: function() {
loop.standaloneMedia.multiplexGum.reset();
this.props.websocket.cancel();
},
@ -448,8 +452,15 @@ loop.webapp = (function($, _, OT, mozL10n) {
*/
startCall: function(callType) {
return function() {
this.props.conversation.setupOutgoingCall(callType);
this.setState({disableCallButton: true});
multiplexGum.getPermsAndCacheMedia({audio:true, video:true},
function(localStream) {
this.props.conversation.setupOutgoingCall(callType);
this.setState({disableCallButton: true});
}.bind(this),
function(errorCode) {
multiplexGum.reset();
}.bind(this)
);
}.bind(this);
},

View File

@ -19,11 +19,14 @@ loop.webapp = (function($, _, OT, mozL10n) {
var sharedViews = loop.shared.views;
var sharedUtils = loop.shared.utils;
var multiplexGum = loop.standaloneMedia.multiplexGum;
/**
* Homepage view.
*/
var HomeView = React.createClass({
render: function() {
loop.standaloneMedia.multiplexGum.reset();
return (
<p>{mozL10n.get("welcome", {clientShortname: mozL10n.get("clientShortname2")})}</p>
);
@ -287,6 +290,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
},
_cancelOutgoingCall: function() {
loop.standaloneMedia.multiplexGum.reset();
this.props.websocket.cancel();
},
@ -448,8 +452,15 @@ loop.webapp = (function($, _, OT, mozL10n) {
*/
startCall: function(callType) {
return function() {
this.props.conversation.setupOutgoingCall(callType);
this.setState({disableCallButton: true});
multiplexGum.getPermsAndCacheMedia({audio:true, video:true},
function(localStream) {
this.props.conversation.setupOutgoingCall(callType);
this.setState({disableCallButton: true});
}.bind(this),
function(errorCode) {
multiplexGum.reset();
}.bind(this)
);
}.bind(this);
},

View File

@ -37,11 +37,13 @@
<script src="../../content/shared/js/views.js"></script>
<script src="../../content/shared/js/websocket.js"></script>
<script src="../../content/shared/js/feedbackApiClient.js"></script>
<script src="../../standalone/content/js/multiplexGum.js"></script>
<script src="../../standalone/content/js/standaloneClient.js"></script>
<script src="../../standalone/content/js/webapp.js"></script>
<!-- Test scripts -->
<script src="standalone_client_test.js"></script>
<script src="webapp_test.js"></script>
<script src="multiplexGum_test.js"></script>
<script>
mocha.run(function () {
$("#mocha").append("<p id='complete'>Complete.</p>");

View File

@ -0,0 +1,370 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/*global loop, sinon, it, beforeEach, afterEach, describe*/
var expect = chai.expect;
describe("loop.standaloneMedia._MultiplexGum", function() {
"use strict";
var defaultGum =
navigator.getUserMedia ||
navigator.mozGetUserMedia ||
navigator.webkitGetUserMedia ||
(window["TBPlugin"] && TBPlugin.getUserMedia);
var sandbox;
var multiplexGum;
beforeEach(function() {
sandbox = sinon.sandbox.create();
multiplexGum = new loop.standaloneMedia._MultiplexGum();
loop.standaloneMedia.setSingleton(multiplexGum);
});
afterEach(function() {
sandbox.restore();
});
describe("#constructor", function() {
it("pending should default to false", function() {
expect(multiplexGum.userMedia.pending).to.equal(false);
});
});
describe("default getUserMedia", function() {
it("should call getPermsAndCacheMedia", function() {
var fakeOptions = {audio: true, video: true};
var successCB = function() {};
var errorCB = function() {};
sandbox.stub(navigator, "originalGum");
sandbox.stub(loop.standaloneMedia._MultiplexGum.prototype,
"getPermsAndCacheMedia");
multiplexGum = new loop.standaloneMedia._MultiplexGum();
defaultGum(fakeOptions, successCB, errorCB);
sinon.assert.calledOnce(multiplexGum.getPermsAndCacheMedia);
sinon.assert.calledWithExactly(multiplexGum.getPermsAndCacheMedia,
fakeOptions, successCB, errorCB);
});
});
describe("#getPermsAndCacheMedia", function() {
beforeEach(function() {
sandbox.stub(navigator, "originalGum");
});
it("should change pending to true", function() {
multiplexGum.getPermsAndCacheMedia();
expect(multiplexGum.userMedia.pending).to.equal(true);
});
it("should call originalGum", function() {
multiplexGum.getPermsAndCacheMedia();
sinon.assert.calledOnce(navigator.originalGum);
});
it("should reset the pending state when the error callback is called",
function(done) {
var fakeError = new Error();
navigator.originalGum.callsArgWith(2, fakeError);
multiplexGum.getPermsAndCacheMedia(null, null, function onError(error) {
expect(multiplexGum.userMedia.pending).to.equal(false);
done();
});
});
it("should reset the pending state when the success callback is called",
function(done) {
var fakeLocalStream = {};
navigator.originalGum.callsArgWith(1, fakeLocalStream);
multiplexGum.getPermsAndCacheMedia(null,
function onSuccess(localStream) {
expect(multiplexGum.userMedia.pending).to.equal(false);
done();
}, null);
});
it("should call the error callback when originalGum calls back an error",
function(done) {
var fakeError = new Error();
navigator.originalGum.callsArgWith(2, fakeError);
multiplexGum.getPermsAndCacheMedia(null, null, function onError(error) {
expect(error).to.eql(fakeError);
done();
});
});
it("should propagate the success callback when originalGum succeeds",
function(done) {
var fakeLocalStream = {};
navigator.originalGum.callsArgWith(1, fakeLocalStream);
multiplexGum.getPermsAndCacheMedia(null,
function onSuccess(localStream) {
expect(localStream).to.eql(fakeLocalStream);
done();
}, null);
});
it("should call the success callback when the stream is cached",
function(done) {
var fakeLocalStream = {};
multiplexGum.userMedia.localStream = fakeLocalStream;
sinon.assert.notCalled(navigator.originalGum);
multiplexGum.getPermsAndCacheMedia(null,
function onSuccess(localStream) {
expect(localStream).to.eql(fakeLocalStream);
done();
}, null);
});
it("should call the error callback when an error is cached",
function(done) {
var fakeError = new Error();
multiplexGum.userMedia.error = fakeError;
sinon.assert.notCalled(navigator.originalGum);
multiplexGum.getPermsAndCacheMedia(null, null, function onError(error) {
expect(error).to.eql(fakeError);
done();
});
});
it("should clear the error when success is called back", function(done) {
var fakeError = new Error();
var fakeLocalStream = {};
multiplexGum.userMedia.localStream = fakeLocalStream;
multiplexGum.userMedia.error = fakeError;
multiplexGum.getPermsAndCacheMedia(null, function onSuccess(localStream) {
expect(multiplexGum.userMedia.error).to.not.eql(fakeError);
expect(localStream).to.eql(fakeLocalStream);
done();
}, null);
});
it("should call all success callbacks when success is achieved",
function(done) {
var fakeLocalStream = {};
var calls = 0;
// Async is needed so that the callbacks can be queued up.
navigator.originalGum.callsArgWithAsync(1, fakeLocalStream);
multiplexGum.getPermsAndCacheMedia(null, function onSuccess(localStream) {
calls += 1;
expect(localStream).to.eql(fakeLocalStream);
}, null);
expect(multiplexGum.userMedia).to.have.property('pending', true);
multiplexGum.getPermsAndCacheMedia(null, function onSuccess(localStream) {
calls += 10;
expect(localStream).to.eql(fakeLocalStream);
expect(calls).to.equal(11);
done();
}, null);
});
it("should call all error callbacks when error is encountered",
function(done) {
var fakeError = new Error();
var calls = 0;
// Async is needed so that the callbacks can be queued up.
navigator.originalGum.callsArgWithAsync(2, fakeError);
multiplexGum.getPermsAndCacheMedia(null, null, function onError(error) {
calls += 1;
expect(error).to.eql(fakeError);
});
expect(multiplexGum.userMedia).to.have.property('pending', true);
multiplexGum.getPermsAndCacheMedia(null, null, function onError(error) {
calls += 10;
expect(error).to.eql(fakeError);
expect(calls).to.eql(11);
done();
});
});
it("should not call a getPermsAndCacheMedia success callback at the time" +
" of gUM success callback fires",
function(done) {
var fakeLocalStream = {};
multiplexGum.userMedia.localStream = fakeLocalStream;
navigator.originalGum.callsArgWith(1, fakeLocalStream);
var calledOnce = false;
var promiseCalledOnce = new Promise(function(resolve, reject) {
multiplexGum.getPermsAndCacheMedia(null,
function gPACMSuccess(localStream) {
expect(localStream).to.eql(fakeLocalStream);
expect(multiplexGum.userMedia).to.have.property('pending', false);
expect(multiplexGum.userMedia.successCallbacks.length).to.equal(0);
if (calledOnce) {
sinon.assert.fail("original callback was called twice");
}
calledOnce = true;
resolve();
}, function() {
sinon.assert.fail("error callback should not have fired");
reject();
done();
});
});
promiseCalledOnce.then(function() {
defaultGum(null, function gUMSuccess(localStream2) {
expect(localStream2).to.eql(fakeLocalStream);
expect(multiplexGum.userMedia).to.have.property('pending', false);
expect(multiplexGum.userMedia.successCallbacks.length).to.equal(0);
done();
});
});
});
it("should not call a getPermsAndCacheMedia error callback when the " +
" gUM error callback fires",
function(done) {
var fakeError = "monkeys ate the stream";
multiplexGum.userMedia.error = fakeError;
navigator.originalGum.callsArgWith(2, fakeError);
var calledOnce = false;
var promiseCalledOnce = new Promise(function(resolve, reject) {
multiplexGum.getPermsAndCacheMedia(null, function() {
sinon.assert.fail("success callback should not have fired");
reject();
done();
}, function gPACMError(errString) {
expect(errString).to.eql(fakeError);
expect(multiplexGum.userMedia).to.have.property('pending', false);
if (calledOnce) {
sinon.assert.fail("original error callback was called twice");
}
calledOnce = true;
resolve();
});
});
promiseCalledOnce.then(function() {
defaultGum(null, function() {},
function gUMError(errString) {
expect(errString).to.eql(fakeError);
expect(multiplexGum.userMedia).to.have.property('pending', false);
done();
});
});
});
it("should call the success callback with a new stream, " +
" when a new stream is available",
function(done) {
var endedStream = {ended: true};
var newStream = {};
multiplexGum.userMedia.localStream = endedStream;
navigator.originalGum.callsArgWith(1, newStream);
multiplexGum.getPermsAndCacheMedia(null, function onSuccess(localStream) {
expect(localStream).to.eql(newStream);
done();
}, null);
});
});
describe("#reset", function () {
it("should reset all userMedia state to default", function() {
// If userMedia is defined, then it needs to have all of
// the properties that multipleGum will depend on. It is
// easier to simply delete the object than to setup a fake
// state of the object.
delete multiplexGum.userMedia;
multiplexGum.reset();
expect(multiplexGum.userMedia).to.deep.equal({
error: null,
localStream: null,
pending: false,
errorCallbacks: [],
successCallbacks: [],
});
});
it("should call all queued error callbacks with 'PERMISSION_DENIED'",
function(done) {
sandbox.stub(navigator, "originalGum");
multiplexGum.getPermsAndCacheMedia(null, function(localStream) {
sinon.assert.fail(
"The success callback shouldn't be called due to reset");
}, function(error) {
expect(error).to.equal("PERMISSION_DENIED");
done();
});
multiplexGum.reset();
});
it("should call MST.stop() on the stream tracks", function() {
var stopStub = sandbox.stub();
multiplexGum.userMedia.localStream = {stop: stopStub};
multiplexGum.reset();
sinon.assert.calledOnce(stopStub);
});
it("should not call MST.stop() on the stream tracks if .stop() doesn't exist",
function() {
multiplexGum.userMedia.localStream = {};
try {
multiplexGum.reset();
} catch (ex) {
sinon.assert.fail(
"reset shouldn't throw when a stream doesn't implement stop(): "
+ ex);
}
});
it("should not get stuck in recursion if the error callback calls 'reset'",
function() {
sandbox.stub(navigator, "originalGum");
navigator.originalGum.callsArgWith(2, "PERMISSION_DENIED");
var calledOnce = false;
multiplexGum.getPermsAndCacheMedia(null, null, function() {
if (calledOnce) {
sinon.assert.fail("reset should only be called once");
}
calledOnce = true;
multiplexGum.reset.bind(multiplexGum)();
});
});
it("should not get stuck in recursion if the success callback calls 'reset'",
function() {
sandbox.stub(navigator, "originalGum");
navigator.originalGum.callsArgWith(1, {});
var calledOnce = false;
multiplexGum.getPermsAndCacheMedia(null, function() {
calledOnce = true;
multiplexGum.reset.bind(multiplexGum)();
}, function() {
if (calledOnce) {
sinon.assert.fail("reset should only be called once");
}
calledOnce = true;
});
});
});
});

View File

@ -13,9 +13,11 @@ describe("loop.webapp", function() {
var sharedModels = loop.shared.models,
sharedViews = loop.shared.views,
sharedUtils = loop.shared.utils,
standaloneMedia = loop.standaloneMedia,
sandbox,
notifications,
feedbackApiClient;
feedbackApiClient,
stubGetPermsAndCacheMedia;
beforeEach(function() {
sandbox = sinon.sandbox.create();
@ -23,6 +25,9 @@ describe("loop.webapp", function() {
feedbackApiClient = new loop.FeedbackAPIClient("http://invalid", {
product: "Loop"
});
stubGetPermsAndCacheMedia = sandbox.stub(
loop.standaloneMedia._MultiplexGum.prototype, "getPermsAndCacheMedia");
});
afterEach(function() {
@ -610,6 +615,19 @@ describe("loop.webapp", function() {
});
});
describe("HomeView", function() {
it("should call loop.standaloneMedia.reset", function() {
var multiplexGum = new standaloneMedia._MultiplexGum();
standaloneMedia.setSingleton(multiplexGum);
sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset");
TestUtils.renderIntoDocument(loop.webapp.HomeView());
sinon.assert.calledOnce(multiplexGum.reset);
sinon.assert.calledWithExactly(multiplexGum.reset);
});
});
describe("PendingConversationView", function() {
var view, websocket, fakeAudio;
@ -652,6 +670,18 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(websocket.cancel);
});
it("should call multiplexGum.reset to release the camera", function() {
var multiplexGum = new standaloneMedia._MultiplexGum();
standaloneMedia.setSingleton(multiplexGum);
sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset");
var button = view.getDOMNode().querySelector(".btn-cancel");
React.addons.TestUtils.Simulate.click(button);
sinon.assert.calledOnce(multiplexGum.reset);
sinon.assert.calledWithExactly(multiplexGum.reset);
});
});
describe("Events", function() {
@ -697,8 +727,27 @@ describe("loop.webapp", function() {
client: standaloneClientStub
})
);
// default to succeeding with a null local media object
stubGetPermsAndCacheMedia.callsArgWith(1, {});
});
it("should fire multiplexGum.reset when getPermsAndCacheMedia calls" +
" back an error",
function() {
var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
var multiplexGum = new standaloneMedia._MultiplexGum();
standaloneMedia.setSingleton(multiplexGum);
sandbox.stub(standaloneMedia._MultiplexGum.prototype, "reset");
stubGetPermsAndCacheMedia.callsArgWith(2, "FAKE_ERROR");
var button = view.getDOMNode().querySelector(".btn-accept");
React.addons.TestUtils.Simulate.click(button);
sinon.assert.calledOnce(multiplexGum.reset);
sinon.assert.calledWithExactly(multiplexGum.reset);
});
it("should start the audio-video conversation establishment process",
function() {
var setupOutgoingCall = sinon.stub(conversation, "setupOutgoingCall");
@ -1002,6 +1051,9 @@ describe("loop.webapp", function() {
client: standaloneClientStub
})
);
// default to succeeding with a null local media object
stubGetPermsAndCacheMedia.callsArgWith(1, {});
});
it("should start the conversation establishment process", function() {

View File

@ -26,6 +26,7 @@
window.OTProperties.configURL = window.OTProperties.assetURL + 'js/dynamic_config.min.js';
window.OTProperties.cssURL = window.OTProperties.assetURL + 'css/ot.css';
</script>
<script src="../content/js/multiplexGum.js"></script>
<script src="../content/shared/libs/sdk.js"></script>
<script src="../content/shared/libs/react-0.11.2.js"></script>
<script src="../content/shared/libs/jquery-2.1.0.js"></script>