Bug 1172662 - ICE failures occuring in Loop conversations should be reported to the user. r=Standard8
authorManuel Casas <manuel.casasbarrado@gmail.com>
Mon, 05 Oct 2015 14:40:15 +0100
changeset 298792 9cb85a2c008e3fc21e8791a65967734478e203c4
parent 298791 be2e4d37fbc2589f267836b9424923ed32014c0d
child 298793 736c3a039fce46b974a412c3ede62707d6162950
push id6119
push usercliu@mozilla.com
push dateMon, 05 Oct 2015 18:41:22 +0000
reviewersStandard8
bugs1172662
milestone44.0a1
Bug 1172662 - ICE failures occuring in Loop conversations should be reported to the user. r=Standard8
browser/components/loop/content/js/conversationViews.js
browser/components/loop/content/js/conversationViews.jsx
browser/components/loop/content/js/roomViews.js
browser/components/loop/content/js/roomViews.jsx
browser/components/loop/content/shared/js/otSdkDriver.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
browser/components/loop/standalone/content/l10n/en-US/loop.properties
browser/components/loop/test/desktop-local/conversationViews_test.js
browser/components/loop/test/desktop-local/roomViews_test.js
browser/components/loop/test/shared/otSdkDriver_test.js
browser/components/loop/test/standalone/standaloneRoomViews_test.js
browser/locales/en-US/chrome/browser/loop/loop.properties
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -399,16 +399,18 @@ loop.conversationViews = (function(mozL1
           }
           return mozL10n.get("generic_contact_unavailable_title");
         case FAILURE_DETAILS.NO_MEDIA:
         case FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA:
           return mozL10n.get("no_media_failure_message");
         case FAILURE_DETAILS.TOS_FAILURE:
           return mozL10n.get("tos_failure_message",
             { clientShortname: mozL10n.get("clientShortname2") });
+        case FAILURE_DETAILS.ICE_FAILED:
+          return mozL10n.get("ice_failure_message");
         default:
           return mozL10n.get("generic_failure_message");
       }
     },
 
     _renderExtraMessage: function() {
       if (this.props.extraMessage) {
         return React.createElement("p", {className: "failure-info-extra"}, this.props.extraMessage);
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -399,16 +399,18 @@ loop.conversationViews = (function(mozL1
           }
           return mozL10n.get("generic_contact_unavailable_title");
         case FAILURE_DETAILS.NO_MEDIA:
         case FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA:
           return mozL10n.get("no_media_failure_message");
         case FAILURE_DETAILS.TOS_FAILURE:
           return mozL10n.get("tos_failure_message",
             { clientShortname: mozL10n.get("clientShortname2") });
+        case FAILURE_DETAILS.ICE_FAILED:
+          return mozL10n.get("ice_failure_message");
         default:
           return mozL10n.get("generic_failure_message");
       }
     },
 
     _renderExtraMessage: function() {
       if (this.props.extraMessage) {
         return <p className="failure-info-extra">{this.props.extraMessage}</p>;
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -3,16 +3,17 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
   var ROOM_STATES = loop.store.ROOM_STATES;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
+  var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedUtils = loop.shared.utils;
   var sharedViews = loop.shared.views;
 
   /**
    * ActiveRoomStore mixin.
    * @type {Object}
@@ -94,24 +95,32 @@ loop.roomViews = (function(mozL10n) {
       this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
     },
 
     render: function() {
       var settingsMenuItems = [
         { id: "feedback" },
         { id: "help" }
       ];
+
+      var btnTitle;
+      if (this.props.failureReason === FAILURE_DETAILS.ICE_FAILED) {
+        btnTitle = mozL10n.get("retry_call_button");
+      } else {
+        btnTitle = mozL10n.get("rejoin_button");
+      }
+
       return (
         React.createElement("div", {className: "room-failure"}, 
           React.createElement(loop.conversationViews.FailureInfoView, {
             failureReason: this.props.failureReason}), 
           React.createElement("div", {className: "btn-group call-action-group"}, 
             React.createElement("button", {className: "btn btn-info btn-rejoin", 
                     onClick: this.handleRejoinCall}, 
-              mozL10n.get("rejoin_button")
+              btnTitle
             )
           ), 
           React.createElement(loop.shared.views.SettingsControlButton, {
             menuBelow: true, 
             menuItems: settingsMenuItems, 
             mozLoop: this.props.mozLoop})
         )
       );
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -3,16 +3,17 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 var loop = loop || {};
 loop.roomViews = (function(mozL10n) {
   "use strict";
 
   var ROOM_STATES = loop.store.ROOM_STATES;
   var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
+  var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
   var sharedActions = loop.shared.actions;
   var sharedMixins = loop.shared.mixins;
   var sharedUtils = loop.shared.utils;
   var sharedViews = loop.shared.views;
 
   /**
    * ActiveRoomStore mixin.
    * @type {Object}
@@ -94,24 +95,32 @@ loop.roomViews = (function(mozL10n) {
       this.props.dispatcher.dispatch(new sharedActions.JoinRoom());
     },
 
     render: function() {
       var settingsMenuItems = [
         { id: "feedback" },
         { id: "help" }
       ];
+
+      var btnTitle;
+      if (this.props.failureReason === FAILURE_DETAILS.ICE_FAILED) {
+        btnTitle = mozL10n.get("retry_call_button");
+      } else {
+        btnTitle = mozL10n.get("rejoin_button");
+      }
+
       return (
         <div className="room-failure">
           <loop.conversationViews.FailureInfoView
             failureReason={this.props.failureReason} />
           <div className="btn-group call-action-group">
             <button className="btn btn-info btn-rejoin"
                     onClick={this.handleRejoinCall}>
-              {mozL10n.get("rejoin_button")}
+              {btnTitle}
             </button>
           </div>
           <loop.shared.views.SettingsControlButton
             menuBelow={true}
             menuItems={settingsMenuItems}
             mozLoop={this.props.mozLoop} />
         </div>
       );
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -923,16 +923,23 @@ loop.OTSdkDriver = (function() {
         reason: FAILURE_DETAILS.MEDIA_DENIED
       }));
 
       delete this._mockPublisherEl;
     },
 
     _onOTException: function(event) {
       switch (event.code) {
+        case OT.ExceptionCodes.PUBLISHER_ICE_WORKFLOW_FAILED:
+        case OT.ExceptionCodes.SUBSCRIBER_ICE_WORKFLOW_FAILED:
+          this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
+            reason: FAILURE_DETAILS.ICE_FAILED
+          }));
+          this._notifyMetricsEvent("sdk.exception." + event.code);
+          break;
         case OT.ExceptionCodes.UNABLE_TO_PUBLISH:
           if (event.message === "GetUserMedia") {
             // 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;
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -79,17 +79,18 @@ var inChrome = typeof Components != "und
     UNABLE_TO_PUBLISH_MEDIA: "unable-to-publish-media",
     USER_UNAVAILABLE: "reason-user-unavailable",
     COULD_NOT_CONNECT: "reason-could-not-connect",
     NETWORK_DISCONNECTED: "reason-network-disconnected",
     EXPIRED_OR_INVALID: "reason-expired-or-invalid",
     // TOS_FAILURE reflects the sdk error code 1026:
     // https://tokbox.com/developer/sdks/js/reference/ExceptionEvent.html
     TOS_FAILURE: "reason-tos-failure",
-    UNKNOWN: "reason-unknown"
+    UNKNOWN: "reason-unknown",
+    ICE_FAILED: "reason-ice-failed"
   };
 
   var ROOM_INFO_FAILURES = {
     // There's no data available from the server.
     NO_DATA: "no_data",
     // WebCrypto is unsupported in this browser.
     WEB_CRYPTO_UNSUPPORTED: "web_crypto_unsupported",
     // The room is missing the crypto key information.
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -153,16 +153,18 @@ loop.standaloneRoomViews = (function(moz
         // XXX Bug 1166824 should provide a better string for this.
         case FAILURE_DETAILS.NO_MEDIA:
           return mozL10n.get("rooms_media_denied_message");
         case FAILURE_DETAILS.EXPIRED_OR_INVALID:
           return mozL10n.get("rooms_unavailable_notification_message");
         case FAILURE_DETAILS.TOS_FAILURE:
           return mozL10n.get("tos_failure_message",
             { clientShortname: mozL10n.get("clientShortname2") });
+        case FAILURE_DETAILS.ICE_FAILED:
+          return mozL10n.get("rooms_ice_failure_message");
         default:
           return mozL10n.get("status_error");
       }
     },
 
     /**
      * This renders a retry button if one is necessary.
      */
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -153,16 +153,18 @@ loop.standaloneRoomViews = (function(moz
         // XXX Bug 1166824 should provide a better string for this.
         case FAILURE_DETAILS.NO_MEDIA:
           return mozL10n.get("rooms_media_denied_message");
         case FAILURE_DETAILS.EXPIRED_OR_INVALID:
           return mozL10n.get("rooms_unavailable_notification_message");
         case FAILURE_DETAILS.TOS_FAILURE:
           return mozL10n.get("tos_failure_message",
             { clientShortname: mozL10n.get("clientShortname2") });
+        case FAILURE_DETAILS.ICE_FAILED:
+          return mozL10n.get("rooms_ice_failure_message");
         default:
           return mozL10n.get("status_error");
       }
     },
 
     /**
      * This renders a retry button if one is necessary.
      */
--- a/browser/components/loop/standalone/content/l10n/en-US/loop.properties
+++ b/browser/components/loop/standalone/content/l10n/en-US/loop.properties
@@ -67,16 +67,17 @@ rooms_room_joined_label=Someone has join
 rooms_room_join_label=Join the conversation
 rooms_room_joined_own_conversation_label=Enjoy your conversation
 rooms_already_joined=You're already in this 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.
 room_information_failure_not_available=No information about this conversation is available. Please request a new link from the person who sent it to you.
 room_information_failure_unsupported_browser=Your browser cannot access any information about this conversation. Please make sure you're using the latest version.
+rooms_ice_failure_message=Connection failed. Your firewall may be blocking calls.
 
 ## LOCALIZATION_NOTE(rooms_read_while_wait_offer): This string is followed by a
 # tile/offer image and title that are provided by a separate service that has
 # localized content.
 rooms_read_while_wait_offer=Want something to read while you wait?
 
 ## LOCALIZATION_NOTE(standalone_title_with_room_name): {{roomName}} will be replaced
 ## by the name of the conversation and {{clientShortname}} will be
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -346,16 +346,26 @@ describe("loop.conversationViews", funct
         extraFailureMessage: "Fake failure message",
         failureReason: FAILURE_DETAILS.UNKNOWN
       });
 
       var extraFailureMessage = view.getDOMNode().querySelector(".failure-info-extra-failure");
 
       expect(extraFailureMessage.textContent).eql("Fake failure message");
     });
