Bug 1549633 - Enable listening for `recording-device-events`. r=esawin,geckoview-reviewers,snorp a=jcristau
authorEmily Toop <etoop@mozilla.com>
Fri, 24 May 2019 07:45:41 +0000
changeset 536624 36fc5c4b157bd06adc11353f7431c7146317f8ef
parent 536623 8aeedae78bb1b4d6c3bf24eba6ce4d3d522d727f
child 536625 75a98a0225b3c38bfd789e019b66e87a4b6c1199
push id2082
push userffxbld-merge
push dateMon, 01 Jul 2019 08:34:18 +0000
treeherdermozilla-release@2fb19d0466d2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersesawin, geckoview-reviewers, snorp, jcristau
bugs1549633
milestone68.0
Bug 1549633 - Enable listening for `recording-device-events`. r=esawin,geckoview-reviewers,snorp a=jcristau This is to allow us to detect the enabling and disabling of recording so that we can notify the embedding application of the change in status. Differential Revision: https://phabricator.services.mozilla.com/D31072
mobile/android/components/geckoview/GeckoView.manifest
mobile/android/components/geckoview/GeckoViewStartup.js
mobile/android/geckoview/api.txt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
mobile/android/modules/geckoview/GeckoViewMedia.jsm
--- a/mobile/android/components/geckoview/GeckoView.manifest
+++ b/mobile/android/components/geckoview/GeckoView.manifest
@@ -17,9 +17,9 @@ contract @mozilla.org/embedcomp/prompt-s
 contract @mozilla.org/prompter;1 {076ac188-23c1-4390-aa08-7ef1f78ca5d9}
 component {aa0dd6fc-73dd-4621-8385-c0b377e02cee} GeckoViewPrompt.js process=main
 contract @mozilla.org/colorpicker;1 {aa0dd6fc-73dd-4621-8385-c0b377e02cee} process=main
 component {e4565e36-f101-4bf5-950b-4be0887785a9} GeckoViewPrompt.js process=main
 contract @mozilla.org/filepicker;1 {e4565e36-f101-4bf5-950b-4be0887785a9} process=main
 
 # GeckoViewExternalAppService.js
 component {a89eeec6-6608-42ee-a4f8-04d425992f45} GeckoViewExternalAppService.js
-contract @mozilla.org/uriloader/external-helper-app-service;1 {a89eeec6-6608-42ee-a4f8-04d425992f45}
\ No newline at end of file
+contract @mozilla.org/uriloader/external-helper-app-service;1 {a89eeec6-6608-42ee-a4f8-04d425992f45}
--- a/mobile/android/components/geckoview/GeckoViewStartup.js
+++ b/mobile/android/components/geckoview/GeckoViewStartup.js
@@ -39,16 +39,23 @@ GeckoViewStartup.prototype = {
             "getUserMedia:request",
             "PeerConnection:request",
           ],
           ppmm: [
             "GeckoView:AddCameraPermission",
           ],
         });
 
