Bug 804611 - Add a way to grant/deny getUserMedia permissions persistently, r=jesup,dolske, ui-r=Boriss.
authorFlorian Quèze <florian@queze.net>
Tue, 25 Feb 2014 12:50:42 +0100
changeset 170460 2c5c8a682efc289c7d815ef8a6c2b87a3569d605
parent 170459 4aecbfc2da19ff6edb09a38509b9fede440fc690
child 170461 f383e3426162ff766e80e7c883281aab6edf9ef4
push id26288
push userryanvm@gmail.com
push dateTue, 25 Feb 2014 20:20:43 +0000
treeherdermozilla-central@22650589a724 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjesup, dolske, Boriss
bugs804611
milestone30.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 804611 - Add a way to grant/deny getUserMedia permissions persistently, r=jesup,dolske, ui-r=Boriss.
browser/base/content/test/general/browser.ini
browser/base/content/test/general/browser_get_user_media.js
browser/base/content/test/general/get_user_media.html
browser/components/preferences/aboutPermissions.js
browser/components/preferences/aboutPermissions.xul
browser/components/preferences/tests/browser_permissions.js
browser/locales/en-US/chrome/browser/browser.properties
browser/locales/en-US/chrome/browser/preferences/aboutPermissions.dtd
browser/locales/en-US/chrome/browser/sitePermissions.properties
browser/modules/SitePermissions.jsm
browser/modules/webrtcUI.jsm
browser/themes/linux/preferences/aboutPermissions.css
browser/themes/osx/preferences/aboutPermissions.css
browser/themes/windows/preferences/aboutPermissions.css
dom/media/MediaManager.cpp
dom/media/MediaManager.h
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -52,16 +52,17 @@ support-files =
   file_bug906190.js
   file_bug906190.sjs
   file_bug970276_popup1.html
   file_bug970276_popup2.html
   file_bug970276_favicon1.ico
   file_bug970276_favicon2.ico
   file_dom_notifications.html
   file_fullscreen-window-open.html
+  get_user_media.html
   head.js
   healthreport_testRemoteCommands.html
   moz.png
   offlineQuotaNotification.cacheManifest
   offlineQuotaNotification.html
   page_style_sample.html
   plugin_add_dynamically.html
   plugin_alternate_content.html
@@ -257,16 +258,17 @@ skip-if = os == "mac" # bug 967013, bug 
 run-if = datareporting
 [browser_discovery.js]
 [browser_duplicateIDs.js]
 [browser_drag.js]
 skip-if = true # browser_drag.js is disabled, as it needs to be updated for the new behavior from bug 320638.
 [browser_findbarClose.js]
 [browser_fullscreen-window-open.js]
 [browser_gestureSupport.js]
