Bug 1097742 - Part 1 Handle access being denied to media, and prevent the sdk prompts from showing in Loop Rooms. r=nperriault

This commit is contained in:
Mark Banner 2014-11-13 22:45:23 +00:00
parent 2b287ee41c
commit cfa103ca14
12 changed files with 256 additions and 83 deletions

View File

@ -10,6 +10,15 @@ loop.store.ActiveRoomStore = (function() {
"use strict";
var sharedActions = loop.shared.actions;
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
// Error numbers taken from
// https://github.com/mozilla-services/loop-server/blob/master/loop/errno.json
var SERVER_CODES = loop.store.SERVER_CODES = {
INVALID_TOKEN: 105,
EXPIRED: 111,
ROOM_FULL: 202
};
var ROOM_STATES = loop.store.ROOM_STATES = {
// The initial state of the room
@ -84,7 +93,8 @@ loop.store.ActiveRoomStore = (function() {
this._storeState = {
roomState: ROOM_STATES.INIT,
audioMuted: false,
videoMuted: false
videoMuted: false,
failureReason: undefined
};
}
@ -112,13 +122,24 @@ loop.store.ActiveRoomStore = (function() {
* @param {sharedActions.RoomFailure} actionData
*/
roomFailure: function(actionData) {
function getReason(serverCode) {
switch (serverCode) {
case SERVER_CODES.INVALID_TOKEN:
case SERVER_CODES.EXPIRED:
return FAILURE_REASONS.EXPIRED_OR_INVALID;
default:
return FAILURE_REASONS.UNKNOWN;
}
}
console.error("Error in state `" + this._storeState.roomState + "`:",
actionData.error);
this.setStoreState({
error: actionData.error,
roomState: actionData.error.errno === 202 ? ROOM_STATES.FULL
: ROOM_STATES.FAILED
failureReason: getReason(actionData.error.errno),
roomState: actionData.error.errno === SERVER_CODES.ROOM_FULL ?
ROOM_STATES.FULL : ROOM_STATES.FAILED
});
},
@ -228,6 +249,11 @@ loop.store.ActiveRoomStore = (function() {
* Handles the action to join to a room.
*/
joinRoom: function() {
// Reset the failure reason if necessary.
if (this.getStoreState().failureReason) {
this.setStoreState({failureReason: undefined});
}
this._mozLoop.rooms.join(this._storeState.roomToken,
function(error, responseData) {
if (error) {
@ -275,11 +301,17 @@ loop.store.ActiveRoomStore = (function() {
/**
* Handles disconnection of this local client from the sdk servers.
*
* @param {sharedActions.ConnectionFailure} actionData
*/
connectionFailure: function() {
connectionFailure: function(actionData) {
// Treat all reasons as something failed. In theory, clientDisconnected
// could be a success case, but there's no way we should be intentionally
// sending that and still have the window open.
this.setStoreState({
failureReason: actionData.reason
});
this._leaveRoom(ROOM_STATES.FAILED);
},

View File

@ -8,6 +8,7 @@ var loop = loop || {};
loop.OTSdkDriver = (function() {
var sharedActions = loop.shared.actions;
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
/**
* This is a wrapper for the OT sdk. It is used to translate the SDK events into
@ -47,8 +48,11 @@ loop.OTSdkDriver = (function() {
// the initial connect of the session. This saves time when setting up
// the media.
this.publisher = this.sdk.initPublisher(this.getLocalElement(),
this.publisherConfig,
this._onPublishComplete.bind(this));
this.publisherConfig);
this.publisher.on("accessAllowed", this._onPublishComplete.bind(this));
this.publisher.on("accessDenied", this._onPublishDenied.bind(this));
this.publisher.on("accessDialogOpened",
this._onAccessDialogOpened.bind(this));
},
/**
@ -96,16 +100,12 @@ loop.OTSdkDriver = (function() {
*/
disconnectSession: function() {
if (this.session) {
this.session.off("streamCreated", this._onRemoteStreamCreated.bind(this));
this.session.off("connectionDestroyed",
this._onConnectionDestroyed.bind(this));
this.session.off("sessionDisconnected",
this._onSessionDisconnected.bind(this));
this.session.off("streamCreated connectionDestroyed sessionDisconnected");
this.session.disconnect();
delete this.session;
}
if (this.publisher) {
this.publisher.off("accessAllowed accessDenied accessDialogOpened");
this.publisher.destroy();
delete this.publisher;
}
@ -126,7 +126,7 @@ loop.OTSdkDriver = (function() {
if (error) {
console.error("Failed to complete connection", error);
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "couldNotConnect"
reason: FAILURE_REASONS.COULD_NOT_CONNECT
}));
return;
}
@ -159,7 +159,7 @@ loop.OTSdkDriver = (function() {
// We only need to worry about the network disconnected reason here.
if (event.reason === "networkDisconnected") {
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "networkDisconnected"
reason: FAILURE_REASONS.NETWORK_DISCONNECTED
}));
}
},
@ -188,24 +188,42 @@ loop.OTSdkDriver = (function() {
}
},
/**
* Called from the sdk when the media access dialog is opened.
* Prevents the default action, to prevent the SDK's "allow access"
* dialog from being shown.
*
* @param {OT.Event} event
*/
_onAccessDialogOpened: function(event) {
event.preventDefault();
},
/**
* Handles the publishing being complete.
*
* @param {Error} error An OT error object, null if there was no error.
* @param {OT.Event} event
*/
_onPublishComplete: function(error) {
if (error) {
console.error("Failed to initialize publisher", error);
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "noMedia"
}));
return;
}
_onPublishComplete: function(event) {
event.preventDefault();
this._publisherReady = true;
this._maybePublishLocalStream();
},
/**
* Handles publishing of media being denied.
*
* @param {OT.Event} event
*/
_onPublishDenied: function(event) {
// This prevents the SDK's "access denied" dialog showing.
event.preventDefault();
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: FAILURE_REASONS.MEDIA_DENIED
}));
},
/**
* Publishes the local stream if the session is connected
* and the publisher is ready.

View File

@ -17,6 +17,14 @@ loop.shared.utils = (function(mozL10n) {
AUDIO_ONLY: "audio"
};
var FAILURE_REASONS = {
MEDIA_DENIED: "reason-media-denied",
COULD_NOT_CONNECT: "reason-could-not-connect",
NETWORK_DISCONNECTED: "reason-network-disconnected",
EXPIRED_OR_INVALID: "reason-expired-or-invalid",
UNKNOWN: "reason-unknown"
};
/**
* Format a given date into an l10n-friendly string.
*
@ -110,6 +118,7 @@ loop.shared.utils = (function(mozL10n) {
return {
CALL_TYPES: CALL_TYPES,
FAILURE_REASONS: FAILURE_REASONS,
Helper: Helper,
composeCallUrlEmail: composeCallUrlEmail,
formatDate: formatDate,

View File

@ -11,6 +11,7 @@ var loop = loop || {};
loop.standaloneRoomViews = (function(mozL10n) {
"use strict";
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
@ -40,6 +41,20 @@ loop.standaloneRoomViews = (function(mozL10n) {
);
},
/**
* @return String An appropriate string according to the failureReason.
*/
_getFailureString: function() {
switch(this.props.failureReason) {
case FAILURE_REASONS.MEDIA_DENIED:
return mozL10n.get("rooms_media_denied_message");
case FAILURE_REASONS.EXPIRED_OR_INVALID:
return mozL10n.get("rooms_unavailable_notification_message");
default:
return mozL10n.get("status_error");
};
},
_renderContent: function() {
switch(this.props.roomState) {
case ROOM_STATES.INIT:
@ -68,6 +83,12 @@ loop.standaloneRoomViews = (function(mozL10n) {
React.DOM.p(null, this._renderCallToActionLink())
)
);
case ROOM_STATES.FAILED:
return (
React.DOM.p({className: "failed-room-message"},
this._getFailureString()
)
);
default:
return null;
}
@ -252,6 +273,7 @@ loop.standaloneRoomViews = (function(mozL10n) {
React.DOM.div({className: "room-conversation-wrapper"},
StandaloneRoomHeader(null),
StandaloneRoomInfoArea({roomState: this.state.roomState,
failureReason: this.state.failureReason,
joinRoom: this.joinRoom,
helper: this.props.helper}),
React.DOM.div({className: "video-layout-wrapper"},

View File

@ -11,6 +11,7 @@ var loop = loop || {};
loop.standaloneRoomViews = (function(mozL10n) {
"use strict";
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
@ -40,6 +41,20 @@ loop.standaloneRoomViews = (function(mozL10n) {
);
},
/**
* @return String An appropriate string according to the failureReason.
*/
_getFailureString: function() {
switch(this.props.failureReason) {
case FAILURE_REASONS.MEDIA_DENIED:
return mozL10n.get("rooms_media_denied_message");
case FAILURE_REASONS.EXPIRED_OR_INVALID:
return mozL10n.get("rooms_unavailable_notification_message");
default:
return mozL10n.get("status_error");
};
},
_renderContent: function() {
switch(this.props.roomState) {
case ROOM_STATES.INIT:
@ -68,6 +83,12 @@ loop.standaloneRoomViews = (function(mozL10n) {
<p>{this._renderCallToActionLink()}</p>
</div>
);
case ROOM_STATES.FAILED:
return (
<p className="failed-room-message">
{this._getFailureString()}
</p>
);
default:
return null;
}
@ -252,6 +273,7 @@ loop.standaloneRoomViews = (function(mozL10n) {
<div className="room-conversation-wrapper">
<StandaloneRoomHeader />
<StandaloneRoomInfoArea roomState={this.state.roomState}
failureReason={this.state.failureReason}
joinRoom={this.joinRoom}
helper={this.props.helper} />
<div className="video-layout-wrapper">

View File

@ -116,6 +116,8 @@ rooms_room_full_call_to_action_label=Learn more about {{clientShortname}} »
rooms_room_joined_label=Someone has joined the conversation!
rooms_room_join_label=Join the conversation
rooms_display_name_guest=Guest
rooms_unavailable_notification_message=Sorry, you cannot join this conversation. The link may be expired or invalid.
rooms_media_denied_message=We could not get access to your microphone or camera. Please reload the page to try again.
## LOCALIZATION_NOTE(standalone_title_with_status): {{clientShortname}} will be
## replaced by the brand name and {{currentStatus}} will be replaced

View File

@ -65,6 +65,7 @@ describe("loop.roomViews", function () {
roomState: ROOM_STATES.INIT,
audioMuted: false,
videoMuted: false,
failureReason: undefined
foo: "bar"
});
});

View File

@ -6,7 +6,9 @@ var sharedActions = loop.shared.actions;
describe("loop.store.ActiveRoomStore", function () {
"use strict";
var SERVER_CODES = loop.store.SERVER_CODES;
var ROOM_STATES = loop.store.ROOM_STATES;
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver;
var fakeMultiplexGum;
@ -91,8 +93,8 @@ describe("loop.store.ActiveRoomStore", function () {
sinon.match(ROOM_STATES.READY), fakeError);
});
it("should set the state to `FULL` on server errno 202", function() {
fakeError.errno = 202;
it("should set the state to `FULL` on server error room full", function() {
fakeError.errno = SERVER_CODES.ROOM_FULL;
store.roomFailure({error: fakeError});
@ -103,7 +105,28 @@ describe("loop.store.ActiveRoomStore", function () {
store.roomFailure({error: fakeError});
expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
expect(store._storeState.failureReason).eql(FAILURE_REASONS.UNKNOWN);
});
it("should set the failureReason to EXPIRED_OR_INVALID on server error: " +
"invalid token", function() {
fakeError.errno = SERVER_CODES.INVALID_TOKEN;
store.roomFailure({error: fakeError});
expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
expect(store._storeState.failureReason).eql(FAILURE_REASONS.EXPIRED_OR_INVALID);
});
it("should set the failureReason to EXPIRED_OR_INVALID on server error: " +
"expired", function() {
fakeError.errno = SERVER_CODES.EXPIRED;
store.roomFailure({error: fakeError});
expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
expect(store._storeState.failureReason).eql(FAILURE_REASONS.EXPIRED_OR_INVALID);
});
});
describe("#setupWindowData", function() {
@ -244,6 +267,14 @@ describe("loop.store.ActiveRoomStore", function () {
store.setStoreState({roomToken: "tokenFake"});
});
it("should reset failureReason", function() {
store.setStoreState({failureReason: "Test"});
store.joinRoom();
expect(store.getStoreState().failureReason).eql(undefined);
});
it("should call rooms.join on mozLoop", function() {
store.joinRoom();
@ -380,22 +411,34 @@ describe("loop.store.ActiveRoomStore", function () {
});
describe("#connectionFailure", function() {
var connectionFailureAction;
beforeEach(function() {
store.setStoreState({
roomState: ROOM_STATES.JOINED,
roomToken: "fakeToken",
sessionToken: "1627384950"
});
connectionFailureAction = new sharedActions.ConnectionFailure({
reason: "FAIL"
});
});
it("should store the failure reason", function() {
store.connectionFailure(connectionFailureAction);
expect(store.getStoreState().failureReason).eql("FAIL");
});
it("should reset the multiplexGum", function() {
store.leaveRoom();
store.connectionFailure(connectionFailureAction);
sinon.assert.calledOnce(fakeMultiplexGum.reset);
});
it("should disconnect from the servers via the sdk", function() {
store.connectionFailure();
store.connectionFailure(connectionFailureAction);
sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
});
@ -404,13 +447,13 @@ describe("loop.store.ActiveRoomStore", function () {
sandbox.stub(window, "clearTimeout");
store._timeout = {};
store.connectionFailure();
store.connectionFailure(connectionFailureAction);
sinon.assert.calledOnce(clearTimeout);
});
it("should call mozLoop.rooms.leave", function() {
store.connectionFailure();
store.connectionFailure(connectionFailureAction);
sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
@ -418,7 +461,7 @@ describe("loop.store.ActiveRoomStore", function () {
});
it("should set the state to `FAILED`", function() {
store.connectionFailure();
store.connectionFailure(connectionFailureAction);
expect(store.getStoreState().roomState).eql(ROOM_STATES.FAILED);
});

View File

@ -7,16 +7,19 @@ describe("loop.OTSdkDriver", function () {
"use strict";
var sharedActions = loop.shared.actions;
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
var sandbox;
var dispatcher, driver, publisher, sdk, session, sessionData;
var fakeLocalElement, fakeRemoteElement, publisherConfig;
var fakeLocalElement, fakeRemoteElement, publisherConfig, fakeEvent;
beforeEach(function() {
sandbox = sinon.sandbox.create();
fakeLocalElement = {fake: 1};
fakeRemoteElement = {fake: 2};
fakeEvent = {
preventDefault: sinon.stub()
};
publisherConfig = {
fake: "config"
};
@ -34,14 +37,14 @@ describe("loop.OTSdkDriver", function () {
subscribe: sinon.stub()
}, Backbone.Events);
publisher = {
publisher = _.extend({
destroy: sinon.stub(),
publishAudio: sinon.stub(),
publishVideo: sinon.stub()
};
}, Backbone.Events);
sdk = {
initPublisher: sinon.stub(),
initPublisher: sinon.stub().returns(publisher),
initSession: sinon.stub().returns(session)
};
@ -80,51 +83,6 @@ describe("loop.OTSdkDriver", function () {
sinon.assert.calledOnce(sdk.initPublisher);
sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig);
});
describe("On Publisher Complete", function() {
it("should publish the stream if the connection is ready", function() {
sdk.initPublisher.callsArgWith(2, null);
driver.session = session;
driver._sessionConnected = true;
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
sinon.assert.calledOnce(session.publish);
});
it("should dispatch connectionFailure if connecting failed", function() {
sdk.initPublisher.callsArgWith(2, new Error("Failure"));
// Special stub, as we want to use the dispatcher, but also know that
// we've been called correctly for the second dispatch.
var dispatchStub = (function() {
var originalDispatch = dispatcher.dispatch.bind(dispatcher);
return sandbox.stub(dispatcher, "dispatch", function(action) {
originalDispatch(action);
});
}());
driver.session = session;
driver._sessionConnected = true;
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
sinon.assert.called(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "noMedia"));
});
});
});
describe("#setMute", function() {
@ -194,7 +152,7 @@ describe("loop.OTSdkDriver", function () {
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "couldNotConnect"));
sinon.match.hasOwn("reason", FAILURE_REASONS.COULD_NOT_CONNECT));
});
});
});
@ -269,7 +227,7 @@ describe("loop.OTSdkDriver", function () {
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "networkDisconnected"));
sinon.match.hasOwn("reason", FAILURE_REASONS.NETWORK_DISCONNECTED));
});
});
@ -328,5 +286,41 @@ describe("loop.OTSdkDriver", function () {
sinon.assert.notCalled(dispatcher.dispatch);
});
});
describe("accessAllowed", function() {
it("should publish the stream if the connection is ready", function() {
driver._sessionConnected = true;
publisher.trigger("accessAllowed", fakeEvent);
sinon.assert.calledOnce(session.publish);
});
});
describe("accessDenied", function() {
it("should prevent the default event behavior", function() {
publisher.trigger("accessDenied", fakeEvent);
sinon.assert.calledOnce(fakeEvent.preventDefault);
});
it("should dispatch connectionFailure", function() {
publisher.trigger("accessDenied", fakeEvent);
sinon.assert.called(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", FAILURE_REASONS.MEDIA_DENIED));
});
});
describe("accessDialogOpened", function() {
it("should prevent the default event behavior", function() {
publisher.trigger("accessDialogOpened", fakeEvent);
sinon.assert.calledOnce(fakeEvent.preventDefault);
});
});
});
});

View File

@ -139,6 +139,16 @@ describe("loop.standaloneRoomViews", function() {
});
});
describe("Failed room message", function() {
it("should display a failed room message on FAILED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
expect(view.getDOMNode().querySelector(".failed-room-message"))
.not.eql(null);
});
});
describe("Join button", function() {
function getJoinButton(view) {
return view.getDOMNode().querySelector(".btn-join");

View File

@ -608,6 +608,16 @@
roomState: ROOM_STATES.FULL,
helper: {isFirefox: returnFalse}})
)
),
Example({summary: "Standalone room conversation (failed)"},
React.DOM.div({className: "standalone"},
StandaloneRoomView({
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.FAILED,
helper: {isFirefox: returnFalse}})
)
)
),

View File

@ -609,6 +609,16 @@
helper={{isFirefox: returnFalse}} />
</div>
</Example>
<Example summary="Standalone room conversation (failed)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.FAILED}
helper={{isFirefox: returnFalse}} />
</div>
</Example>
</Section>
<Section name="SVG icons preview">