+        GeckoViewUtils.addLazyGetter(this, "GeckoViewRecordingMedia", {
+          module: "resource://gre/modules/GeckoViewMedia.jsm",
+          observers: [
+            "recording-device-events",
+          ],
+        });
+
         GeckoViewUtils.addLazyGetter(this, "GeckoViewConsole", {
           module: "resource://gre/modules/GeckoViewConsole.jsm",
         });
 
         GeckoViewUtils.addLazyGetter(this, "GeckoViewWebExtension", {
           module: "resource://gre/modules/GeckoViewWebExtension.jsm",
           ged: [
             "GeckoView:RegisterWebExtension",
--- a/mobile/android/geckoview/api.txt
+++ b/mobile/android/geckoview/api.txt
@@ -514,16 +514,37 @@ package org.mozilla.geckoview {
 
   public static interface GeckoSession.HistoryDelegate.HistoryList implements List {
     method @AnyThread default public int getCurrentIndex();
   }
 
   public static interface GeckoSession.MediaDelegate {
     method @UiThread default public void onMediaAdd(@NonNull GeckoSession, @NonNull MediaElement);
     method @UiThread default public void onMediaRemove(@NonNull GeckoSession, @NonNull MediaElement);
+    method @UiThread default public void onRecordingStatusChanged(@NonNull GeckoSession, @NonNull GeckoSession.MediaDelegate.RecordingDevice[]);
+  }
+
+  public static class GeckoSession.MediaDelegate.RecordingDevice {
+    ctor protected RecordingDevice();
+    field public static final int TYPE_CAMERA = 0;
+    field public static final int TYPE_MICROPHONE = 1;
+    field public final long status;
+    field public final long type;
+  }
+
+  public static class GeckoSession.MediaDelegate.RecordingDevice.Status {
+    ctor protected Status();
+    field public static final long INACTIVE = 1L;
+    field public static final long RECORDING = 0L;
+  }
+
+  public static class GeckoSession.MediaDelegate.RecordingDevice.Type {
+    ctor protected Type();
+    field public static final long CAMERA = 0L;
+    field public static final long MICROPHONE = 1L;
   }
 
   public static interface GeckoSession.NavigationDelegate {
     method @UiThread default public void onCanGoBack(@NonNull GeckoSession, boolean);
     method @UiThread default public void onCanGoForward(@NonNull GeckoSession, boolean);
     method @UiThread @Nullable default public GeckoResult<String> onLoadError(@NonNull GeckoSession, @Nullable String, @NonNull WebRequestError);
     method @UiThread @Nullable default public GeckoResult<AllowOrDeny> onLoadRequest(@NonNull GeckoSession, @NonNull GeckoSession.NavigationDelegate.LoadRequest);
     method @UiThread default public void onLocationChange(@NonNull GeckoSession, @Nullable String);
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt
@@ -0,0 +1,150 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+
+import android.support.test.filters.MediumTest
+import android.support.test.runner.AndroidJUnit4
+import android.util.Log
+import org.hamcrest.Matchers
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+import org.mozilla.geckoview.test.util.Callbacks
+import org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class MediaDelegateTest : BaseSessionTest() {
+
+    private fun requestRecordingPermission(allowAudio: Boolean, allowCamera: Boolean) {
+
+        mainSession.delegateDuringNextWait(object : Callbacks.PermissionDelegate {
+            @GeckoSessionTestRule.AssertCalled(count = 1)
+            override fun onMediaPermissionRequest(
+                    session: GeckoSession, uri: String,
+                    video: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+                    audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+                    callback: GeckoSession.PermissionDelegate.MediaCallback) {
+                if (! (allowAudio || allowCamera)) {
+                    callback.reject();
+                    return;
+                }
+                var audioDevice: GeckoSession.PermissionDelegate.MediaSource? = null;
+                var videoDevice: GeckoSession.PermissionDelegate.MediaSource? = null;
+                if (allowAudio) {
+                    audioDevice = audio!![0];
+                }
+                if (allowCamera) {
+                    videoDevice = video!![0];
+                }
+
+                if (videoDevice != null || audioDevice != null) {
+                    callback.grant(videoDevice, audioDevice);
+                }
+            }
+
+            override fun onAndroidPermissionsRequest(session: GeckoSession,
+                                                     permissions: Array<out String>?,
+                                                     callback: GeckoSession.PermissionDelegate.Callback) {
+                callback.grant()
+            }
+        })
+
+        mainSession.delegateDuringNextWait(object : Callbacks.MediaDelegate {
+            @GeckoSessionTestRule.AssertCalled(count = 1)
+            override fun onRecordingStatusChanged(session: GeckoSession,
+                                                devices:  Array<RecordingDevice>) {
+                var audioActive = false
+                var cameraActive = false
+                for (device in devices) {
+                    if (device.type == RecordingDevice.Type.MICROPHONE) {
+                        audioActive = device.status != RecordingDevice.Status.INACTIVE
+                    }
+                    if (device.type == RecordingDevice.Type.CAMERA) {
+                        cameraActive = device.status != RecordingDevice.Status.INACTIVE
+                    }
+                }
+
+                assertThat("Camera is ${if (allowCamera) { "active" } else { "inactive" }}",
+                        cameraActive, Matchers.equalTo(allowCamera))
+
+                assertThat("Audio is ${if (allowAudio ) { "active" } else { "inactive" }}" ,
+                        audioActive, Matchers.equalTo(allowAudio))
+
+            }
+        })
+
+
+        var code: String?
+        if (allowAudio && allowCamera) {
+            code = """window.navigator.mediaDevices.getUserMedia({
+                       video: { width: 320, height: 240, frameRate: 10 },
+                       audio: true
+                   })"""
+        } else if (allowAudio) {
+            code = """window.navigator.mediaDevices.getUserMedia({
+                       audio: true,
+                   })"""
+        } else if (allowCamera) {
+            code = """window.navigator.mediaDevices.getUserMedia({
+                       video: { width: 320, height: 240, frameRate: 10 },
+                   })"""
+        } else {
+            return
+        }
+
+        val stream = mainSession.waitForJS(code)
+
+        assertThat("Stream should be active", stream.asJSMap(),
+                Matchers.hasEntry("active", true))
+        assertThat("Stream should have ID", stream.asJSMap(),
+                Matchers.hasEntry(Matchers.equalTo("id"), Matchers.not(Matchers.isEmptyString())))
+
+
+    }
+
+    @GeckoSessionTestRule.WithDevToolsAPI
+    @Test fun testDeviceRecordingEventAudio() {
+        mainSession.loadTestPath(HELLO_HTML_PATH)
+        mainSession.waitForPageStop()
+
+        val devices = mainSession.waitForJS(
+                "window.navigator.mediaDevices.enumerateDevices()").asJSList<Map<String, String>>()
+        val audioDevice = devices.find { map -> map["kind"] == "audioinput" }
+        if (audioDevice != null) {
+            requestRecordingPermission(allowAudio = true, allowCamera = false);
+        }
+    }
+
+    @GeckoSessionTestRule.WithDevToolsAPI
+    @Test fun testDeviceRecordingEventVideo() {
+        mainSession.loadTestPath(HELLO_HTML_PATH)
+        mainSession.waitForPageStop()
+
+        val devices = mainSession.waitForJS(
+                "window.navigator.mediaDevices.enumerateDevices()").asJSList<Map<String, String>>()
+        val videoDevice = devices.find { map -> map["kind"] == "videoinput" }
+        if (videoDevice != null) {
+            requestRecordingPermission(allowAudio = false, allowCamera = true);
+        }
+
+    }
+
+    @GeckoSessionTestRule.WithDevToolsAPI
+    @Test fun testDeviceRecordingEventAudioAndVideo() {
+        mainSession.loadTestPath(HELLO_HTML_PATH)
+        mainSession.waitForPageStop()
+
+        val devices = mainSession.waitForJS(
+                "window.navigator.mediaDevices.enumerateDevices()").asJSList<Map<String, String>>()
+        val audioDevice = devices.find { map -> map["kind"] == "audioinput" }
+        val videoDevice = devices.find { map -> map["kind"] == "videoinput" }
+        if(audioDevice != null && videoDevice != null) {
+            requestRecordingPermission(allowAudio = true, allowCamera = true);
+        }
+    }
+}
\ No newline at end of file
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -44,16 +44,17 @@ import android.net.Uri;
 import android.os.Binder;
 import android.os.IBinder;
 import android.os.IInterface;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.os.SystemClock;
 import android.support.annotation.AnyThread;
 import android.support.annotation.IntDef;
+import android.support.annotation.LongDef;
 import android.support.annotation.Nullable;
 import android.support.annotation.NonNull;
 import android.support.annotation.StringDef;
 import android.support.annotation.UiThread;
 import android.util.Base64;
 import android.util.Log;
 import android.util.LongSparseArray;
 import android.view.Surface;
@@ -863,16 +864,17 @@ public class GeckoSession implements Par
                         "GeckoView:MediaTimeChanged",
                         "GeckoView:MediaPlaybackStateChanged",
                         "GeckoView:MediaMetadataChanged",
                         "GeckoView:MediaProgress",
                         "GeckoView:MediaVolumeChanged",
                         "GeckoView:MediaRateChanged",
                         "GeckoView:MediaFullscreenChanged",
                         "GeckoView:MediaError",
+                        "GeckoView:MediaRecordingStatusChanged",
                     }
             ) {
         @Override
         public void handleMessage(final MediaDelegate delegate,
                                   final String event,
                                   final GeckoBundle message,
                                   final EventCallback callback) {
             if ("GeckoView:MediaAdd".equals(event)) {
@@ -881,16 +883,24 @@ public class GeckoSession implements Par
                 return;
             } else if ("GeckoView:MediaRemoveAll".equals(event)) {
                 for (int i = 0; i < mMediaElements.size(); i++) {
                     final long key = mMediaElements.keyAt(i);
                     delegate.onMediaRemove(GeckoSession.this, mMediaElements.get(key));
                 }
                 mMediaElements.clear();
                 return;
+            } else if ("GeckoView:MediaRecordingStatusChanged".equals(event)) {
+                final GeckoBundle[] deviceBundles = message.getBundleArray("devices");
+                final MediaDelegate.RecordingDevice[] devices = new MediaDelegate.RecordingDevice[deviceBundles.length];
+                for (int i = 0; i < deviceBundles.length; i++) {
+                    devices[i] = new MediaDelegate.RecordingDevice(deviceBundles[i]);
+                }
+                delegate.onRecordingStatusChanged(GeckoSession.this, devices);
+                return;
             }
 
             final long id = message.getLong("id", 0);
             final MediaElement element = mMediaElements.get(id);
             if (element == null) {
                 Log.w(LOGTAG, "MediaElement not found for '" + id + "'");
                 return;
             }
@@ -1007,17 +1017,17 @@ public class GeckoSession implements Par
      */
     @AnyThread
     public static @NonNull String getDefaultUserAgent() {
         return BuildConfig.USER_AGENT_GECKOVIEW_MOBILE;
     }
 
     /**
      * Get the current prompt delegate for this GeckoSession.
-     * @return PromptDelegate instance or null if using default delegate.
+     * @return PermissionDelegate instance or null if using default delegate.
      */
     @UiThread
     public @Nullable PermissionDelegate getPermissionDelegate() {
         ThreadUtils.assertOnUiThread();
         return mPermissionHandler.getDelegate();
     }
 
     /**
@@ -4947,31 +4957,128 @@ public class GeckoSession implements Par
             mOverscroll.setSize(mWidth, mClientHeight);
         }
     }
 
     /**
      * GeckoSession applications implement this interface to handle media events.
      */
     public interface MediaDelegate {
+
+        class RecordingDevice {
+
+            /*
+             * Default status flags for this RecordingDevice.
+             */
+            public static class Status {
+                public static final long RECORDING = 0;
+                public static final long INACTIVE = 1 << 0;
+
+                // Do not instantiate this class.
+                protected Status() {}
+            }
+
+            /*
+             * Default device types for this RecordingDevice.
+             */
+            public static class Type {
+                public static final long CAMERA = 0;
+                public static final long MICROPHONE = 1 << 0;
+
+                // Do not instantiate this class.
+                protected Type() {}
+            }
+
+            @Retention(RetentionPolicy.SOURCE)
+            @LongDef(flag = true,
+                    value = { Status.RECORDING, Status.INACTIVE })
+            /* package */ @interface RecordingStatus {}
+
+            @Retention(RetentionPolicy.SOURCE)
+            @LongDef(flag = true,
+                    value = {Type.CAMERA, Type.MICROPHONE})
+            /* package */ @interface DeviceType {}
+
+            /**
+             * The recording device is camera.
+             */
+            public static final int TYPE_CAMERA = 0;
+
+            /**
+             * The recording device is microphone.
+             */
+            public static final int TYPE_MICROPHONE = 1;
+
+            /**
+             * A long giving the current recording status, must be either Status.RECORDING,
+             * Status.PAUSED or Status.INACTIVE.
+             */
+            public final @RecordingStatus long status;
+
+            /**
+             * A long giving the type of the recording device, must be either Type.CAMERA or Type.MICROPHONE.
+             */
+            public final @DeviceType long type;
+
+            private static @DeviceType long getTypeFromString(final String type) {
+                if ("microphone".equals(type)) {
+                    return Type.MICROPHONE;
+                } else if ("camera".equals(type)) {
+                    return Type.CAMERA;
+                } else {
+                    throw new IllegalArgumentException("String: " + type + " is not a valid recording device string");
+                }
+            }
+
+            private static @RecordingStatus long getStatusFromString(final String type) {
+                if ("recording".equals(type)) {
+                    return Status.RECORDING;
+                } else {
+                    return Status.INACTIVE;
+                }
+            }
+
+            /* package */ RecordingDevice(final GeckoBundle media) {
+                status = getStatusFromString(media.getString("status"));
+                type = getTypeFromString(media.getString("type"));
+            }
+
+            /**
+             * Empty constructor for tests.
+             */
+            protected RecordingDevice() {
+                status = Status.INACTIVE;
+                type = Type.CAMERA;
+            }
+        }
         /**
          * An HTMLMediaElement has been created.
          * @param session Session instance.
          * @param element The media element that was just created.
          */
         @UiThread
         default void onMediaAdd(@NonNull GeckoSession session, @NonNull MediaElement element) {}
 
         /**
          * An HTMLMediaElement has been unloaded.
          * @param session Session instance.
          * @param element The media element that was unloaded.
          */
         @UiThread
         default void onMediaRemove(@NonNull GeckoSession session, @NonNull MediaElement element) {}
+
+        /**
+         * A recording device has changed state.
+         * Any change to the recording state of the devices microphone or camera will call this
+         * delegate method. The argument provides details of the active recording devices.
+         * @param session The session that the event has originated from.
+         * @param devices The list of active devices and their recording state.
+         */
+        @UiThread
+        default void onRecordingStatusChanged(@NonNull GeckoSession session, @NonNull RecordingDevice[] devices) {}
     }
 
     /**
      * An interface for recording new history visits and fetching the visited
      * status for links.
      */
     public interface HistoryDelegate {
         /**
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -99,16 +99,21 @@ exclude: true
 
 [68.23]: ./GeckoView.html#setVerticalClipping-int-
 [68.24]: ./GeckoDisplay.html#setVerticalClipping-int-
 
 - Added [`StorageController`][68.25] API for clearing data.
 
 [68.25]: ../StorageController.html
 
+- Added [`onRecordingStatusChanged`][68.26] to [`MediaDelegate`][68.27] to handle events related to the status of recording devices.
+
+[68.26]: ./GeckoSession.MediaDelegate.html#onRecordingStatusChanged-org.mozilla.geckoview.GeckoSession-org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice:A-
+[68.27]: ./GeckoSession.MediaDelegate.html
+
 ## v67
 - Added [`setAutomaticFontSizeAdjustment`][67.2] to
   [`GeckoRuntimeSettings`][67.3] for automatically adjusting font size settings
   depending on the OS-level font size setting.
 
 [67.2]: ../GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment-boolean-
 [67.3]: ../GeckoRuntimeSettings.html
 
@@ -305,9 +310,9 @@ exclude: true
 [65.23]: ../GeckoSession.FinderResult.html
 
 - Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a
   [`GeckoResult<String>`][65.25].
 
 [65.24]: ../CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String-
 [65.25]: ../GeckoResult.html
 
-[api-version]: 6078967e45c80550c5d17189856d3d3206b8540f
+[api-version]: 5a26cbe99d38ca771058fef5b84d2ae033e161b4
--- a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
+++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
@@ -23,18 +23,16 @@ import android.app.DownloadManager;
 import android.content.ActivityNotFoundException;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Environment;
 import android.os.SystemClock;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
 import android.support.v4.app.ActivityCompat;
 import android.support.v4.content.ContextCompat;
 import android.support.v7.app.ActionBar;
 import android.support.v7.app.AppCompatActivity;
 import android.support.v7.widget.Toolbar;
 import android.util.Log;
 import android.view.Menu;
 import android.view.MenuInflater;
--- a/mobile/android/modules/geckoview/GeckoViewMedia.jsm
+++ b/mobile/android/modules/geckoview/GeckoViewMedia.jsm
@@ -1,17 +1,32 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-var EXPORTED_SYMBOLS = ["GeckoViewMedia"];
+var EXPORTED_SYMBOLS = ["GeckoViewMedia", "GeckoViewRecordingMedia"];
 
 const {GeckoViewModule} = ChromeUtils.import("resource://gre/modules/GeckoViewModule.jsm");
+const {GeckoViewUtils} = ChromeUtils.import("resource://gre/modules/GeckoViewUtils.jsm");
+const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService",
+                                   "@mozilla.org/mediaManagerService;1",
+                                   "nsIMediaManagerService");
+
+const STATUS_RECORDING = "recording";
+const STATUS_INACTIVE = "inactive";
+const TYPE_CAMERA = "camera";
+const TYPE_MICROPHONE = "microphone";
 
 class GeckoViewMedia extends GeckoViewModule {
   onEnable() {
     this.registerListener([
       "GeckoView:MediaObserve",
       "GeckoView:MediaUnobserve",
       "GeckoView:MediaPlay",
       "GeckoView:MediaPause",
@@ -27,9 +42,69 @@ class GeckoViewMedia extends GeckoViewMo
   }
 
   onEvent(aEvent, aData, aCallback) {
     debug `onEvent: event=${aEvent}, data=${aData}`;
     this.messageManager.sendAsyncMessage(aEvent, aData);
   }
 }
 
+const GeckoViewRecordingMedia = {
+  // The event listener for this is hooked up in GeckoViewStartup.js
+  observe(aSubject, aTopic, aData) {
+    debug `observe: aTopic=${aTopic}`;
+    switch (aTopic) {
+      case "recording-device-events": {
+        this.handleRecordingDeviceEvents();
+        break;
+      }
+    }
+  },
+
+  handleRecordingDeviceEvents() {
+    debug `handleRecordingDeviceEvents`;
+    const windows = MediaManagerService.activeMediaCaptureWindows;
+    const devices = [];
+
+    const getStatusString = function(activityStatus) {
+      switch (activityStatus) {
+        case MediaManagerService.STATE_CAPTURE_ENABLED:
+        case MediaManagerService.STATE_CAPTURE_DISABLED:
+          return STATUS_RECORDING;
+        case MediaManagerService.STATE_NOCAPTURE:
+          return STATUS_INACTIVE;
+      }
+    };
+
+    for (let i = 0; i < windows.length; i++) {
+      const win = windows.queryElementAt(i, Ci.nsIDOMWindow);
+      const hasCamera = {};
+      const hasMicrophone = {};
+      MediaManagerService.mediaCaptureWindowState(win, hasCamera, hasMicrophone);
+      var cameraStatus = getStatusString(hasCamera.value);
+      var microphoneStatus = getStatusString(hasMicrophone.value);
+      if (hasCamera.value != MediaManagerService.STATE_NOCAPTURE) {
+        devices.push({
+          type: TYPE_CAMERA,
+          status: cameraStatus,
+        });
+      }
+      if (hasMicrophone.value != MediaManagerService.STATE_NOCAPTURE) {
+        devices.push({
+          type: TYPE_MICROPHONE,
+          status: microphoneStatus,
+        });
+      }
+    }
+
+    const [dispatcher] = GeckoViewUtils.getActiveDispatcherAndWindow();
+    if (dispatcher) {
+      dispatcher.sendRequestForResult({
+        type: "GeckoView:MediaRecordingStatusChanged",
+        devices: devices,
+      });
+    } else {
+      console.log("no dispatcher present");
+    }
+  },
+};
+
 const {debug, warn} = GeckoViewMedia.initLogging("GeckoViewMedia"); // eslint-disable-line no-unused-vars