+[browser_get_user_media.js]
 [browser_getshortcutoruri.js]
 [browser_hide_removing.js]
 [browser_homeDrop.js]
 [browser_identity_UI.js]
 [browser_keywordBookmarklets.js]
 [browser_keywordSearch.js]
 [browser_keywordSearch_postData.js]
 [browser_lastAccessedTab.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_get_user_media.js
@@ -0,0 +1,742 @@
+const kObservedTopics = [
+  "getUserMedia:response:allow",
+  "getUserMedia:revoke",
+  "getUserMedia:response:deny",
+  "getUserMedia:request",
+  "recording-device-events",
+  "recording-window-ended"
+];
+
+const PREF_PERMISSION_FAKE = "media.navigator.permission.fake";
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService",
+                                   "@mozilla.org/mediaManagerService;1",
+                                   "nsIMediaManagerService");
+
+var gObservedTopics = {};
+function observer(aSubject, aTopic, aData) {
+  if (!(aTopic in gObservedTopics))
+    gObservedTopics[aTopic] = 1;
+  else
+    ++gObservedTopics[aTopic];
+}
+
+function promiseNotification(aTopic, aAction) {
+  let deferred = Promise.defer();
+
+  Services.obs.addObserver(function observer() {
+    ok(true, "got " + aTopic + " notification");
+    Services.obs.removeObserver(observer, aTopic);
+
+    if (kObservedTopics.indexOf(aTopic) != -1) {
+      if (!(aTopic in gObservedTopics))
+        gObservedTopics[aTopic] = -1;
+      else
+        --gObservedTopics[aTopic];
+    }
+
+    deferred.resolve();
+  }, aTopic, false);
+
+  if (aAction)
+    aAction();
+
+  return deferred.promise;
+}
+
+function expectNotification(aTopic) {
+  is(gObservedTopics[aTopic], 1, "expected notification " + aTopic);
+  if (aTopic in gObservedTopics)
+    --gObservedTopics[aTopic];
+}
+
+function expectNoNotifications() {
+  for (let topic in gObservedTopics) {
+    if (gObservedTopics[topic])
+      is(gObservedTopics[topic], 0, topic + " notification unexpected");
+  }
+  gObservedTopics = {}
+}
+
+function promiseMessage(aMessage, aAction) {
+  let deferred = Promise.defer();
+
+  content.addEventListener("message", function messageListener(event) {
+    content.removeEventListener("message", messageListener);
+    is(event.data, aMessage, "received " + aMessage);
+    if (event.data == aMessage)
+      deferred.resolve();
+    else
+      deferred.reject();
+  });
+
+  if (aAction)
+    aAction();
+
+  return deferred.promise;
+}
+
+function promisePopupNotification(aName) {
+  let deferred = Promise.defer();
+
+  waitForCondition(() => PopupNotifications.getNotification(aName),
+                   () => {
+    ok(!!PopupNotifications.getNotification(aName),
+       aName + " notification appeared");
+    deferred.resolve();
+  }, "timeout waiting for popup notification " + aName);
+
+  return deferred.promise;
+}
+
+function promiseNoPopupNotification(aName) {
+  let deferred = Promise.defer();
+
+  waitForCondition(() => !PopupNotifications.getNotification(aName),
+                   () => {
+    ok(!PopupNotifications.getNotification(aName),
+       aName + " notification removed");
+    deferred.resolve();
+  }, "timeout waiting for popup notification " + aName + " to disappear");
+
+  return deferred.promise;
+}
+
+const kActionAlways = 1;
+const kActionDeny = 2;
+const kActionNever = 3;
+
+function activateSecondaryAction(aAction) {
+  let notification = PopupNotifications.panel.firstChild;
+  notification.button.focus();
+  let popup = notification.menupopup;
+  popup.addEventListener("popupshown", function () {
+    popup.removeEventListener("popupshown", arguments.callee, false);
+
+    // Press 'down' as many time as needed to select the requested action.
+    while (aAction--)
+      EventUtils.synthesizeKey("VK_DOWN", {});
+
+    // Activate
+    EventUtils.synthesizeKey("VK_RETURN", {});
+  }, false);
+
+  // One down event to open the popup
+  EventUtils.synthesizeKey("VK_DOWN",
+                           { altKey: !navigator.platform.contains("Mac") });
+}
+
+registerCleanupFunction(function() {
+  gBrowser.removeCurrentTab();
+  kObservedTopics.forEach(topic => {
+    Services.obs.removeObserver(observer, topic);
+  });
+  Services.prefs.clearUserPref(PREF_PERMISSION_FAKE);
+});
+
+function getMediaCaptureState() {
+  let hasVideo = {};
+  let hasAudio = {};
+  MediaManagerService.mediaCaptureWindowState(content, hasVideo, hasAudio);
+  if (hasVideo.value && hasAudio.value)
+    return "CameraAndMicrophone";
+  if (hasVideo.value)
+    return "Camera";
+  if (hasAudio.value)
+    return "Microphone";
+  return "none";
+}
+
+function closeStream(aAlreadyClosed) {
+  expectNoNotifications();
+
+  info("closing the stream");
+  content.wrappedJSObject.closeStream();
+
+  if (!aAlreadyClosed)
+    yield promiseNotification("recording-device-events");
+
+  yield promiseNoPopupNotification("webRTC-sharingDevices");
+  if (!aAlreadyClosed)
+    expectNotification("recording-window-ended");
+
+  let statusButton = document.getElementById("webrtc-status-button");
+  ok(statusButton.hidden, "WebRTC status button hidden");
+}
+
+function checkDeviceSelectors(aAudio, aVideo) {
+  let micSelector = document.getElementById("webRTC-selectMicrophone");
+  if (aAudio)
+    ok(!micSelector.hidden, "microphone selector visible");
+  else
+    ok(micSelector.hidden, "microphone selector hidden");
+
+  let cameraSelector = document.getElementById("webRTC-selectCamera");
+  if (aVideo)
+    ok(!cameraSelector.hidden, "camera selector visible");
+  else
+    ok(cameraSelector.hidden, "camera selector hidden");
+}
+
+function checkSharingUI() {
+  yield promisePopupNotification("webRTC-sharingDevices");
+  let statusButton = document.getElementById("webrtc-status-button");
+  ok(!statusButton.hidden, "WebRTC status button visible");
+}
+
+function checkNotSharing() {
+  is(getMediaCaptureState(), "none", "expected nothing to be shared");
+
+  ok(!PopupNotifications.getNotification("webRTC-sharingDevices"),
+     "no webRTC-sharingDevices popup notification");
+
+  let statusButton = document.getElementById("webrtc-status-button");
+  ok(statusButton.hidden, "WebRTC status button hidden");
+}
+
+let gTests = [
+
+{
+  desc: "getUserMedia audio+video",
+  run: function checkAudioVideo() {
+    yield promiseNotification("getUserMedia:request", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(true, true);
+    });
+
+    yield promisePopupNotification("webRTC-shareDevices");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectNotification("getUserMedia:response:allow");
+    expectNotification("recording-device-events");
+    is(getMediaCaptureState(), "CameraAndMicrophone",
+       "expected camera and microphone to be shared");
+
+    yield checkSharingUI();
+    yield closeStream();
+  }
+},
+
+{
+  desc: "getUserMedia audio only",
+  run: function checkAudioOnly() {
+    yield promiseNotification("getUserMedia:request", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(true);
+    });
+
+    yield promisePopupNotification("webRTC-shareDevices");
+    checkDeviceSelectors(true);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectNotification("getUserMedia:response:allow");
+    expectNotification("recording-device-events");
+    is(getMediaCaptureState(), "Microphone", "expected microphone to be shared");
+
+    yield checkSharingUI();
+    yield closeStream();
+  }
+},
+
+{
+  desc: "getUserMedia video only",
+  run: function checkVideoOnly() {
+    yield promiseNotification("getUserMedia:request", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(false, true);
+    });
+
+    yield promisePopupNotification("webRTC-shareDevices");
+    checkDeviceSelectors(false, true);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectNotification("getUserMedia:response:allow");
+    expectNotification("recording-device-events");
+    is(getMediaCaptureState(), "Camera", "expected camera to be shared");
+
+    yield checkSharingUI();
+    yield closeStream();
+  }
+},
+
+{
+  desc: "getUserMedia audio+video, user disables video",
+  run: function checkDisableVideo() {
+    yield promiseNotification("getUserMedia:request", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(true, true);
+    });
+
+    yield promisePopupNotification("webRTC-shareDevices");
+    checkDeviceSelectors(true, true);
+
+    // disable the camera
+    document.getElementById("webRTC-selectCamera-menulist").value = -1;
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    // reset the menuitem to have no impact on the following tests.
+    document.getElementById("webRTC-selectCamera-menulist").value = 0;
+
+    expectNotification("getUserMedia:response:allow");
+    expectNotification("recording-device-events");
+    is(getMediaCaptureState(), "Microphone",
+       "expected microphone to be shared");
+
+    yield checkSharingUI();
+    yield closeStream();
+  }
+},
+
+{
+  desc: "getUserMedia audio+video, user disables audio",
+  run: function checkDisableAudio() {
+    yield promiseNotification("getUserMedia:request", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(true, true);
+    });
+
+    yield promisePopupNotification("webRTC-shareDevices");
+    checkDeviceSelectors(true, true);
+
+    // disable the microphone
+    document.getElementById("webRTC-selectMicrophone-menulist").value = -1;
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    // reset the menuitem to have no impact on the following tests.
+    document.getElementById("webRTC-selectMicrophone-menulist").value = 0;
+
+    expectNotification("getUserMedia:response:allow");
+    expectNotification("recording-device-events");
+    is(getMediaCaptureState(), "Camera",
+       "expected microphone to be shared");
+
+    yield checkSharingUI();
+    yield closeStream();
+  }
+},
+
+{
+  desc: "getUserMedia audio+video, user disables both audio and video",
+  run: function checkDisableAudioVideo() {
+    yield promiseNotification("getUserMedia:request", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(true, true);
+    });
+
+    yield promisePopupNotification("webRTC-shareDevices");
+    checkDeviceSelectors(true, true);
+
+    // disable the camera and microphone
+    document.getElementById("webRTC-selectCamera-menulist").value = -1;
+    document.getElementById("webRTC-selectMicrophone-menulist").value = -1;
+
+    yield promiseMessage("error: PERMISSION_DENIED", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+
+    // reset the menuitems to have no impact on the following tests.
+    document.getElementById("webRTC-selectCamera-menulist").value = 0;
+    document.getElementById("webRTC-selectMicrophone-menulist").value = 0;
+
+    expectNotification("getUserMedia:response:deny");
+    expectNotification("recording-window-ended");
+    checkNotSharing();
+  }
+},
+
+{
+  desc: "getUserMedia audio+video, user clicks \"Don't Share\"",
+  run: function checkDontShare() {
+    yield promiseNotification("getUserMedia:request", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(true, true);
+    });
+
+    yield promisePopupNotification("webRTC-shareDevices");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage("error: PERMISSION_DENIED", () => {
+      activateSecondaryAction(kActionDeny);
+    });
+
+    expectNotification("getUserMedia:response:deny");
+    expectNotification("recording-window-ended");
+    checkNotSharing();
+  }
+},
+
+{
+  desc: "getUserMedia audio+video: stop sharing",
+  run: function checkStopSharing() {
+    yield promiseNotification("getUserMedia:request", () => {
+      info("requesting devices");
+      content.wrappedJSObject.requestDevice(true, true);
+    });
+
+    yield promisePopupNotification("webRTC-shareDevices");
+    checkDeviceSelectors(true, true);
+
+    yield promiseMessage("ok", () => {
+      PopupNotifications.panel.firstChild.button.click();
+    });
+    expectNotification("getUserMedia:response:allow");
+    expectNotification("recording-device-events");
+    is(getMediaCaptureState(), "CameraAndMicrophone",
+       "expected camera and microphone to be shared");
+
+    yield checkSharingUI();
+
+    PopupNotifications.getNotification("webRTC-sharingDevices").reshow();
+    activateSecondaryAction(kActionDeny);
+
+    yield promiseNotification("recording-device-events");
+    expectNotification("getUserMedia:revoke");
+
+    yield promiseNoPopupNotification("webRTC-sharingDevices");
+
+    if (gObservedTopics["recording-device-events"] == 1) {
+      todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719");
+      gObservedTopics["recording-device-events"] = 0;
+    }
+
+    expectNoNotifications();
+    checkNotSharing();
+
+    // the stream is already closed, but this will do some cleanup anyway
+    yield closeStream(true);
+  }
+},
+
+{
+  desc: "getUserMedia prompt: Always/Never Share",
+  run: function checkRememberCheckbox() {
+    function checkPerm(aRequestAudio, aRequestVideo, aAllowAudio, aAllowVideo,
+                       aExpectedAudioPerm, aExpectedVideoPerm, aNever) {
+      yield promiseNotification("getUserMedia:request", () => {
+        content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo);
+      });
+
+      yield promisePopupNotification("webRTC-shareDevices");
+
+      let elt = id => document.getElementById(id);
+
+      let noAudio = aAllowAudio === undefined;
+      is(elt("webRTC-selectMicrophone").hidden, noAudio,
+         "microphone selector expected to be " + (noAudio ? "hidden" : "visible"));
+      if (!noAudio)
+        elt("webRTC-selectMicrophone-menulist").value = (aAllowAudio || aNever) ? 0 : -1;
+
+      let noVideo = aAllowVideo === undefined;
+      is(elt("webRTC-selectCamera").hidden, noVideo,
+         "camera selector expected to be " + (noVideo ? "hidden" : "visible"));
+      if (!noVideo)
+        elt("webRTC-selectCamera-menulist").value = (aAllowVideo || aNever) ? 0 : -1;
+
+      let expectedMessage =
+        (aAllowVideo || aAllowAudio) ? "ok" : "error: PERMISSION_DENIED";
+      yield promiseMessage(expectedMessage, () => {
+        activateSecondaryAction(aNever ? kActionNever : kActionAlways);
+      });
+      let expected = [];
+      if (expectedMessage == "ok") {
+        expectNotification("getUserMedia:response:allow");
+        expectNotification("recording-device-events");
+        if (aAllowVideo)
+          expected.push("Camera");
+        if (aAllowAudio)
+          expected.push("Microphone");
+        expected = expected.join("And");
+      }
+      else {
+        expectNotification("getUserMedia:response:deny");
+        expectNotification("recording-window-ended");
+        expected = "none";
+      }
+      is(getMediaCaptureState(), expected,
+         "expected " + expected + " to be shared");
+
+      function checkDevicePermissions(aDevice, aExpected) {
+        let Perms = Services.perms;
+        let uri = content.document.documentURIObject;
+        let devicePerms = Perms.testExactPermission(uri, aDevice);
+        if (aExpected === undefined)
+          is(devicePerms, Perms.UNKNOWN_ACTION, "no " + aDevice + " persistent permissions");
+        else {
+          is(devicePerms, aExpected ? Perms.ALLOW_ACTION : Perms.DENY_ACTION,
+             aDevice + " persistently " + (aExpected ? "allowed" : "denied"));
+        }
+        Perms.remove(uri.host, aDevice);
+      }
+      checkDevicePermissions("microphone", aExpectedAudioPerm);
+      checkDevicePermissions("camera", aExpectedVideoPerm);
+
+      if (expectedMessage == "ok")
+        yield closeStream();
+    }
+
+    // 3 cases where the user accepts the device prompt.
+    info("audio+video, user grants, expect both perms set to allow");
+    yield checkPerm(true, true, true, true, true, true);
+    info("audio only, user grants, check audio perm set to allow, video perm not set");
+    yield checkPerm(true, false, true, undefined, true, undefined);
+    info("video only, user grants, check video perm set to allow, audio perm not set");
+    yield checkPerm(false, true, undefined, true, undefined, true);
+
+    // 3 cases where the user rejects the device request.
+    // First test these cases by setting the device to 'No Audio'/'No Video'
+    info("audio+video, user denies, expect both perms set to deny");
+    yield checkPerm(true, true, false, false, false, false);
+    info("audio only, user denies, expect audio perm set to deny, video not set");
+    yield checkPerm(true, false, false, undefined, false, undefined);
+    info("video only, user denies, expect video perm set to deny, audio perm not set");
+    yield checkPerm(false, true, undefined, false, undefined, false);
+    // Now test these 3 cases again by using the 'Never Share' action.
+    info("audio+video, user denies, expect both perms set to deny");
+    yield checkPerm(true, true, false, false, false, false, true);
+    info("audio only, user denies, expect audio perm set to deny, video not set");
+    yield checkPerm(true, false, false, undefined, false, undefined, true);
+    info("video only, user denies, expect video perm set to deny, audio perm not set");
+    yield checkPerm(false, true, undefined, false, undefined, false, true);
+
+    // 2 cases where the user allows half of what's requested.
+    info("audio+video, user denies video, grants audio, " +
+         "expect video perm set to deny, audio perm set to allow.");
+    yield checkPerm(true, true, true, false, true, false);
+    info("audio+video, user denies audio, grants video, " +
+         "expect video perm set to allow, audio perm set to deny.");
+    yield checkPerm(true, true, false, true, false, true);
+  }
+},
+
+{
+  desc: "getUserMedia without prompt: use persistent permissions",
+  run: function checkUsePersistentPermissions() {
+    function usePerm(aAllowAudio, aAllowVideo, aRequestAudio, aRequestVideo,
+                     aExpectStream) {
+      let Perms = Services.perms;
+      let uri = content.document.documentURIObject;
+      if (aAllowAudio !== undefined) {
+        Perms.add(uri, "microphone", aAllowAudio ? Perms.ALLOW_ACTION
+                                                 : Perms.DENY_ACTION);
+      }
+      if (aAllowVideo !== undefined) {
+        Perms.add(uri, "camera", aAllowVideo ? Perms.ALLOW_ACTION
+                                             : Perms.DENY_ACTION);
+      }
+
+      let gum = function() {
+        content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo);
+      };
+
+      if (aExpectStream === undefined) {
+        // Check that we get a prompt.
+        yield promiseNotification("getUserMedia:request", gum);
+        yield promisePopupNotification("webRTC-shareDevices");
+
+        // Deny the request to cleanup...
+        yield promiseMessage("error: PERMISSION_DENIED", () => {
+          activateSecondaryAction(kActionDeny);
+        });
+        expectNotification("getUserMedia:response:deny");
+        expectNotification("recording-window-ended");
+      }
+      else {
+        let allow = (aAllowVideo && aRequestVideo) || (aAllowAudio && aRequestAudio);
+        let expectedMessage = allow ? "ok" : "error: PERMISSION_DENIED";
+        yield promiseMessage(expectedMessage, gum);
+
+        if (expectedMessage == "ok") {
+          expectNotification("recording-device-events");
+
+          // Check what's actually shared.
+          let expected = [];
+          if (aAllowVideo && aRequestVideo)
+            expected.push("Camera");
+          if (aAllowAudio && aRequestAudio)
+            expected.push("Microphone");
+          expected = expected.join("And");
+          is(getMediaCaptureState(), expected,
+             "expected " + expected + " to be shared");
+
+          yield closeStream();
+        }
+        else {
+          expectNotification("recording-window-ended");
+        }
+      }
+
+      Perms.remove(uri.host, "camera");
+      Perms.remove(uri.host, "microphone");
+    }
+
+    // Set both permissions identically
+    info("allow audio+video, request audio+video, expect ok (audio+video)");
+    yield usePerm(true, true, true, true, true);
+    info("deny audio+video, request audio+video, expect denied");
+    yield usePerm(false, false, true, true, false);
+
+    // Allow audio, deny video.
+    info("allow audio, deny video, request audio+video, expect ok (audio)");
+    yield usePerm(true, false, true, true, true);
+    info("allow audio, deny video, request audio, expect ok (audio)");
+    yield usePerm(true, false, true, false, true);
+    info("allow audio, deny video, request video, expect denied");
+    yield usePerm(true, false, false, true, false);
+
+    // Deny audio, allow video.
+    info("deny audio, allow video, request audio+video, expect ok (video)");
+    yield usePerm(false, true, true, true, true);
+    info("deny audio, allow video, request audio, expect denied");
+    yield usePerm(false, true, true, false, true);
+    info("deny audio, allow video, request video, expect ok (video)");
+    yield usePerm(false, true, false, true, false);
+
+    // Allow audio, video not set.
+    info("allow audio, request audio+video, expect prompt");
+    yield usePerm(true, undefined, true, true, undefined);
+    info("allow audio, request audio, expect ok (audio)");
+    yield usePerm(true, undefined, true, false, true);
+    info("allow audio, request video, expect prompt");
+    yield usePerm(true, undefined, false, true, undefined);
+
+    // Deny audio, video not set.
+    info("deny audio, request audio+video, expect prompt");
+    yield usePerm(false, undefined, true, true, undefined);
+    info("deny audio, request audio, expect denied");
+    yield usePerm(false, undefined, true, false, false);
+    info("deny audio, request video, expect prompt");
+    yield usePerm(false, undefined, false, true, undefined);
+
+    // Allow video, video not set.
+    info("allow video, request audio+video, expect prompt");
+    yield usePerm(undefined, true, true, true, undefined);
+    info("allow video, request audio, expect prompt");
+    yield usePerm(undefined, true, true, false, undefined);
+    info("allow video, request video, expect ok (video)");
+    yield usePerm(undefined, true, false, true, true);
+
+    // Deny video, video not set.
+    info("deny video, request audio+video, expect prompt");
+    yield usePerm(undefined, false, true, true, undefined);
+    info("deny video, request audio, expect prompt");
+    yield usePerm(undefined, false, true, false, undefined);
+    info("deny video, request video, expect denied");
+    yield usePerm(undefined, false, false, true, false);
+  }
+},
+
+{
+  desc: "Stop Sharing removes persistent permissions",
+  run: function checkStopSharingRemovesPersistentPermissions() {
+    function stopAndCheckPerm(aRequestAudio, aRequestVideo) {
+      let Perms = Services.perms;
+      let uri = content.document.documentURIObject;
+
+      // Initially set both permissions to 'allow'.
+      Perms.add(uri, "microphone", Perms.ALLOW_ACTION);
+      Perms.add(uri, "camera", Perms.ALLOW_ACTION);
+
+      // Start sharing what's been requested.
+      yield promiseMessage("ok", () => {
+        content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo);
+      });
+      expectNotification("recording-device-events");
+      yield checkSharingUI();
+
+      // Stop sharing.
+      PopupNotifications.getNotification("webRTC-sharingDevices").reshow();
+      activateSecondaryAction(kActionDeny);
+
+      yield promiseNotification("recording-device-events");
+      expectNotification("getUserMedia:revoke");
+
+      yield promiseNoPopupNotification("webRTC-sharingDevices");
+
+      if (gObservedTopics["recording-device-events"] == 1) {
+        todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719");
+        gObservedTopics["recording-device-events"] = 0;
+      }
+
+      // Check that permissions have been removed as expected.
+      let audioPerm = Perms.testExactPermission(uri, "microphone");
+      if (aRequestAudio)
+        is(audioPerm, Perms.UNKNOWN_ACTION, "microphone permissions removed");
+      else
+        is(audioPerm, Perms.ALLOW_ACTION, "microphone permissions untouched");
+
+      let videoPerm = Perms.testExactPermission(uri, "camera");
+      if (aRequestVideo)
+        is(videoPerm, Perms.UNKNOWN_ACTION, "camera permissions removed");
+      else
+        is(videoPerm, Perms.ALLOW_ACTION, "camera permissions untouched");
+
+      // Cleanup.
+      yield closeStream(true);
+
+      Perms.remove(uri.host, "camera");
+      Perms.remove(uri.host, "microphone");
+    }
+
+    info("request audio+video, stop sharing resets both");
+    yield stopAndCheckPerm(true, true);
+    info("request audio, stop sharing resets audio only");
+    yield stopAndCheckPerm(true, false);
+    info("request video, stop sharing resets video only");
+    yield stopAndCheckPerm(false, true);
+  }
+}
+
+];
+
+function test() {
+  waitForExplicitFinish();
+
+  let tab = gBrowser.addTab();
+  gBrowser.selectedTab = tab;
+  tab.linkedBrowser.addEventListener("load", function onload() {
+    tab.linkedBrowser.removeEventListener("load", onload, true);
+
+    kObservedTopics.forEach(topic => {
+      Services.obs.addObserver(observer, topic, false);
+    });
+    Services.prefs.setBoolPref(PREF_PERMISSION_FAKE, true);
+
+    Task.spawn(function () {
+      for (let test of gTests) {
+        info(test.desc);
+        yield test.run();
+
+        // Cleanup before the next test
+        expectNoNotifications();
+      }
+    }).then(finish, ex => {
+     ok(false, "Unexpected Exception: " + ex);
+     finish();
+    });
+  }, true);
+  let rootDir = getRootDirectory(gTestPath)
+  rootDir = rootDir.replace("chrome://mochitests/content/",
+                            "http://127.0.0.1:8888/");
+  content.location = rootDir + "get_user_media.html";
+}
+
+
+function wait(time) {
+  let deferred = Promise.defer();
+  setTimeout(deferred.resolve, time);
+  return deferred.promise;
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/get_user_media.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8"></head>
+<body>
+<div id="message"></div>
+<script>
+function message(m) {
+  document.getElementById("message").innerHTML = m;
+  window.parent.postMessage(m, "*");
+}
+
+var gStream;
+
+function requestDevice(aAudio, aVideo) {
+  window.navigator.mozGetUserMedia({video: aVideo, audio: aAudio, fake: true},
+                                   function(stream) {
+    gStream = stream;
+    message("ok");
+  }, function(err) { message("error: " + err); });
+}
+message("pending");
+
+function closeStream() {
+  if (!gStream)
+    return;
+  gStream.stop();
+  gStream = null;
+  message("closed");
+}
+</script>
+</body>
+</html>
--- a/browser/components/preferences/aboutPermissions.js
+++ b/browser/components/preferences/aboutPermissions.js
@@ -33,17 +33,17 @@ let gVisitStmt = gPlacesDatabase.createA
                   "SELECT SUM(visit_count) AS count " +
                   "FROM moz_places " +
                   "WHERE rev_host = :rev_host");
 
 /**
  * Permission types that should be tested with testExactPermission, as opposed
  * to testPermission. This is based on what consumers use to test these permissions.
  */
