Bug 1048425 - Enable support for tab sharing with Roku device. r=wesj r=mfinkle
authorRandall Barker <rbarker@mozilla.com>
Fri, 19 Sep 2014 13:50:00 +0200
changeset 206470 4f539eec6a3edd6568f3ea685f6018aa23f34c16
parent 206469 6e195997b6848f3a0d4173030b518db3b3c29d20
child 206471 6ebbcb6f3b0347fe1e53d7a0cdefc3d744e23284
push id27528
push userryanvm@gmail.com
push dateMon, 22 Sep 2014 19:27:54 +0000
treeherdermozilla-central@d8688cafc752 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerswesj, mfinkle
bugs1048425
milestone35.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1048425 - Enable support for tab sharing with Roku device. r=wesj r=mfinkle
mobile/android/app/mobile.js
mobile/android/chrome/content/CastingApps.js
mobile/android/modules/RokuApp.jsm
mobile/android/modules/SimpleServiceDiscovery.jsm
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -278,16 +278,23 @@ pref("browser.search.noCurrentEngine", t
 
 #ifdef MOZ_OFFICIAL_BRANDING
 // {moz:official} expands to "official"
 pref("browser.search.official", true);
 #endif
 
 // Control media casting feature
 pref("browser.casting.enabled", true);
+#ifdef RELEASE_BUILD
+pref("browser.mirroring.enabled", false);
+pref("browser.mirroring.enabled.roku", false);
+#else
+pref("browser.mirroring.enabled", true);
+pref("browser.mirroring.enabled.roku", true);
+#endif
 
 // Enable sparse localization by setting a few package locale overrides
 pref("chrome.override_package.global", "browser");
 pref("chrome.override_package.mozapps", "browser");
 pref("chrome.override_package.passwordmgr", "browser");
 
 // enable xul error pages
 pref("browser.xul.error_pages.enabled", true);
--- a/mobile/android/chrome/content/CastingApps.js
+++ b/mobile/android/chrome/content/CastingApps.js
@@ -11,16 +11,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 // JSM files, but we left them here to allow for better lazy JSM loading.
 var rokuDevice = {
   id: "roku:ecp",
   target: "roku:ecp",
   factory: function(aService) {
     Cu.import("resource://gre/modules/RokuApp.jsm");
     return new RokuApp(aService);
   },
+  mirror: Services.prefs.getBoolPref("browser.mirroring.enabled.roku"),
   types: ["video/mp4"],
   extensions: ["mp4"]
 };
 
 var fireflyDevice = {
   id: "firefly:dial",
   target: "urn:dial-multiscreen-org:service:dial:1",
   filters: {
@@ -47,17 +48,17 @@ var mediaPlayerDevice = {
 };
 
 var CastingApps = {
   _castMenuId: -1,
   mirrorStartMenuId: -1,
   mirrorStopMenuId: -1,
 
   init: function ca_init() {
-    if (!this.isEnabled()) {
+    if (!this.isCastingEnabled()) {
       return;
     }
 
     // Register targets
     SimpleServiceDiscovery.registerDevice(rokuDevice);
     SimpleServiceDiscovery.registerDevice(fireflyDevice);
     SimpleServiceDiscovery.registerDevice(mediaPlayerDevice);
 
@@ -94,50 +95,58 @@ var CastingApps = {
     Services.obs.removeObserver(this, "Casting:Stop");
     Services.obs.removeObserver(this, "Casting:Mirror");
     Services.obs.removeObserver(this, "ssdp-service-found");
     Services.obs.removeObserver(this, "ssdp-service-lost");
 
     NativeWindow.contextmenus.remove(this._castMenuId);
   },
 
+  _mirrorStarted: function(stopMirrorCallback) {
+    this.stopMirrorCallback = stopMirrorCallback;
+    NativeWindow.menu.update(this.mirrorStartMenuId, { visible: false });
+    NativeWindow.menu.update(this.mirrorStopMenuId, { visible: true });
+  },
+
   serviceAdded: function(aService) {
-    if (aService.mirror && this.mirrorStartMenuId == -1) {
+    if (this.isMirroringEnabled() && aService.mirror && this.mirrorStartMenuId == -1) {
       this.mirrorStartMenuId = NativeWindow.menu.add({
         name: Strings.browser.GetStringFromName("casting.mirrorTab"),
         callback: function() {
-          function callbackFunc(aService) {
+          let callbackFunc = function(aService) {
             let app = SimpleServiceDiscovery.findAppForService(aService);
-            if (app)
-              app.mirror(function() {
-              });
-          }
+            if (app) {
+              app.mirror(function() {}, window, BrowserApp.selectedTab.getViewport(), this._mirrorStarted.bind(this));
+            }
+          }.bind(this);
 
-          function filterFunc(aService) {
-            return aService.mirror == true;
-          }
-          this.prompt(callbackFunc, filterFunc);
+          this.prompt(callbackFunc, aService => aService.mirror);
         }.bind(this),
         parent: NativeWindow.menu.toolsMenuID
       });
 
       this.mirrorStopMenuId = NativeWindow.menu.add({
         name: Strings.browser.GetStringFromName("casting.mirrorTabStop"),
         callback: function() {
           if (this.tabMirror) {
             this.tabMirror.stop();
             this.tabMirror = null;
+          } else if (this.stopMirrorCallback) {
+            this.stopMirrorCallback();
+            this.stopMirrorCallback = null;
           }
           NativeWindow.menu.update(this.mirrorStartMenuId, { visible: true });
           NativeWindow.menu.update(this.mirrorStopMenuId, { visible: false });
         }.bind(this),
         parent: NativeWindow.menu.toolsMenuID
       });
     }
-    NativeWindow.menu.update(this.mirrorStopMenuId, { visible: false });
+    if (this.mirrorStartMenuId != -1) {
+      NativeWindow.menu.update(this.mirrorStopMenuId, { visible: false });
+    }
   },
 
   serviceLost: function(aService) {
     if (aService.mirror && this.mirrorStartMenuId != -1) {
       let haveMirror = false;
       SimpleServiceDiscovery.services.forEach(function(service) {
         if (service.mirror) {
           haveMirror = true;
@@ -145,20 +154,24 @@ var CastingApps = {
       });
       if (!haveMirror) {
         NativeWindow.menu.remove(this.mirrorStartMenuId);
         this.mirrorStartMenuId = -1;
       }
     }
   },
 
-  isEnabled: function isEnabled() {
+  isCastingEnabled: function isCastingEnabled() {
     return Services.prefs.getBoolPref("browser.casting.enabled");
   },
 
+  isMirroringEnabled: function isMirroringEnabled() {
+    return Services.prefs.getBoolPref("browser.mirroring.enabled");
+  },
+
   observe: function (aSubject, aTopic, aData) {
     switch (aTopic) {
       case "Casting:Play":
         if (this.session && this.session.remoteMedia.status == "paused") {
           this.session.remoteMedia.play();
         }
         break;
       case "Casting:Pause":
--- a/mobile/android/modules/RokuApp.jsm
+++ b/mobile/android/modules/RokuApp.jsm
@@ -6,16 +6,20 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["RokuApp"];
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 
+const WEBRTC_PLAYER_NAME = "WebRTC Player";
+const MIRROR_PORT = 8011;
+const JSON_MESSAGE_TERMINATOR = "\r\n";
+
 function log(msg) {
   //Services.console.logStringMessage(msg);
 }
 
 const PROTOCOL_VERSION = 1;
 
 /* RokuApp is a wrapper for interacting with a Roku channel.
  * The basic interactions all use a REST API.
@@ -24,36 +28,39 @@ const PROTOCOL_VERSION = 1;
 function RokuApp(service) {
   this.service = service;
   this.resourceURL = this.service.location;
 #ifdef RELEASE_BUILD
   this.app = "Firefox";
 #else
   this.app = "Firefox Nightly";
 #endif
-  this.appID = -1;
+  this.mediaAppID = -1;
+  this.mirrorAppID = -1;
 }
 
 RokuApp.prototype = {
   status: function status(callback) {
     // We have no way to know if the app is running, so just return "unknown"
-    // but we use this call to fetch the appID for the given app name
+    // but we use this call to fetch the mediaAppID for the given app name
     let url = this.resourceURL + "query/apps";
     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
     xhr.open("GET", url, true);
     xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
     xhr.overrideMimeType("text/xml");
 
     xhr.addEventListener("load", (function() {
       if (xhr.status == 200) {
         let doc = xhr.responseXML;
         let apps = doc.querySelectorAll("app");
         for (let app of apps) {
           if (app.textContent == this.app) {
-            this.appID = app.id;
+            this.mediaAppID = app.id;
+          } else if (app.textContent == WEBRTC_PLAYER_NAME) {
+            this.mirrorAppID = app.id
           }
         }
       }
 
       // Since ECP has no way of telling us if an app is running, we always return "unknown"
       if (callback) {
         callback({ state: "unknown" });
       }
@@ -64,33 +71,33 @@ RokuApp.prototype = {
         callback({ state: "unknown" });
       }
     }).bind(this), false);
 
     xhr.send(null);
   },
 
   start: function start(callback) {
-    // We need to make sure we have cached the appID
-    if (this.appID == -1) {
+    // We need to make sure we have cached the mediaAppID
+    if (this.mediaAppID == -1) {
       this.status(function() {
-        // If we found the appID, use it to make a new start call
-        if (this.appID != -1) {
+        // If we found the mediaAppID, use it to make a new start call
+        if (this.mediaAppID != -1) {
           this.start(callback);
         } else {
           // We failed to start the app, so let the caller know
           callback(false);
         }
       }.bind(this));
       return;
     }
 
     // Start a given app with any extra query data. Each app uses it's own data scheme.
     // NOTE: Roku will also pass "source=external-control" as a param
-    let url = this.resourceURL + "launch/" + this.appID + "?version=" + parseInt(PROTOCOL_VERSION);
+    let url = this.resourceURL + "launch/" + this.mediaAppID + "?version=" + parseInt(PROTOCOL_VERSION);
     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
     xhr.open("POST", url, true);
     xhr.overrideMimeType("text/plain");
 
     xhr.addEventListener("load", (function() {
       if (callback) {
         callback(xhr.status === 200);
       }
@@ -124,25 +131,63 @@ RokuApp.prototype = {
         callback(false);
       }
     }).bind(this), false);
 
     xhr.send(null);
   },
 
   remoteMedia: function remoteMedia(callback, listener) {
-    if (this.appID != -1) {
+    if (this.mediaAppID != -1) {
       if (callback) {
         callback(new RemoteMedia(this.resourceURL, listener));
       }
     } else {
       if (callback) {
         callback();
       }
     }
+  },
+
+  mirror: function(callback, win, viewport, mirrorStartedCallback) {
+    if (this.mirrorAppID == -1) {
+      // The status function may not have been called yet if mirrorAppID is -1
+      this.status(this._createRemoteMirror.bind(this, callback, win, viewport, mirrorStartedCallback));
+    } else {
+      this._createRemoteMirror(callback, win, viewport, mirrorStartedCallback);
+    }
+  },
+
+  _createRemoteMirror: function(callback, win, viewport, mirrorStartedCallback) {
+    if (this.mirrorAppID == -1) {
+      // TODO: Inform user to install Roku WebRTC Player Channel.
+      log("RokuApp: Failed to find Mirror App ID.");
+    } else {
+      let url = this.resourceURL + "launch/" + this.mirrorAppID;
+      let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
+      xhr.open("POST", url, true);
+      xhr.overrideMimeType("text/plain");
+
+      xhr.addEventListener("load", (function() {
+        // 204 seems to be returned if the channel is already running
+        if ((xhr.status == 200) || (xhr.status == 204)) {
+          this.remoteMirror = new RemoteMirror(this.resourceURL, win, viewport, mirrorStartedCallback);
+        }
+      }).bind(this), false);
+
+      xhr.addEventListener("error", function() {
+        log("RokuApp: XHR Failed to launch application: " + WEBRTC_PLAYER_NAME);
+      }, false);
+
+      xhr.send(null);
+    }
+
+    if (callback) {
+      callback();
+    }
   }
 }
 
 /* RemoteMedia provides a wrapper for using TCP socket to control Roku apps.
  * The server implementation must be built into the Roku receiver app.
  */
 function RemoteMedia(url, listener) {
   this._url = url;
@@ -220,16 +265,158 @@ RemoteMedia.prototype = {
     // TODO: add position support
     this._sendMsg({ type: "PLAY" });
   },
 
   pause: function pause() {
     this._sendMsg({ type: "STOP" });
   },
 
-  load: function load(aData) {
-    this._sendMsg({ type: "LOAD", title: aData.title, source: aData.source, poster: aData.poster });
+  load: function load(data) {
+    this._sendMsg({ type: "LOAD", title: data.title, source: data.source, poster: data.poster });
   },
 
   get status() {
     return this._status;
   }
 }
+
+function RemoteMirror(url, win, viewport, mirrorStartedCallback) {
+  this._serverURI = Services.io.newURI(url , null, null);
+  this._window = win;
+  this._iceCandidates = [];
+  this.mirrorStarted = mirrorStartedCallback;
+
+  // This code insures the generated tab mirror is not wider than 800 nor taller than 600
+  // Better dimensions should be chosen after the Roku Channel is working.
+  let windowId = win.BrowserApp.selectedBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
+  let cWidth =  Math.max(viewport.cssWidth, viewport.width);
+  let cHeight = Math.max(viewport.cssHeight, viewport.height);
+
+  const MAX_WIDTH = 800;
+  const MAX_HEIGHT = 600;
+
+  let tWidth = 0;
+  let tHeight = 0;
+
+  if ((cWidth / MAX_WIDTH) > (cHeight / MAX_HEIGHT)) {
+    tHeight = Math.ceil((MAX_WIDTH / cWidth) * cHeight);
+    tWidth = MAX_WIDTH;
+  } else {
+    tWidth = Math.ceil((MAX_HEIGHT / cHeight) * cWidth);
+    tHeight = MAX_HEIGHT;
+  }
+
+  let constraints = {
+    video: {
+      mediaSource: "browser",
+      browserWindow: windowId,
+      scrollWithPage: true,
+      advanced: [
+        {
+          width: { min: tWidth, max: tWidth },
+          height: { min: tHeight, max: tHeight }
+        },
+        { aspectRatio: cWidth / cHeight }
+      ]
+    }
+  };
+
+  this._window.navigator.mozGetUserMedia(constraints, this._onReceiveGUMStream.bind(this), function() {});
+}
+
+RemoteMirror.prototype = {
+  _sendOffer: function(offer) {
+    if (!this._baseSocket) {
+      this._baseSocket = Cc["@mozilla.org/tcp-socket;1"].createInstance(Ci.nsIDOMTCPSocket);
+    }
+    this._jsonOffer = JSON.stringify(offer);
+    this._socket = this._baseSocket.open(this._serverURI.host, MIRROR_PORT, { useSecureTransport: false, binaryType: "string" });
+    this._socket.onopen = this._onSocketOpen.bind(this);
+    this._socket.ondata = this._onSocketData.bind(this);
+    this._socket.onerror = this._onSocketError.bind(this);
+  },
+
+  _onReceiveGUMStream: function(stream) {
+    this._pc = new this._window.mozRTCPeerConnection;
+    this._pc.addStream(stream);
+    this._pc.onicecandidate = (evt => {
+      // Usually the last candidate is null, expected?
+      if (!evt.candidate) {
+        return;
+      }
+      let jsonCandidate = JSON.stringify(evt.candidate);
+      this._iceCandidates.push(jsonCandidate);
+      this._sendIceCandidates();
+    });
+
+    this._pc.createOffer(offer => {
+      this._pc.setLocalDescription(
+        new this._window.mozRTCSessionDescription(offer),
+        () => this._sendOffer(offer),
+        () => log("RemoteMirror: Failed to set local description."));
+    },
+    () => log("RemoteMirror: Failed to create offer."));
+  },
+
+  _stopMirror: function() {
+    if (this._socket) {
+      this._socket.close();
+      this._socket = null;
+    }
+    if (this._pc) {
+      this._pc.close();
+      this._pc = null;
+    }
+    this._jsonOffer = null;
+    this._iceCandidates = [];
+  },
+
+  _onSocketData: function(response) {
+    if (response.type == "data") {
+      response.data.split(JSON_MESSAGE_TERMINATOR).forEach(data => {
+        if (data) {
+          let parsedData = JSON.parse(data);
+          if (parsedData.type == "answer") {
+            this._pc.setRemoteDescription(
+              new this._window.mozRTCSessionDescription(parsedData),
+              () => this.mirrorStarted(this._stopMirror.bind(this)),
+              () => log("RemoteMirror: Failed to set remote description."));
+          } else {
+            this._pc.addIceCandidate(new this._window.mozRTCIceCandidate(parsedData))
+          }
+        } else {
+          log("RemoteMirror: data is null");
+        }
+      });
+    } else if (response.type == "error") {
+      log("RemoteMirror: Got socket error.");
+      this._stopMirror();
+    } else {
+      log("RemoteMirror: Got unhandled socket event: " + response.type);
+    }
+  },
+
+  _onSocketError: function(err) {
+    log("RemoteMirror: Error socket.onerror: " + (err.data ? err.data : "NO DATA"));
+    this._stopMirror();
+  },
+
+  _onSocketOpen: function() {
+    this._open = true;
+    if (this._jsonOffer) {
+      let jsonOffer = this._jsonOffer + JSON_MESSAGE_TERMINATOR;
+      this._socket.send(jsonOffer, jsonOffer.length);
+      this._jsonOffer = null;
+      this._sendIceCandidates();
+    }
+  },
+
+  _sendIceCandidates: function() {
+    if (this._socket && this._open) {
+      this._iceCandidates.forEach(value => {
+        value = value + JSON_MESSAGE_TERMINATOR;
+        this._socket.send(value, value.length);
+      });
+      this._iceCandidates = [];
+    }
+  }
+};
--- a/mobile/android/modules/SimpleServiceDiscovery.jsm
+++ b/mobile/android/modules/SimpleServiceDiscovery.jsm
@@ -404,16 +404,20 @@ var SimpleServiceDiscovery = {
   _addService: function(service) {
     // Filter out services that do not match the device filter
     if (!this._filterService(service)) {
       return;
     }
 
     // Only add and notify if we don't already know about this service
     if (!this._services.has(service.uuid)) {
+      let device = this._devices.get(service.target);
+      if (device && device.mirror) {
+        service.mirror = true;
+      }
       this._services.set(service.uuid, service);
       Services.obs.notifyObservers(null, EVENT_SERVICE_FOUND, service.uuid);
     }
 
     // Make sure we remember this service is not stale
     this._services.get(service.uuid).lastPing = this._searchTimestamp;
   }
 }