Bug 1106941 - Part 1: Firefox Hello doesn't work properly when no video camera is installed - fix rooms and outgoing conversations. r=mikedeboer, a=lsblakk
authorMark Banner <standard8@mozilla.com>
Sun, 08 Mar 2015 13:16:32 +0000
changeset 250357 245598f10dcd
parent 250356 b526678ba6d2
child 250358 9e52698fd237
push id4557
push usermbanner@mozilla.com
push date2015-03-11 21:28 +0000
treeherdermozilla-beta@9e52698fd237 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer, lsblakk
bugs1106941
milestone37.0
Bug 1106941 - Part 1: Firefox Hello doesn't work properly when no video camera is installed - fix rooms and outgoing conversations. r=mikedeboer, a=lsblakk
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/content/shared/js/otSdkDriver.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/shared/conversationStore_test.js
browser/components/loop/test/shared/otSdkDriver_test.js
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -118,16 +118,17 @@ loop.conversation = (function(mozL10n) {
         navigator.mozLoop.setLoopPref("ot.guid", guid, PREF_STRING);
         callback(null);
       }
     });
 
     var dispatcher = new loop.Dispatcher();
     var client = new loop.Client();
     var sdkDriver = new loop.OTSdkDriver({
+      isDesktop: true,
       dispatcher: dispatcher,
       sdk: OT
     });
     var appVersionInfo = navigator.mozLoop.appVersionInfo;
     var feedbackClient = new loop.FeedbackAPIClient(
       navigator.mozLoop.getLoopPref("feedback.baseUrl"), {
       product: navigator.mozLoop.getLoopPref("feedback.product"),
       platform: appVersionInfo.OS,
@@ -137,20 +138,22 @@ loop.conversation = (function(mozL10n) {
 
     // Create the stores.
     var conversationAppStore = new loop.store.ConversationAppStore({
       dispatcher: dispatcher,
       mozLoop: navigator.mozLoop
     });
     var conversationStore = new loop.store.ConversationStore(dispatcher, {
       client: client,
+      isDesktop: true,
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
     var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
+      isDesktop: true,
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
     var roomStore = new loop.store.RoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
     var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -118,16 +118,17 @@ loop.conversation = (function(mozL10n) {
         navigator.mozLoop.setLoopPref("ot.guid", guid, PREF_STRING);
         callback(null);
       }
     });
 
     var dispatcher = new loop.Dispatcher();
     var client = new loop.Client();
     var sdkDriver = new loop.OTSdkDriver({
+      isDesktop: true,
       dispatcher: dispatcher,
       sdk: OT
     });
     var appVersionInfo = navigator.mozLoop.appVersionInfo;
     var feedbackClient = new loop.FeedbackAPIClient(
       navigator.mozLoop.getLoopPref("feedback.baseUrl"), {
       product: navigator.mozLoop.getLoopPref("feedback.product"),
       platform: appVersionInfo.OS,
@@ -137,20 +138,22 @@ loop.conversation = (function(mozL10n) {
 
     // Create the stores.
     var conversationAppStore = new loop.store.ConversationAppStore({
       dispatcher: dispatcher,
       mozLoop: navigator.mozLoop
     });
     var conversationStore = new loop.store.ConversationStore(dispatcher, {
       client: client,
+      isDesktop: true,
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
     var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
+      isDesktop: true,
       mozLoop: navigator.mozLoop,
       sdkDriver: sdkDriver
     });
     var roomStore = new loop.store.RoomStore(dispatcher, {
       mozLoop: navigator.mozLoop,
       activeRoomStore: activeRoomStore
     });
     var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -52,16 +52,18 @@ loop.store.ActiveRoomStore = (function()
         throw new Error("Missing option mozLoop");
       }
       this._mozLoop = options.mozLoop;
 
       if (!options.sdkDriver) {
         throw new Error("Missing option sdkDriver");
       }
       this._sdkDriver = options.sdkDriver;
+
+      this._isDesktop = options.isDesktop || false;
     },
 
     /**
      * Returns initial state data for this active room.
      */
     getInitialStoreState: function() {
       return {
         roomState: ROOM_STATES.INIT,
@@ -344,16 +346,31 @@ loop.store.ActiveRoomStore = (function()
     },
 
     /**
      * Handles disconnection of this local client from the sdk servers.
      *
      * @param {sharedActions.ConnectionFailure} actionData
      */
     connectionFailure: function(actionData) {
+      /**
+       * XXX This is a workaround for desktop machines that do not have a
+       * camera installed. As we don't yet have device enumeration, when
+       * we do, this can be removed (bug 1138851), and the sdk should handle it.
+       */
+      if (this._isDesktop &&
+          actionData.reason === FAILURE_REASONS.UNABLE_TO_PUBLISH_MEDIA &&
+          this.getStoreState().videoMuted === false) {
+        // We failed to publish with media, so due to the bug, we try again without
+        // video.
+        this.setStoreState({videoMuted: true});
+        this._sdkDriver.retryPublishWithoutVideo();
+        return;
+      }
+
       // 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);
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -5,16 +5,17 @@
 /* global loop:true */
 
 var loop = loop || {};
 loop.store = loop.store || {};
 
 (function() {
   var sharedActions = loop.shared.actions;
   var CALL_TYPES = loop.shared.utils.CALL_TYPES;
+  var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
 
   /**
    * Websocket states taken from:
    * https://docs.services.mozilla.com/loop/apis.html#call-progress-state-change-progress
    */
   var WS_STATES = loop.store.WS_STATES = {
     // The call is starting, and the remote party is not yet being alerted.
     INIT: "init",
@@ -126,25 +127,41 @@ loop.store = loop.store || {};
       }
       if (!options.mozLoop) {
         throw new Error("Missing option mozLoop");
       }
 
       this.client = options.client;
       this.sdkDriver = options.sdkDriver;
       this.mozLoop = options.mozLoop;
+      this._isDesktop = options.isDesktop || false;
     },
 
     /**
      * Handles the connection failure action, setting the state to
      * terminated.
      *
      * @param {sharedActions.ConnectionFailure} actionData The action data.
      */
     connectionFailure: function(actionData) {
+      /**
+       * XXX This is a workaround for desktop machines that do not have a
+       * camera installed. As we don't yet have device enumeration, when
+       * we do, this can be removed (bug 1138851), and the sdk should handle it.
+       */
+      if (this._isDesktop &&
+          actionData.reason === FAILURE_REASONS.UNABLE_TO_PUBLISH_MEDIA &&
+          this.getStoreState().videoMuted === false) {
+        // We failed to publish with media, so due to the bug, we try again without
+        // video.
+        this.setStoreState({videoMuted: true});
+        this.sdkDriver.retryPublishWithoutVideo();
+        return;
+      }
+
       this._endSession();
       this.setStoreState({
         callState: CALL_STATES.TERMINATED,
         callStateReason: actionData.reason
       });
     },
 
     /**
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -26,43 +26,79 @@ loop.OTSdkDriver = (function() {
       this.sdk = options.sdk;
 
       this.connections = {};
 
       this.dispatcher.register(this, [
         "setupStreamElements",
         "setMute"
       ]);
+
+    /**
+     * XXX This is a workaround for desktop machines that do not have a
+     * camera installed. As we don't yet have device enumeration, when
+     * we do, this can be removed (bug 1138851), and the sdk should handle it.
+     */
+    if ("isDesktop" in options && options.isDesktop &&
+        !window.MediaStreamTrack.getSources) {
+      // If there's no getSources function, the sdk defines its own and caches
+      // the result. So here we define the "normal" one which doesn't get cached, so
+      // we can change it later.
+      window.MediaStreamTrack.getSources = function(callback) {
+        callback([{kind: "audio"}, {kind: "video"}]);
+      };
+    }
   };
 
   OTSdkDriver.prototype = {
     /**
      * Handles the setupStreamElements action. Saves the required data and
      * kicks off the initialising of the publisher.
      *
      * @param {sharedActions.SetupStreamElements} actionData The data associated
      *   with the action. See action.js.
      */
     setupStreamElements: function(actionData) {
       this.getLocalElement = actionData.getLocalElementFunc;
       this.getRemoteElement = actionData.getRemoteElementFunc;
       this.publisherConfig = actionData.publisherConfig;
 
+      this.sdk.on("exception", this._onOTException.bind(this));
+
       // At this state we init the publisher, even though we might be waiting for
       // the initial connect of the session. This saves time when setting up
       // the media.
+      this._publishLocalStreams();
+    },
+
+    /**
+     * Internal function to publish a local stream.
+     * XXX This can be simplified when bug 1138851 is actioned.
+     */
+    _publishLocalStreams: function() {
       this.publisher = this.sdk.initPublisher(this.getLocalElement(),
         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));
     },
 
     /**
+     * Forces the sdk into not using video, and starts publishing again.
+     * XXX This is part of the work around that will be removed by bug 1138851.
+     */
+    retryPublishWithoutVideo: function() {
+      window.MediaStreamTrack.getSources = function(callback) {
+        callback([{kind: "audio"}]);
+      };
+      this._publishLocalStreams();
+    },
+
+    /**
      * Handles the setMute action. Informs the published stream to mute
      * or unmute audio as appropriate.
      *
      * @param {sharedActions.SetMute} actionData The data associated with the
      *                                           action. See action.js.
      */
     setMute: function(actionData) {
       if (actionData.type === "audio") {
@@ -277,16 +313,32 @@ loop.OTSdkDriver = (function() {
       // This prevents the SDK's "access denied" dialog showing.
       event.preventDefault();
 
       this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
         reason: FAILURE_REASONS.MEDIA_DENIED
       }));
     },
 
+    _onOTException: function(event) {
+      if (event.code === OT.ExceptionCodes.UNABLE_TO_PUBLISH &&
+          event.message === "Unknown Error while getting user media") {
+        // We free up the publisher here in case the store wants to try
+        // grabbing the media again.
+        if (this.publisher) {
+          this.publisher.off("accessAllowed accessDenied accessDialogOpened streamCreated");
+          this.publisher.destroy();
+          delete this.publisher;
+        }
+        this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+          reason: FAILURE_REASONS.UNABLE_TO_PUBLISH_MEDIA
+        }));
+      }
+    },
+
     /**
      * Publishes the local stream if the session is connected
      * and the publisher is ready.
      */
     _maybePublishLocalStream: function() {
       if (this._sessionConnected && this._publisherReady) {
         // We are clear to publish the stream to the session.
         this.session.publish(this.publisher);
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -14,16 +14,17 @@ loop.shared.utils = (function(mozL10n) {
    */
   var CALL_TYPES = {
     AUDIO_VIDEO: "audio-video",
     AUDIO_ONLY: "audio"
   };
 
   var FAILURE_REASONS = {
     MEDIA_DENIED: "reason-media-denied",
+    UNABLE_TO_PUBLISH_MEDIA: "unable-to-publish-media",
     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.
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -30,17 +30,18 @@ describe("loop.store.ActiveRoomStore", f
         on: sinon.stub(),
         off: sinon.stub()
       }
     };
 
     fakeSdkDriver = {
       connectSession: sandbox.stub(),
       disconnectSession: sandbox.stub(),
-      forceDisconnectAll: sandbox.stub().callsArg(0)
+      forceDisconnectAll: sandbox.stub().callsArg(0),
+      retryPublishWithoutVideo: sinon.stub()
     };
 
     fakeMultiplexGum = {
         reset: sandbox.spy()
     };
 
     loop.standaloneMedia = {
       multiplexGum: fakeMultiplexGum
@@ -550,16 +551,36 @@ describe("loop.store.ActiveRoomStore", f
         sessionToken: "1627384950"
       });
 
       connectionFailureAction = new sharedActions.ConnectionFailure({
         reason: "FAIL"
       });
     });
 
+    it("should retry publishing if on desktop, and in the videoMuted state", function() {
+      store._isDesktop = true;
+
+      store.connectionFailure(new sharedActions.ConnectionFailure({
+        reason: FAILURE_REASONS.UNABLE_TO_PUBLISH_MEDIA
+      }));
+
+      sinon.assert.calledOnce(fakeSdkDriver.retryPublishWithoutVideo);
+    });
+
+    it("should set videoMuted to try when retrying publishing", function() {
+      store._isDesktop = true;
+
+      store.connectionFailure(new sharedActions.ConnectionFailure({
+        reason: FAILURE_REASONS.UNABLE_TO_PUBLISH_MEDIA
+      }));
+
+      expect(store.getStoreState().videoMuted).eql(true);
+    });
+
     it("should store the failure reason", function() {
       store.connectionFailure(connectionFailureAction);
 
       expect(store.getStoreState().failureReason).eql("FAIL");
     });
 
     it("should reset the multiplexGum", function() {
       store.connectionFailure(connectionFailureAction);
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -3,16 +3,17 @@
 
 var expect = chai.expect;
 
 describe("loop.store.ConversationStore", function () {
   "use strict";
 
   var CALL_STATES = loop.store.CALL_STATES;
   var WS_STATES = loop.store.WS_STATES;
+  var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
   var sharedActions = loop.shared.actions;
   var sharedUtils = loop.shared.utils;
   var sandbox, dispatcher, client, store, fakeSessionData, sdkDriver;
   var contact, fakeMozLoop;
   var connectPromise, resolveConnectPromise, rejectConnectPromise;
   var wsCancelSpy, wsCloseSpy, wsMediaUpSpy, fakeWebsocket;
 
   function checkFailures(done, f) {
@@ -50,17 +51,18 @@ describe("loop.store.ConversationStore",
 
     dispatcher = new loop.Dispatcher();
     client = {
       setupOutgoingCall: sinon.stub(),
       requestCallUrl: sinon.stub()
     };
     sdkDriver = {
       connectSession: sinon.stub(),
-      disconnectSession: sinon.stub()
+      disconnectSession: sinon.stub(),
+      retryPublishWithoutVideo: sinon.stub()
     };
 
     wsCancelSpy = sinon.spy();
     wsCloseSpy = sinon.spy();
     wsMediaUpSpy = sinon.spy();
 
     fakeWebsocket = {
       cancel: wsCancelSpy,
@@ -129,16 +131,36 @@ describe("loop.store.ConversationStore",
   });
 
   describe("#connectionFailure", function() {
     beforeEach(function() {
       store._websocket = fakeWebsocket;
       store.setStoreState({windowId: "42"});
     });
 
+    it("should retry publishing if on desktop, and in the videoMuted state", function() {
+      store._isDesktop = true;
+
+      store.connectionFailure(new sharedActions.ConnectionFailure({
+        reason: FAILURE_REASONS.UNABLE_TO_PUBLISH_MEDIA
+      }));
+
+      sinon.assert.calledOnce(sdkDriver.retryPublishWithoutVideo);
+    });
+
+    it("should set videoMuted to try when retrying publishing", function() {
+      store._isDesktop = true;
+
+      store.connectionFailure(new sharedActions.ConnectionFailure({
+        reason: FAILURE_REASONS.UNABLE_TO_PUBLISH_MEDIA
+      }));
+
+      expect(store.getStoreState().videoMuted).eql(true);
+    });
+
     it("should disconnect the session", function() {
       store.connectionFailure(
         new sharedActions.ConnectionFailure({reason: "fake"}));
 
       sinon.assert.calledOnce(sdkDriver.disconnectSession);
     });
 
     it("should ensure the websocket is closed", function() {
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -39,19 +39,25 @@ describe("loop.OTSdkDriver", function ()
     }, Backbone.Events);
 
     publisher = _.extend({
       destroy: sinon.stub(),
       publishAudio: sinon.stub(),
       publishVideo: sinon.stub()
     }, Backbone.Events);
 
-    sdk = {
+    sdk = _.extend({
       initPublisher: sinon.stub().returns(publisher),
       initSession: sinon.stub().returns(session)
+    }, Backbone.Events);
+
+    window.OT = {
+      ExceptionCodes: {
+        UNABLE_TO_PUBLISH: 1500
+      }
     };
 
     driver = new loop.OTSdkDriver({
       dispatcher: dispatcher,
       sdk: sdk
     });
   });
 
@@ -81,16 +87,47 @@ describe("loop.OTSdkDriver", function ()
         publisherConfig: publisherConfig
       }));
 
       sinon.assert.calledOnce(sdk.initPublisher);
       sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig);
     });
   });
 
+  describe("#retryPublishWithoutVideo", function() {
+    beforeEach(function() {
+      sdk.initPublisher.returns(publisher);
+
+      driver.setupStreamElements(new sharedActions.SetupStreamElements({
+        getLocalElementFunc: function() {return fakeLocalElement;},
+        getRemoteElementFunc: function() {return fakeRemoteElement;},
+        publisherConfig: publisherConfig
+      }));
+    });
+
+    it("should make MediaStreamTrack.getSources return without a video source", function(done) {
+      driver.retryPublishWithoutVideo();
+
+      window.MediaStreamTrack.getSources(function(sources) {
+        expect(sources.some(function(src) {
+          return src.kind === "video";
+        })).eql(false);
+
+        done();
+      });
+    });
+
+    it("should call initPublisher", function() {
+      driver.retryPublishWithoutVideo();
+
+      sinon.assert.calledTwice(sdk.initPublisher);
+      sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig);
+    });
+  });
+
   describe("#setMute", function() {
     beforeEach(function() {
       sdk.initPublisher.returns(publisher);
 
       dispatcher.dispatch(new sharedActions.SetupStreamElements({
         getLocalElementFunc: function() {return fakeLocalElement;},
         getRemoteElementFunc: function() {return fakeRemoteElement;},
         publisherConfig: publisherConfig