+
+    it("should display an ICE failure message", function() {
+      view = mountTestComponent({
+        failureReason: FAILURE_DETAILS.ICE_FAILED
+      });
+
+      var message = view.getDOMNode().querySelector(".failure-info-message");
+
+      expect(message.textContent).eql("ice_failure_message");
+    });
   });
 
   describe("DirectCallFailureView", function() {
     var fakeAudio, composeCallUrlEmail;
 
     var fakeContact = {email: [{value: "test@test.tld"}]};
 
     function mountTestComponent(options) {
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -137,17 +137,17 @@ describe("loop.roomViews", function () {
   });
 
   describe("RoomFailureView", function() {
     var fakeAudio;
 
     function mountTestComponent(props) {
       props = _.extend({
         dispatcher: dispatcher,
-        failureReason: FAILURE_DETAILS.UNKNOWN,
+        failureReason: props && props.failureReason || FAILURE_DETAILS.UNKNOWN,
         mozLoop: fakeMozLoop
       });
       return TestUtils.renderIntoDocument(
         React.createElement(loop.roomViews.RoomFailureView, props));
     }
 
     beforeEach(function() {
       fakeAudio = {
@@ -172,16 +172,26 @@ describe("loop.roomViews", function () {
 
       React.addons.TestUtils.Simulate.click(rejoinBtn);
 
       sinon.assert.calledOnce(dispatcher.dispatch);
       sinon.assert.calledWithExactly(dispatcher.dispatch,
         new sharedActions.JoinRoom());
     });
 
+    it("should render retry button when an ice failure is dispatched", function() {
+      view = mountTestComponent({
+        failureReason: FAILURE_DETAILS.ICE_FAILED
+      });
+
+      var retryBtn = view.getDOMNode().querySelector(".btn-rejoin");
+
+      expect(retryBtn.textContent).eql("retry_call_button");
+    });
+
     it("should play a failure sound, once", function() {
       view = mountTestComponent();
 
       sinon.assert.calledOnce(fakeMozLoop.getAudioBlob);
       sinon.assert.calledWithExactly(fakeMozLoop.getAudioBlob,
                                      "failure", sinon.match.func);
       sinon.assert.calledOnce(fakeAudio.play);
       expect(fakeAudio.loop).to.equal(false);
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -1656,16 +1656,61 @@ describe("loop.OTSdkDriver", function ()
               event: "sdk.exception." + OT.ExceptionCodes.TERMS_OF_SERVICE_FAILURE,
               state: "starting",
               connections: 0,
               sendStreams: 0,
               recvStreams: 0
             }));
         });
       });
+
+      describe("ICE failed", function() {
+        it("should dispatch a ConnectionFailure action (Publisher)", function() {
+          sdk.trigger("exception", {
+            code: OT.ExceptionCodes.PUBLISHER_ICE_WORKFLOW_FAILED,
+            message: "ICE failed"
+          });
+
+          sinon.assert.calledTwice(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.ConnectionFailure({
+              reason: FAILURE_DETAILS.ICE_FAILED
+            }));
+        });
+
+        it("should dispatch a ConnectionFailure action (Subscriber)", function() {
+          sdk.trigger("exception", {
+            code: OT.ExceptionCodes.SUBSCRIBER_ICE_WORKFLOW_FAILED,
+            message: "ICE failed"
+          });
+
+          sinon.assert.calledTwice(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.ConnectionFailure({
+              reason: FAILURE_DETAILS.ICE_FAILED
+            }));
+        });
+
+        it("should notify metrics", function() {
+          sdk.trigger("exception", {
+            code: OT.ExceptionCodes.PUBLISHER_ICE_WORKFLOW_FAILED,
+            message: "ICE failed"
+          });
+
+          sinon.assert.calledTwice(dispatcher.dispatch);
+          sinon.assert.calledWithExactly(dispatcher.dispatch,
+            new sharedActions.ConnectionStatus({
+              event: "sdk.exception." + OT.ExceptionCodes.PUBLISHER_ICE_WORKFLOW_FAILED,
+              state: "starting",
+              connections: 0,
+              sendStreams: 0,
+              recvStreams: 0
+            }));
+        });
+      });
     });
   });
 
   describe("Events: screenshare:", function() {
     var videoElement;
 
     beforeEach(function() {
       driver.connectSession(sessionData);
--- a/browser/components/loop/test/standalone/standaloneRoomViews_test.js
+++ b/browser/components/loop/test/standalone/standaloneRoomViews_test.js
@@ -647,16 +647,27 @@ describe("loop.standaloneRoomViews", fun
 
       describe("Failed room message", function() {
         it("should display the StandaloneRoomFailureView", function() {
           activeRoomStore.setStoreState({ roomState: ROOM_STATES.FAILED });
 
           TestUtils.findRenderedComponentWithType(view,
             loop.standaloneRoomViews.StandaloneRoomFailureView);
         });
+
+        it("should display ICE failure message", function() {
+          activeRoomStore.setStoreState({
+            roomState: ROOM_STATES.FAILED,
+            failureReason: FAILURE_DETAILS.ICE_FAILED
+          });
+
+          var ice_failed_message = view.getDOMNode().querySelector(".failed-room-message").textContent;
+          expect(ice_failed_message).eql("rooms_ice_failure_message");
+          expect(view.getDOMNode().querySelector(".btn-info")).not.eql(null);
+        });
       });
 
       describe("Join button", function() {
         function getJoinButton(elem) {
           return elem.getDOMNode().querySelector(".btn-join");
         }
 
         it("should render the Join button when room isn't active", function() {
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -293,16 +293,17 @@ call_timeout_notification_text=Your call
 retry_call_button=Retry
 cancel_button=Cancel
 rejoin_button=Rejoin Conversation
 
 cannot_start_call_session_not_ready=Can't start call, session is not ready.
 network_disconnected=The network connection terminated abruptly.
 connection_error_see_console_notification=Call failed; see console for details.
 no_media_failure_message=No camera or microphone found.
+ice_failure_message=Connection failed. Your firewall may be blocking calls.
 
 ## LOCALIZATION NOTE (legal_text_and_links3): In this item, don't translate the
 ## parts between {{..}} because these will be replaced with links with the labels
 ## from legal_text_tos and legal_text_privacy. clientShortname will be replaced
 ## by the brand name.
 legal_text_and_links3=By using {{clientShortname}} you agree to the {{terms_of_use}} \
   and {{privacy_notice}}.
 legal_text_tos = Terms of Use