-let TEST_EXACT_PERM_TYPES = ["geo"];
+let TEST_EXACT_PERM_TYPES = ["geo", "camera", "microphone"];
 
 /**
  * Site object represents a single site, uniquely identified by a host.
  */
 function Site(host) {
   this.host = host;
   this.listitem = null;
 
@@ -325,26 +325,29 @@ let PermissionDefaults = {
     if (!Services.prefs.getBoolPref("full-screen-api.enabled")) {
       return this.DENY;
     }
     return this.UNKNOWN;
   },
   set fullscreen(aValue) {
     let value = (aValue != this.DENY);
     Services.prefs.setBoolPref("full-screen-api.enabled", value);
-  }
-}
+  },
+
+  get camera() this.UNKNOWN,
+  get microphone() this.UNKNOWN
+};
 
 /**
  * AboutPermissions manages the about:permissions page.
  */
 let AboutPermissions = {
   /**
    * Number of sites to return from the places database.
-   */  
+   */
   PLACES_SITES_LIMIT: 50,
 
   /**
    * When adding sites to the dom sites-list, divide workload into intervals.
    */
   LIST_BUILD_CHUNK: 5, // interval size
   LIST_BUILD_DELAY: 100, // delay between intervals
 
@@ -364,27 +367,28 @@ let AboutPermissions = {
 
   /**
    * This reflects the permissions that we expose in the UI. These correspond
    * to permission type strings in the permission manager, PermissionDefaults,
    * and element ids in aboutPermissions.xul.
    *
    * Potential future additions: "sts/use", "sts/subd"
    */
-  _supportedPermissions: ["password", "cookie", "geo", "indexedDB", "popup", "fullscreen"],
+  _supportedPermissions: ["password", "cookie", "geo", "indexedDB", "popup",
+                          "fullscreen", "camera", "microphone"],
 
   /**
    * Permissions that don't have a global "Allow" option.
    */
-  _noGlobalAllow: ["geo", "indexedDB", "fullscreen"],
+  _noGlobalAllow: ["geo", "indexedDB", "fullscreen", "camera", "microphone"],
 
   /**
    * Permissions that don't have a global "Deny" option.
    */
-  _noGlobalDeny: [],
+  _noGlobalDeny: ["camera", "microphone"],
 
   _stringBundle: Services.strings.
                  createBundle("chrome://browser/locale/preferences/aboutPermissions.properties"),
 
   /**
    * Called on page load.
    */
   init: function() {
@@ -402,17 +406,17 @@ let AboutPermissions = {
     Services.prefs.addObserver("dom.indexedDB.enabled", this, false);
     Services.prefs.addObserver("dom.disable_open_during_load", this, false);
     Services.prefs.addObserver("full-screen-api.enabled", this, false);
 
     Services.obs.addObserver(this, "perm-changed", false);
     Services.obs.addObserver(this, "passwordmgr-storage-changed", false);
     Services.obs.addObserver(this, "cookie-changed", false);
     Services.obs.addObserver(this, "browser:purge-domain-data", false);
-    
+
     this._observersInitialized = true;
     Services.obs.notifyObservers(null, "browser-permissions-preinit", null);
   },
 
   /**
    * Called on page unload.
    */
   cleanUp: function() {
@@ -537,32 +541,32 @@ let AboutPermissions = {
       if (itemCnt % this.LIST_BUILD_CHUNK == 0) {
         yield true;
       }
       try {
         // aLogin.hostname is a string in origin URL format (e.g. "http://foo.com")
         let uri = NetUtil.newURI(aLogin.hostname);
         this.addHost(uri.host);
       } catch (e) {
-        // newURI will throw for add-ons logins stored in chrome:// URIs 
+        // newURI will throw for add-ons logins stored in chrome:// URIs
       }
       itemCnt++;
     }, this);
 
     let disabledHosts = Services.logins.getAllDisabledHosts();
     disabledHosts.forEach(function(aHostname) {
       if (itemCnt % this.LIST_BUILD_CHUNK == 0) {
         yield true;
       }
       try {
         // aHostname is a string in origin URL format (e.g. "http://foo.com")
         let uri = NetUtil.newURI(aHostname);
         this.addHost(uri.host);
       } catch (e) {
-        // newURI will throw for add-ons logins stored in chrome:// URIs 
+        // newURI will throw for add-ons logins stored in chrome:// URIs
       }
       itemCnt++;
     }, this);
 
     let (enumerator = Services.perms.enumerator) {
       while (enumerator.hasMoreElements()) {
         if (itemCnt % this.LIST_BUILD_CHUNK == 0) {
           yield true;
@@ -773,17 +777,17 @@ let AboutPermissions = {
   },
 
   updateVisitCount: function() {
     this._selectedSite.getVisitCount(function(aCount) {
       let visitForm = AboutPermissions._stringBundle.GetStringFromName("visitCount");
       let visitLabel = PluralForm.get(aCount, visitForm)
                                   .replace("#1", aCount);
       document.getElementById("site-visit-count").value = visitLabel;
-    });  
+    });
   },
 
   updatePasswordsCount: function() {
     if (!this._selectedSite) {
       document.getElementById("passwords-count").hidden = true;
       document.getElementById("passwords-manage-all-button").hidden = false;
       return;
     }
--- a/browser/components/preferences/aboutPermissions.xul
+++ b/browser/components/preferences/aboutPermissions.xul
@@ -108,16 +108,58 @@
                 <menuitem id="geo-1" value="1" label="&permission.allow;"/>
                 <menuitem id="geo-2" value="2" label="&permission.block;"/>
               </menupopup>
             </menulist>
           </hbox>
         </vbox>
       </hbox>
 
+      <!-- Camera -->
+      <hbox id="camera-pref-item"
+            class="pref-item" align="top">
+        <image class="pref-icon" type="camera"/>
+        <vbox>
+          <label class="pref-title" value="&camera.label;"/>
+          <hbox align="center">
+            <menulist id="camera-menulist"
+                      class="pref-menulist"
+                      type="camera"
+                      oncommand="AboutPermissions.onPermissionCommand(event);">
+              <menupopup>
+                <menuitem id="camera-0" value="0" label="&permission.alwaysAsk;"/>
+                <menuitem id="camera-1" value="1" label="&permission.allow;"/>
+                <menuitem id="camera-2" value="2" label="&permission.block;"/>
+              </menupopup>
+            </menulist>
+          </hbox>
+        </vbox>
+      </hbox>
+
+      <!-- Microphone -->
+      <hbox id="microphone-pref-item"
+            class="pref-item" align="top">
+        <image class="pref-icon" type="microphone"/>
+        <vbox>
+          <label class="pref-title" value="&microphone.label;"/>
+          <hbox align="center">
+            <menulist id="microphone-menulist"
+                      class="pref-menulist"
+                      type="microphone"
+                      oncommand="AboutPermissions.onPermissionCommand(event);">
+              <menupopup>
+                <menuitem id="microphone-0" value="0" label="&permission.alwaysAsk;"/>
+                <menuitem id="microphone-1" value="1" label="&permission.allow;"/>
+                <menuitem id="microphone-2" value="2" label="&permission.block;"/>
+              </menupopup>
+            </menulist>
+          </hbox>
+        </vbox>
+      </hbox>
+
       <!-- Cookies -->
       <hbox id="cookie-pref-item"
             class="pref-item" align="top">
         <image class="pref-icon" type="cookie"/>
         <vbox>
           <label class="pref-title" value="&cookie.label;"/>
           <hbox align="center">
             <menulist id="cookie-menulist"
--- a/browser/components/preferences/tests/browser_permissions.js
+++ b/browser/components/preferences/tests/browser_permissions.js
@@ -22,26 +22,28 @@ const PERM_FIRST_PARTY_ONLY = 9;
 // used to set permissions on test sites
 const TEST_PERMS = {
   "password": PERM_ALLOW,
   "cookie": PERM_ALLOW,
   "geo": PERM_UNKNOWN,
   "indexedDB": PERM_UNKNOWN,
   "popup": PERM_DENY,
   "fullscreen" : PERM_UNKNOWN,
+  "camera": PERM_UNKNOWN,
+  "microphone": PERM_UNKNOWN
 };
 
 const NO_GLOBAL_ALLOW = [
   "geo",
   "indexedDB",
   "fullscreen"
 ];
 
 // number of managed permissions in the interface
-const TEST_PERMS_COUNT = 6;
+const TEST_PERMS_COUNT = 8;
 
 function test() {
   waitForExplicitFinish();
   registerCleanupFunction(cleanUp);
 
   // add test history visit
   addVisits(TEST_URI_1, function() {
     // set permissions ourselves to avoid problems with different defaults
@@ -159,17 +161,17 @@ var tests = [
     });
 
     runNextTest();
   },
 
   function test_all_sites_permission() {
     // apply the old default of allowing all cookies
     Services.prefs.setIntPref("network.cookie.cookieBehavior", 0);
-  
+
     // there should be no user-set pref for cookie behavior
     is(Services.prefs.getIntPref("network.cookie.cookieBehavior"), PERM_UNKNOWN,
        "network.cookie.cookieBehavior is expected default");
 
     // the default behavior is to allow cookies
     let cookieMenulist = getPermissionMenulist("cookie");
     is(cookieMenulist.value, PERM_ALLOW,
        "menulist correctly shows that cookies are allowed");
@@ -184,22 +186,22 @@ var tests = [
 
     runNextTest();
   },
 
   function test_manage_all_passwords() {
     // make sure "Manage All Passwords..." button opens the correct dialog
     addWindowListener("chrome://passwordmgr/content/passwordManager.xul", runNextTest);
     gBrowser.contentDocument.getElementById("passwords-manage-all-button").doCommand();
-    
+
   },
 
   function test_manage_all_cookies() {
     // make sure "Manage All Cookies..." button opens the correct dialog
-    addWindowListener("chrome://browser/content/preferences/cookies.xul", runNextTest);    
+    addWindowListener("chrome://browser/content/preferences/cookies.xul", runNextTest);
     gBrowser.contentDocument.getElementById("cookies-manage-all-button").doCommand();
   },
 
   function test_select_site() {
     // select the site that has the permissions we set at the beginning of the test
     let testSiteItem = getSiteItem(TEST_URI_2.host);
     gSitesList.selectedItem = testSiteItem;
 
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -492,18 +492,22 @@ identity.loggedIn.signOut.accessKey = O
 # The number of devices can be either one or two.
 getUserMedia.shareCamera.message = Would you like to share your camera with %S?
 getUserMedia.shareMicrophone.message = Would you like to share your microphone with %S?
 getUserMedia.shareCameraAndMicrophone.message = Would you like to share your camera and microphone with %S?
 getUserMedia.noVideo.label = No Video
 getUserMedia.noAudio.label = No Audio
 getUserMedia.shareSelectedDevices.label = Share Selected Device;Share Selected Devices
 getUserMedia.shareSelectedDevices.accesskey = S
+getUserMedia.always.label = Always Share
+getUserMedia.always.accesskey = A
 getUserMedia.denyRequest.label = Don't Share
 getUserMedia.denyRequest.accesskey = D
+getUserMedia.never.label = Never Share
+getUserMedia.never.accesskey = N
 getUserMedia.sharingCamera.message2 = You are currently sharing your camera with this page.
 getUserMedia.sharingMicrophone.message2 = You are currently sharing your microphone with this page.
 getUserMedia.sharingCameraAndMicrophone.message2 = You are currently sharing your camera and microphone with this page.
 getUserMedia.continueSharing.label = Continue Sharing
 getUserMedia.continueSharing.accesskey = C
 getUserMedia.stopSharing.label = Stop Sharing
 getUserMedia.stopSharing.accesskey = S
 
--- a/browser/locales/en-US/chrome/browser/preferences/aboutPermissions.dtd
+++ b/browser/locales/en-US/chrome/browser/preferences/aboutPermissions.dtd
@@ -37,8 +37,10 @@
 
 <!-- LOCALIZATION NOTE (indexedDB.label): This is describing indexedDB storage
      using the same language used for the permIndexedDB string in browser/pageInfo.dtd -->
 <!ENTITY indexedDB.label                 "Maintain Offline Storage">
 
 <!ENTITY popup.label                     "Open Pop-up Windows">
 
 <!ENTITY fullscreen.label                "Fullscreen">
+<!ENTITY camera.label                    "Use the Camera">
+<!ENTITY microphone.label                "Use the Microphone">
--- a/browser/locales/en-US/chrome/browser/sitePermissions.properties
+++ b/browser/locales/en-US/chrome/browser/sitePermissions.properties
@@ -5,15 +5,17 @@
 allow = Allow
 allowForSession = Allow for Session
 block = Block
 alwaysAsk = Always Ask
 
 permission.cookie.label = Set Cookies
 permission.desktop-notification.label = Show Notifications
 permission.image.label = Load Images
+permission.camera.label = Use the Camera
+permission.microphone.label = Use the Microphone
 permission.install.label = Install Add-ons
 permission.popup.label = Open Pop-up Windows
 permission.geo.label = Access Your Location
 permission.indexedDB.label = Maintain Offline Storage
 permission.fullscreen.label = Enter Fullscreen
 permission.pointerLock.label = Hide the Mouse Pointer
 
--- a/browser/modules/SitePermissions.jsm
+++ b/browser/modules/SitePermissions.jsm
@@ -181,16 +181,19 @@ let gPermissionObject = {
         return SitePermissions.SESSION;
 
       return SitePermissions.ALLOW;
     }
   },
 
   "desktop-notification": {},
 
+  "camera": {},
+  "microphone": {},
+
   "popup": {
     getDefault: function () {
       return Services.prefs.getBoolPref("dom.disable_open_during_load") ?
                SitePermissions.BLOCK : SitePermissions.ALLOW;
     }
   },
 
   "install": {
--- a/browser/modules/webrtcUI.jsm
+++ b/browser/modules/webrtcUI.jsm
@@ -115,41 +115,62 @@ function prompt(aContentWindow, aCallID,
     requestType = "Microphone";
   else if (videoDevices.length)
     requestType = "Camera";
   else {
     denyRequest(aCallID, "NO_DEVICES_FOUND");
     return;
   }
 
-  let host = aContentWindow.document.documentURIObject.host;
+  let uri = aContentWindow.document.documentURIObject;
   let browser = getBrowserForWindow(aContentWindow);
   let chromeDoc = browser.ownerDocument;
   let chromeWin = chromeDoc.defaultView;
   let stringBundle = chromeWin.gNavigatorBundle;
   let message = stringBundle.getFormattedString("getUserMedia.share" + requestType + ".message",
-                                                [ host ]);
+                                                [ uri.host ]);
 
   let mainAction = {
     label: PluralForm.get(requestType == "CameraAndMicrophone" ? 2 : 1,
                           stringBundle.getString("getUserMedia.shareSelectedDevices.label")),
     accessKey: stringBundle.getString("getUserMedia.shareSelectedDevices.accesskey"),
     // The real callback will be set during the "showing" event. The
     // empty function here is so that PopupNotifications.show doesn't
     // reject the action.
     callback: function() {}
   };
 
-  let secondaryActions = [{
-    label: stringBundle.getString("getUserMedia.denyRequest.label"),
-    accessKey: stringBundle.getString("getUserMedia.denyRequest.accesskey"),
-    callback: function () {
-      denyRequest(aCallID);
+  let secondaryActions = [
+    {
+      label: stringBundle.getString("getUserMedia.always.label"),
+      accessKey: stringBundle.getString("getUserMedia.always.accesskey"),
+      callback: function () {
+        mainAction.callback(true);
+      }
+    },
+    {
+      label: stringBundle.getString("getUserMedia.denyRequest.label"),
+      accessKey: stringBundle.getString("getUserMedia.denyRequest.accesskey"),
+      callback: function () {
+        denyRequest(aCallID);
+      }
+    },
+    {
+      label: stringBundle.getString("getUserMedia.never.label"),
+      accessKey: stringBundle.getString("getUserMedia.never.accesskey"),
+      callback: function () {
+        denyRequest(aCallID);
+        let perms = Services.perms;
+        if (audioDevices.length)
+          perms.add(uri, "microphone", perms.DENY_ACTION);
+        if (videoDevices.length)
+          perms.add(uri, "camera", perms.DENY_ACTION);
+      }
     }
-  }];
+  ];
 
   let options = {
     eventCallback: function(aTopic, aNewBrowser) {
       if (aTopic == "swapping")
         return true;
 
       if (aTopic != "showing")
         return false;
@@ -183,28 +204,39 @@ function prompt(aContentWindow, aCallID,
       listDevices(camMenupopup, videoDevices);
       listDevices(micMenupopup, audioDevices);
       if (requestType == "CameraAndMicrophone") {
         let stringBundle = chromeDoc.defaultView.gNavigatorBundle;
         addDeviceToList(camMenupopup, stringBundle.getString("getUserMedia.noVideo.label"), "-1");
         addDeviceToList(micMenupopup, stringBundle.getString("getUserMedia.noAudio.label"), "-1");
       }
 
-      this.mainAction.callback = function() {
+      this.mainAction.callback = function(aRemember) {
         let allowedDevices = Cc["@mozilla.org/supports-array;1"]
                                .createInstance(Ci.nsISupportsArray);
+        let perms = Services.perms;
         if (videoDevices.length) {
           let videoDeviceIndex = chromeDoc.getElementById("webRTC-selectCamera-menulist").value;
-          if (videoDeviceIndex != "-1")
+          let allowCamera = videoDeviceIndex != "-1";
+          if (allowCamera)
             allowedDevices.AppendElement(videoDevices[videoDeviceIndex]);
+          if (aRemember) {
+            perms.add(uri, "camera",
+                      allowCamera ? perms.ALLOW_ACTION : perms.DENY_ACTION);
+          }
         }
         if (audioDevices.length) {
           let audioDeviceIndex = chromeDoc.getElementById("webRTC-selectMicrophone-menulist").value;
-          if (audioDeviceIndex != "-1")
+          let allowMic = audioDeviceIndex != "-1";
+          if (allowMic)
             allowedDevices.AppendElement(audioDevices[audioDeviceIndex]);
+          if (aRemember) {
+            perms.add(uri, "microphone",
+                      allowMic ? perms.ALLOW_ACTION : perms.DENY_ACTION);
+          }
         }
 
         if (allowedDevices.Count() == 0) {
           denyRequest(aCallID);
           return;
         }
 
         Services.obs.notifyObservers(allowedDevices, "getUserMedia:response:allow", aCallID);
@@ -247,30 +279,39 @@ function showBrowserSpecificIndicator(aB
     return;
   }
 
   let chromeWin = aBrowser.ownerDocument.defaultView;
   let stringBundle = chromeWin.gNavigatorBundle;
 
   let message = stringBundle.getString("getUserMedia.sharing" + captureState + ".message2");
 
+  let uri = aBrowser.contentWindow.document.documentURIObject;
   let windowId = aBrowser.contentWindow
                          .QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIDOMWindowUtils)
                          .currentInnerWindowID;
   let mainAction = {
     label: stringBundle.getString("getUserMedia.continueSharing.label"),
     accessKey: stringBundle.getString("getUserMedia.continueSharing.accesskey"),
     callback: function () {},
     dismiss: true
   };
   let secondaryActions = [{
     label: stringBundle.getString("getUserMedia.stopSharing.label"),
     accessKey: stringBundle.getString("getUserMedia.stopSharing.accesskey"),
     callback: function () {
+      let perms = Services.perms;
+      if (hasVideo.value &&
+          perms.testExactPermission(uri, "camera") == perms.ALLOW_ACTION)
+        perms.remove(uri.host, "camera");
+      if (hasAudio.value &&
+          perms.testExactPermission(uri, "microphone") == perms.ALLOW_ACTION)
+        perms.remove(uri.host, "microphone");
+
       Services.obs.notifyObservers(null, "getUserMedia:revoke", windowId);
     }
   }];
   let options = {
     hideNotNow: true,
     dismissed: true,
     eventCallback: function(aTopic) aTopic == "swapping"
   };
--- a/browser/themes/linux/preferences/aboutPermissions.css
+++ b/browser/themes/linux/preferences/aboutPermissions.css
@@ -92,16 +92,22 @@
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 .pref-icon[type="plugins"] {
   list-style-image: url(chrome://mozapps/skin/plugins/pluginGeneric.png);
 }
 .pref-icon[type="fullscreen"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
+.pref-icon[type="camera"] {
+  list-style-image: url(chrome://global/skin/icons/question-64.png);
+}
+.pref-icon[type="microphone"] {
+  list-style-image: url(chrome://global/skin/icons/question-64.png);
+}
 
 .pref-title {
   font-size: 125%;
   margin-bottom: 0;
   font-weight: bold;
 }
 
 .pref-menulist {
--- a/browser/themes/osx/preferences/aboutPermissions.css
+++ b/browser/themes/osx/preferences/aboutPermissions.css
@@ -102,16 +102,22 @@
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 .pref-icon[type="plugins"] {
   list-style-image: url(chrome://mozapps/skin/plugins/pluginGeneric.png);
 }
 .pref-icon[type="fullscreen"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
+.pref-icon[type="camera"] {
+  list-style-image: url(chrome://global/skin/icons/question-64.png);
+}
+.pref-icon[type="microphone"] {
+  list-style-image: url(chrome://global/skin/icons/question-64.png);
+}
 
 @media (min-resolution: 2dppx) {
   .pref-icon[type="geo"] {
     list-style-image: url(chrome://browser/skin/Geolocation-64@2x.png);
   }
 }
 
 .pref-title {
--- a/browser/themes/windows/preferences/aboutPermissions.css
+++ b/browser/themes/windows/preferences/aboutPermissions.css
@@ -95,16 +95,22 @@
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 .pref-icon[type="plugins"] {
   list-style-image: url(chrome://mozapps/skin/plugins/pluginGeneric.png);
 }
 .pref-icon[type="fullscreen"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
+.pref-icon[type="camera"] {
+  list-style-image: url(chrome://global/skin/icons/question-64.png);
+}
+.pref-icon[type="microphone"] {
+  list-style-image: url(chrome://global/skin/icons/question-64.png);
+}
 
 .pref-title {
   font-size: 125%;
   margin-bottom: 0;
   font-weight: bold;
 }
 
 .pref-menulist {
--- a/dom/media/MediaManager.cpp
+++ b/dom/media/MediaManager.cpp
@@ -9,16 +9,17 @@
 #include "nsHashPropertyBag.h"
 #ifdef MOZ_WIDGET_GONK
 #include "nsIAudioManager.h"
 #endif
 #include "nsIDOMFile.h"
 #include "nsIEventTarget.h"
 #include "nsIUUIDGenerator.h"
 #include "nsIScriptGlobalObject.h"
+#include "nsIPermissionManager.h"
 #include "nsIPopupWindowManager.h"
 #include "nsISupportsArray.h"
 #include "nsIDocShell.h"
 #include "nsIDocument.h"
 #include "nsISupportsPrimitives.h"
 #include "nsIInterfaceRequestorUtils.h"
 #include "mozilla/dom/ContentChild.h"
 #include "mozilla/dom/MediaStreamTrackBinding.h"
@@ -28,18 +29,16 @@
 
 // For PR_snprintf
 #include "prprf.h"
 
 #include "nsJSUtils.h"
 #include "nsDOMFile.h"
 #include "nsGlobalWindow.h"
 
-#include "mozilla/Preferences.h"
-
 /* Using WebRTC backend on Desktops (Mac, Windows, Linux), otherwise default */
 #include "MediaEngineDefault.h"
 #if defined(MOZ_WEBRTC)
 #include "MediaEngineWebRTC.h"
 #endif
 
 #ifdef MOZ_B2G
 #include "MediaPermissionGonk.h"
@@ -903,16 +902,23 @@ public:
       // MUST happen after ErrorCallbackRunnable Run()s, as it checks the active window list
       NS_DispatchToMainThread(new GetUserMediaListenerRemove(mWindowID, mListener));
     }
 
     return NS_OK;
   }
 
   nsresult
+  SetContraints(const MediaStreamConstraintsInternal& aConstraints)
+  {
+    mConstraints = aConstraints;
+    return NS_OK;
+  }
+
+  nsresult
   SetAudioDevice(MediaDevice* aAudioDevice)
   {
     mAudioDevice = aAudioDevice;
     mDeviceChosen = true;
     return NS_OK;
   }
 
   nsresult
@@ -1070,17 +1076,21 @@ public:
     , mLoopbackAudioDevice(aAudioLoopbackDev)
     , mLoopbackVideoDevice(aVideoLoopbackDev) {}
 
   NS_IMETHOD
   Run()
   {
     NS_ASSERTION(!NS_IsMainThread(), "Don't call on main thread");
 
-    MediaEngine *backend = mManager->GetBackend(mWindowId);
+    nsRefPtr<MediaEngine> backend;
+    if (mConstraints.mFake)
+      backend = new MediaEngineDefault();
+    else
+      backend = mManager->GetBackend(mWindowId);
 
     ScopedDeletePtr<SourceSet> final (GetSources(backend, mConstraints.mVideom,
                                           &MediaEngine::EnumerateVideoDevices,
                                           mLoopbackVideoDevice));
     {
       ScopedDeletePtr<SourceSet> s (GetSources(backend, mConstraints.mAudiom,
                                         &MediaEngine::EnumerateAudioDevices,
                                         mLoopbackAudioDevice));
@@ -1410,24 +1420,69 @@ MediaManager::GetUserMedia(JSContext* aC
   if (c.mPicture) {
     // ShowFilePickerForMimeType() must run on the Main Thread! (on Android)
     runnable->Arm();
     NS_DispatchToMainThread(runnable);
     return NS_OK;
   }
 #endif
   // XXX No full support for picture in Desktop yet (needs proper UI)
-  if (aPrivileged || c.mFake) {
+  if (aPrivileged ||
+      (c.mFake && !Preferences::GetBool("media.navigator.permission.fake"))) {
     runnable->Arm();
     mMediaThread->Dispatch(runnable, NS_DISPATCH_NORMAL);
   } else {
+    // Check if this site has persistent permissions.
+    nsresult rv;
+    nsCOMPtr<nsIPermissionManager> permManager =
+      do_GetService(NS_PERMISSIONMANAGER_CONTRACTID, &rv);
+    NS_ENSURE_SUCCESS(rv, rv);
+
+    uint32_t audioPerm = nsIPermissionManager::UNKNOWN_ACTION;
+    if (c.mAudio) {
+      rv = permManager->TestExactPermissionFromPrincipal(
+        aWindow->GetExtantDoc()->NodePrincipal(), "microphone", &audioPerm);
+      NS_ENSURE_SUCCESS(rv, rv);
+      if (audioPerm == nsIPermissionManager::PROMPT_ACTION) {
+        audioPerm = nsIPermissionManager::UNKNOWN_ACTION;
+      }
+    }
+
+    uint32_t videoPerm = nsIPermissionManager::UNKNOWN_ACTION;
+    if (c.mVideo) {
+      rv = permManager->TestExactPermissionFromPrincipal(
+        aWindow->GetExtantDoc()->NodePrincipal(), "camera", &videoPerm);
+      NS_ENSURE_SUCCESS(rv, rv);
+      if (videoPerm == nsIPermissionManager::PROMPT_ACTION) {
+        videoPerm = nsIPermissionManager::UNKNOWN_ACTION;
+      }
+    }
+
+    if ((!c.mAudio || audioPerm) && (!c.mVideo || videoPerm)) {
+      // All permissions we were about to request already have a saved value.
+      if (c.mAudio && audioPerm == nsIPermissionManager::DENY_ACTION) {
+        c.mAudio = false;
+        runnable->SetContraints(c);
+      }
+      if (c.mVideo && videoPerm == nsIPermissionManager::DENY_ACTION) {
+        c.mVideo = false;
+        runnable->SetContraints(c);
+      }
+
+      runnable->Arm();
+      if (!c.mAudio && !c.mVideo) {
+        return runnable->Denied(NS_LITERAL_STRING("PERMISSION_DENIED"));
+      }
+
+      return mMediaThread->Dispatch(runnable, NS_DISPATCH_NORMAL);
+    }
+
     // Ask for user permission, and dispatch runnable (or not) when a response
     // is received via an observer notification. Each call is paired with its
     // runnable by a GUID.
-    nsresult rv;
     nsCOMPtr<nsIUUIDGenerator> uuidgen =
       do_GetService("@mozilla.org/uuid-generator;1", &rv);
     NS_ENSURE_SUCCESS(rv, rv);
 
     // Generate a call ID.
     nsID id;
     rv = uuidgen->GenerateUUIDInPlace(&id);
     NS_ENSURE_SUCCESS(rv, rv);
--- a/dom/media/MediaManager.h
+++ b/dom/media/MediaManager.h
@@ -14,16 +14,17 @@
 #include "nsObserverService.h"
 #include "nsIPrefService.h"
 #include "nsIPrefBranch.h"
 
 #include "nsPIDOMWindow.h"
 #include "nsIDOMNavigatorUserMedia.h"
 #include "nsXULAppAPI.h"
 #include "mozilla/Attributes.h"
+#include "mozilla/Preferences.h"
 #include "mozilla/StaticPtr.h"
 #include "mozilla/dom/MediaStreamTrackBinding.h"
 #include "prlog.h"
 #include "DOMMediaStream.h"
 
 #ifdef MOZ_WEBRTC
 #include "mtransport/runnable_utils.h"
 #endif
@@ -97,22 +98,26 @@ public:
     return mStream->AsSourceStream();
   }
 
   // mVideo/AudioSource are set by Activate(), so we assume they're capturing
   // if set and represent a real capture device.
   bool CapturingVideo()
   {
     NS_ASSERTION(NS_IsMainThread(), "Only call on main thread");
-    return mVideoSource && !mVideoSource->IsFake() && !mStopped;
+    return mVideoSource && !mStopped &&
+           (!mVideoSource->IsFake() ||
+            Preferences::GetBool("media.navigator.permission.fake"));
   }
   bool CapturingAudio()
   {
     NS_ASSERTION(NS_IsMainThread(), "Only call on main thread");
-    return mAudioSource && !mAudioSource->IsFake() && !mStopped;
+    return mAudioSource && !mStopped &&
+           (!mAudioSource->IsFake() ||
+            Preferences::GetBool("media.navigator.permission.fake"));
   }
 
   void SetStopped()
   {
     mStopped = true;
   }
 
   // implement in .cpp to avoid circular dependency with MediaOperationRunnable