Bug 1307820 - Implement per-GeckoView messaging; r=snorp r=sebastian
authorJim Chen <nchen@mozilla.com>
Mon, 14 Nov 2016 21:29:50 +0800
changeset 322491 8c4b29e6e5bd48410d01477d2bc5cd477a70950c
parent 322490 9cb1f00af10bc3f614a0411c7b6b874946540b97
child 322492 ad24787de436bc634cd746d0f953f057db017d44
push id21
push usermaklebus@msu.edu
push dateThu, 01 Dec 2016 06:22:08 +0000
reviewerssnorp, sebastian
bugs1307820
milestone52.0a1
Bug 1307820 - Implement per-GeckoView messaging; r=snorp r=sebastian Bug 1307820 - 1a. Move GeckoApp EventDispatcher to GeckoView; r=snorp Make it a GeckoView-specific EventDispatcher instead of GeckoApp-specific, so that GeckoView consumers can benefit from a per-view EventDispatcher. In addition, a few events like Gecko:Ready are moved back to the global EventDispatcher because that makes more sense. Bug 1307820 - 1b. Don't use GeckoApp EventDispatcher during inflation; r=snorp During layout inflation, we don't yet have GeckoView and therefore the GeckoView EventDispatcher, so we should not register events until later, typically during onAttachedToWindow. Bug 1307820 - 2. Introduce GeckoBundle; r=snorp The Android Bundle class has several disadvantages when used for holding structured data from JS. The most obvious one is the differentiation between int and double, which doesn't exist in JS. So when a JS number is converted to either a Bundle int or double, we run the risk of making a wrong conversion, resulting in a type mismatch exception when Java uses the Bundle. This extends to number arrays from JS. There is one more gotcha when using arrays. When we receive an empty array from JS, there is no way for us to determine the type of the array, because even empty arrays in Java have types. We are forced to pick an arbitrary type like boolean[], which can easily result in a type mismatch exception when using the array on the Java side. In addition, Bundle is fairly cumbersome, and we cannot access the inner structures of Bundle from Java or JNI, making it harder to use. With these factors in mind, this patch introduces GeckoBundle as a better choice for Gecko/Java communication. It is almost fully API-compatible with the Android Bundle; only the Bundle array methods are different. It resolves the numbers problem by performing conversions if necessary, and it is a lot more lightweight than Bundle. Bug 1307820 - 3. Convert BundleEventListener to use GeckoBundle; r=snorp Convert BundleEventListener from using Bundle to using GeckoBundle. Because NativeJSContainer still only supports Bundle, we do an extra conversion when sending Bundle messages, but eventually, as we eliminate the use of NativeJSContainer, that will go away as well. Bug 1307820 - 4. Introduce EventDispatcher interfaces; r=snorp Introduce several new XPCOM interfaces for the new EventDispatcher API, these interfaces are mostly mirrored after their Java counterparts. * nsIAndroidEventDispatcher is the main interface for registering/unregistering listeners and for dispatching events from JS/C++. * nsIAndroidEventListener is the interface that JS/C++ clients implement to receive events. * nsIAndroidEventCallback is the interface that JS/C++ clients implement to receive responses from dispatched events. * nsIAndroidView is the new interface that every window receives that is specific to the window/GeckoView pair. It is passed to chrome scripts through window arguments. Bug 1307820 - 5. Remove EventDispatcher references from gfx code; r=snorp EventDispatcher was used for JPZC, but NPZC doesn't use it anymore. Bug 1307820 - 6. General JNI template improvements; r=snorp This patch includes several improvements to the JNI templates. * Context::RawClassRef is removed to avoid misuse, as Context::ClassRef should be used instead. * Fix a compile error, in certain usages, in the DisposeNative overload in NativeStub. * Add Ref::IsInstanceOf and Context::IsInstanceOf to mirror the JNIEnv::IsInstanceOf call. * Add Ref::operator* and Context::operator* to provide an easy way to get a Context object. * Add built-in declarations for boxed Java objects (e.g. Boolean, Integer, etc). * Add ObjectArray::New for creating new object arrays of specific types. * Add lvalue qualifiers to LocalRef::operator= and GlobalRef::operator=, to prevent accidentally assigning to rvalues. (e.g. `objectArray->GetElement(0) = newObject;`, which won't work as intended.) Bug 1307820 - 7. Support ownership through RefPtr for native JNI objects; r=snorp In addition to direct ownership and weak pointer ownership, add a third ownership model where a native JNI object owns a RefPtr that holds a strong reference to the actual C++ object. This ownership model works well with ref-counted objects such as XPCOM objects, and is activated through the presence of public members AddRef() and Release() in the C++ object. Bug 1307820 - 8. Implement Gecko-side EventDispatcher; r=snorp Add a skeletal implementation of EventDispatcher on the Gecko side. Each widget::EventDispatcher will be associated with a Java EventDispatcher, so events can be dispatched from Gecko to Java and vice versa. AndroidBridge and nsWindow will implement nsIAndroidEventDispatcher through widget::EventDispatcher. Other patches will add more complete functionality such as GeckoBundle/JSObject translation and support for callbacks. Bug 1307820 - 9. Implement dispatching between Gecko/Java; r=snorp Implement translation between JSObject and GeckoBundle, and use that for dispatching events from Gecko to Java and vice versa. Bug 1307820 - 10. Implement callback support; r=snorp Implement callback support for both Gecko-to-Java events and Java-to-Gecko events. For Gecko-to-Java, we translate nsIAndroidEventCallback to a Java EventCallback through NativeCallbackDelegate and pass it to the Java listener. For Java-to-Gecko, we translate EventCallback to a nsIAndroidEventCallback through JavaCallbackDelegate and pass it to the Gecko listener. There is another JavaCallbackDelegate on the Java side that redirects the callback to a particular thread. For example, if the event was dispatched from the UI thread, we make sure the callback happens on the UI thread as well. Bug 1307820 - 11. Add BundleEventListener support for Gecko thread; r=snorp Add support for BundleEventListener on the Gecko thread, so that we can use it to replace any existing GeckoEventListener or NativeEventListener implementations that require the listener be run synchronously on the Gecko thread. Bug 1307820 - 12. Add global EventDispatcher in AndroidBridge; r=snorp Add an instance of EventDispatcher to AndroidBridge to act as a global event dispatcher. Bug 1307820 - 13. Add per-nsWindow EventDispatcher; r=snorp Add an instance of EventDispatcher to each nsWindow through an AndroidView object, which implements nsIAndroidView. The nsIAndroidView is passed to the chrome script through the window argument when opening the window. Bug 1307820 - 14. Update auto-generated bindings; r=me Bug 1307820 - 15. Update testEventDispatcher; r=snorp Update testEventDispatcher to include new functionalities in EventDisptcher. * Add tests for dispatching events to UI/background thread through nsIAndroidEventDispatcher::dispatch. * Add tests for dispatching events to UI/background thread through EventDispatcher.dispatch. * Add tests for dispatching events to Gecko thread through EventDispatcher.dispatch. Each kind of test exercises both the global EventDispatcher through EventDispatcher.getInstance() and the per-GeckoView EventDispatcher through GeckoApp.getEventDispatcher().
mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java
mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java
mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java
mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java
mobile/android/base/java/org/mozilla/gecko/push/PushService.java
mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java
mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java
mobile/android/base/moz.build
mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoView.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoLayerClient.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerView.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java
mobile/android/tests/browser/robocop/robocop.ini
mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testEventDispatcher.java
mobile/android/tests/browser/robocop/testEventDispatcher.js
widget/android/AndroidBridge.cpp
widget/android/AndroidBridge.h
widget/android/EventDispatcher.cpp
widget/android/EventDispatcher.h
widget/android/GeneratedJNINatives.h
widget/android/GeneratedJNIWrappers.cpp
widget/android/GeneratedJNIWrappers.h
widget/android/jni/Accessors.h
widget/android/jni/Natives.h
widget/android/jni/Refs.h
widget/android/jni/Utils.cpp
widget/android/moz.build
widget/android/nsIAndroidBridge.idl
widget/android/nsWindow.cpp
widget/android/nsWindow.h
--- a/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java
+++ b/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java
@@ -85,28 +85,39 @@ public class FormAssistPopup extends Rel
     public FormAssistPopup(Context context, AttributeSet attrs) {
         super(context, attrs);
         mContext = context;
 
         mAnimation = AnimationUtils.loadAnimation(context, R.anim.grow_fade_in);
         mAnimation.setDuration(75);
 
         setFocusable(false);
+    }
+
+    @Override
+    public void onAttachedToWindow() {
+        super.onAttachedToWindow();
 
         GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
             "FormAssist:AutoComplete",
             "FormAssist:ValidationMessage",
             "FormAssist:Hide");
     }
 
     void destroy() {
+    }
+
+    @Override
+    public void onDetachedFromWindow() {
         GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
             "FormAssist:AutoComplete",
             "FormAssist:ValidationMessage",
             "FormAssist:Hide");
+
+        super.onDetachedFromWindow();
     }
 
     @Override
     public void handleMessage(String event, JSONObject message) {
         try {
             if (event.equals("FormAssist:AutoComplete")) {
                 handleAutoCompleteMessage(message);
             } else if (event.equals("FormAssist:ValidationMessage")) {
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
@@ -2,16 +2,17 @@
  * 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/. */
 
 package org.mozilla.gecko;
 
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
+import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.db.UrlAnnotations;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.gfx.FullScreenState;
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.health.HealthRecorder;
 import org.mozilla.gecko.health.SessionInformation;
 import org.mozilla.gecko.health.StubbedHealthRecorder;
@@ -199,18 +200,16 @@ public abstract class GeckoApp
     private View mFullScreenPluginView;
 
     private final HashMap<String, PowerManager.WakeLock> mWakeLocks = new HashMap<String, PowerManager.WakeLock>();
 
     protected boolean mLastSessionCrashed;
     protected boolean mShouldRestore;
     private boolean mSessionRestoreParsingFinished = false;
 
-    private EventDispatcher eventDispatcher;
-
     private int lastSelectedTabId = -1;
 
     private static final class LastSessionParser extends SessionParser {
         private JSONArray tabs;
         private JSONObject windowObject;
         private boolean isExternalURL;
 
         private boolean selectNextTab;
@@ -1094,18 +1093,16 @@ public abstract class GeckoApp
      *
      * Here we initialize all of our profile settings, Firefox Health Report,
      * and other one-shot constructions.
      **/
     @Override
     public void onCreate(Bundle savedInstanceState) {
         GeckoAppShell.ensureCrashHandling();
 
-        eventDispatcher = new EventDispatcher();
-
         // Enable Android Strict Mode for developers' local builds (the "default" channel).
         if ("default".equals(AppConstants.MOZ_UPDATE_CHANNEL)) {
             enableStrictMode();
         }
 
         if (!HardwareUtils.isSupportedSystem()) {
             // This build does not support the Android version of the device: Show an error and finish the app.
             mIsAbortingAppLaunch = true;
@@ -1192,43 +1189,22 @@ public abstract class GeckoApp
                 // Start a speculative connection as soon as Gecko loads.
                 GeckoThread.speculativeConnect(uri);
             }
         }
 
         // GeckoThread has to register for "Gecko:Ready" first, so GeckoApp registers
         // for events after initializing GeckoThread but before launching it.
 
-        getAppEventDispatcher().registerGeckoThreadListener((GeckoEventListener)this,
+        EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener)this,
             "Gecko:Ready",
-            "Gecko:Exited",
-            "Accessibility:Event");
-
-        getAppEventDispatcher().registerGeckoThreadListener((NativeEventListener)this,
-            "Accessibility:Ready",
-            "Bookmark:Insert",
-            "Contact:Add",
-            "DevToolsAuth:Scan",
-            "DOMFullScreen:Start",
-            "DOMFullScreen:Stop",
-            "Image:SetAs",
-            "Locale:Set",
-            "Permissions:Data",
-            "PrivateBrowsing:Data",
-            "RuntimePermissions:Prompt",
-            "Session:StatePurged",
-            "Share:Text",
-            "Snackbar:Show",
-            "SystemUI:Visibility",
-            "ToggleChrome:Focus",
-            "ToggleChrome:Hide",
-            "ToggleChrome:Show",
-            "Update:Check",
-            "Update:Download",
-            "Update:Install");
+            "Gecko:Exited");
+
+        EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener)this,
+            "Accessibility:Ready");
 
         GeckoThread.launch();
 
         Bundle stateBundle = IntentUtils.getBundleExtraSafe(getIntent(), EXTRA_STATE_BUNDLE);
         if (stateBundle != null) {
             // Use the state bundle if it was given as an intent extra. This is
             // only intended to be used internally via Robocop, so a boolean
             // is read from a private shared pref to prevent other apps from
@@ -1250,16 +1226,41 @@ public abstract class GeckoApp
         setContentView(getLayout());
 
         // Set up Gecko layout.
         mRootLayout = (RelativeLayout) findViewById(R.id.root_layout);
         mGeckoLayout = (RelativeLayout) findViewById(R.id.gecko_layout);
         mMainLayout = (RelativeLayout) findViewById(R.id.main_layout);
         mLayerView = (GeckoView) findViewById(R.id.layer_view);
 
+        getAppEventDispatcher().registerGeckoThreadListener((GeckoEventListener)this,
+            "Accessibility:Event");
+
+        getAppEventDispatcher().registerGeckoThreadListener((NativeEventListener)this,
+            "Bookmark:Insert",
+            "Contact:Add",
+            "DevToolsAuth:Scan",
+            "DOMFullScreen:Start",
+            "DOMFullScreen:Stop",
+            "Image:SetAs",
+            "Locale:Set",
+            "Permissions:Data",
+            "PrivateBrowsing:Data",
+            "RuntimePermissions:Prompt",
+            "Session:StatePurged",
+            "Share:Text",
+            "Snackbar:Show",
+            "SystemUI:Visibility",
+            "ToggleChrome:Focus",
+            "ToggleChrome:Hide",
+            "ToggleChrome:Show",
+            "Update:Check",
+            "Update:Download",
+            "Update:Install");
+
         Tabs.getInstance().attachToContext(this, mLayerView);
 
         // Use global layout state change to kick off additional initialization
         mMainLayout.getViewTreeObserver().addOnGlobalLayoutListener(this);
 
         if (Versions.preMarshmallow) {
             mTextSelection = new ActionBarTextSelection(this);
         } else {
@@ -1737,24 +1738,25 @@ public abstract class GeckoApp
             JSONObject restoreData = new JSONObject();
             restoreData.put("sessionString", sessionString);
             return restoreData.toString();
         } catch (JSONException e) {
             throw new SessionRestoreException(e);
         }
     }
 
+    @RobocopTarget
     public static EventDispatcher getEventDispatcher() {
         final GeckoApp geckoApp = (GeckoApp) GeckoAppShell.getGeckoInterface();
         return geckoApp.getAppEventDispatcher();
     }
 
     @Override
     public EventDispatcher getAppEventDispatcher() {
-        return eventDispatcher;
+        return mLayerView != null ? mLayerView.getEventDispatcher() : null;
     }
 
     @Override
     public GeckoProfile getProfile() {
         return GeckoThread.getActiveProfile();
     }
 
     /**
@@ -2222,23 +2224,27 @@ public abstract class GeckoApp
     public void onDestroy() {
         if (mIsAbortingAppLaunch) {
             // This build does not support the Android version of the device:
             // We did not initialize anything, so skip cleaning up.
             super.onDestroy();
             return;
         }
 
-        getAppEventDispatcher().unregisterGeckoThreadListener((GeckoEventListener)this,
+        EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener)this,
             "Gecko:Ready",
-            "Gecko:Exited",
+            "Gecko:Exited");
+
+        EventDispatcher.getInstance().unregisterGeckoThreadListener((NativeEventListener)this,
+            "Accessibility:Ready");
+
+        getAppEventDispatcher().unregisterGeckoThreadListener((GeckoEventListener)this,
             "Accessibility:Event");
 
         getAppEventDispatcher().unregisterGeckoThreadListener((NativeEventListener)this,
-            "Accessibility:Ready",
             "Bookmark:Insert",
             "Contact:Add",
             "DevToolsAuth:Scan",
             "DOMFullScreen:Start",
             "DOMFullScreen:Stop",
             "Image:SetAs",
             "Locale:Set",
             "Permissions:Data",
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -4,17 +4,16 @@
 
 package org.mozilla.gecko;
 
 import android.app.Application;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.content.res.Configuration;
-import android.os.Bundle;
 import android.os.SystemClock;
 import android.util.Log;
 
 import com.squareup.leakcanary.LeakCanary;
 import com.squareup.leakcanary.RefWatcher;
 
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserDB;
@@ -26,16 +25,17 @@ import org.mozilla.gecko.lwt.Lightweight
 import org.mozilla.gecko.mdns.MulticastDNSManager;
 import org.mozilla.gecko.media.AudioFocusAgent;
 import org.mozilla.gecko.notifications.NotificationClient;
 import org.mozilla.gecko.notifications.NotificationHelper;
 import org.mozilla.gecko.preferences.DistroSharedPrefsImport;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.Clipboard;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import java.io.File;
 import java.lang.reflect.Method;
 
 public class GeckoApplication extends Application
     implements ContextGetter {
@@ -286,21 +286,21 @@ public class GeckoApplication extends Ap
 
                     Log.d(LOG_TAG, "Running late distribution task: android preferences.");
                     DistroSharedPrefsImport.importPreferences(context, distribution);
                 }
             });
         }
 
         @Override // BundleEventListener
-        public void handleMessage(final String event, final Bundle message,
+        public void handleMessage(final String event, final GeckoBundle message,
                                   final EventCallback callback) {
             if ("Profile:Create".equals(event)) {
-                onProfileCreate(message.getCharSequence("name").toString(),
-                                message.getCharSequence("path").toString());
+                onProfileCreate(message.getString("name"),
+                                message.getString("path"));
             }
         }
     }
 
     public boolean isApplicationInBackground() {
         return mInBackground;
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java
@@ -8,22 +8,22 @@ package org.mozilla.gecko;
 import java.lang.ref.SoftReference;
 import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.Queue;
 import java.util.Set;
 
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.content.ContentResolver;
 import android.content.Context;
 import android.database.Cursor;
-import android.os.Bundle;
 import android.os.Handler;
 import android.os.SystemClock;
 import android.util.Log;
 
 class GlobalHistory {
     private static final String LOGTAG = "GeckoGlobalHistory";
 
     public static final String EVENT_URI_AVAILABLE_IN_HISTORY = "URI_INSERTED_TO_HISTORY";
@@ -166,13 +166,13 @@ class GlobalHistory {
                 }
                 mProcessing = true;
                 mHandler.postDelayed(runnable, BATCHING_DELAY_MS);
             }
         });
     }
 
     private void dispatchUriAvailableMessage(String uri) {
-        final Bundle message = new Bundle();
+        final GeckoBundle message = new GeckoBundle();
         message.putString(EVENT_PARAM_URI, uri);
         EventDispatcher.getInstance().dispatch(EVENT_URI_AVAILABLE_IN_HISTORY, message);
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java
@@ -1,27 +1,27 @@
 /* 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/. */
 
 package org.mozilla.gecko;
 
 import android.content.ContentProviderClient;
-import android.os.Bundle;
 import android.support.annotation.NonNull;
 import android.support.annotation.VisibleForTesting;
 import android.text.TextUtils;
 import android.util.Log;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
@@ -39,17 +39,18 @@ import java.util.Map;
 
     private static final GlobalPageMetadata instance = new GlobalPageMetadata();
 
     private static final String KEY_HAS_IMAGE = "hasImage";
     private static final String KEY_METADATA_JSON = "metadataJSON";
 
     private static final int MAX_METADATA_QUEUE_SIZE = 15;
 
-    private final Map<String, Bundle> queuedMetadata = Collections.synchronizedMap(new LimitedLinkedHashMap<String, Bundle>());
+    private final Map<String, GeckoBundle> queuedMetadata =
+            Collections.synchronizedMap(new LimitedLinkedHashMap<String, GeckoBundle>());
 
     public static GlobalPageMetadata getInstance() {
         return instance;
     }
 
     private static class LimitedLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
         private static final long serialVersionUID = 6359725112736360244L;
 
@@ -102,54 +103,54 @@ import java.util.Map;
         }
 
         // If we could insert page metadata, we're done.
         if (db.insertPageMetadata(contentProviderClient, uri, hasImage, preparedMetadataJSON)) {
             return;
         }
 
         // Otherwise, we need to queue it for future insertion when history record is available.
-        Bundle bundledMetadata = new Bundle();
+        GeckoBundle bundledMetadata = new GeckoBundle();
         bundledMetadata.putBoolean(KEY_HAS_IMAGE, hasImage);
         bundledMetadata.putString(KEY_METADATA_JSON, preparedMetadataJSON);
         queuedMetadata.put(uri, bundledMetadata);
     }
 
     @VisibleForTesting
     /* package-local */ int getMetadataQueueSize() {
         return queuedMetadata.size();
     }
 
     @Override
-    public void handleMessage(String event, Bundle message, EventCallback callback) {
+    public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
         ThreadUtils.assertOnBackgroundThread();
 
         if (!GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY.equals(event)) {
             return;
         }
 
         final String uri = message.getString(GlobalHistory.EVENT_PARAM_URI);
         if (TextUtils.isEmpty(uri)) {
             return;
         }
 
-        final Bundle bundledMetadata;
+        final GeckoBundle bundledMetadata;
         synchronized (queuedMetadata) {
             if (!queuedMetadata.containsKey(uri)) {
                 return;
             }
 
             bundledMetadata = queuedMetadata.get(uri);
             queuedMetadata.remove(uri);
         }
 
         insertMetadataBundleForUri(uri, bundledMetadata);
     }
 
-    private void insertMetadataBundleForUri(String uri, Bundle bundledMetadata) {
+    private void insertMetadataBundleForUri(String uri, GeckoBundle bundledMetadata) {
         final boolean hasImage = bundledMetadata.getBoolean(KEY_HAS_IMAGE);
         final String metadataJSON = bundledMetadata.getString(KEY_METADATA_JSON);
 
         // Acquire CPC, must be released in this function.
         final ContentProviderClient contentProviderClient = GeckoAppShell.getApplicationContext()
                 .getContentResolver()
                 .acquireContentProviderClient(BrowserContract.PageMetadata.CONTENT_URI);
 
--- a/mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java
+++ b/mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java
@@ -27,16 +27,21 @@ public class MediaCastingBar extends Rel
     private ImageButton mMediaPlay;
     private ImageButton mMediaPause;
     private ImageButton mMediaStop;
 
     private boolean mInflated;
 
     public MediaCastingBar(Context context, AttributeSet attrs) {
         super(context, attrs);
+    }
+
+    @Override
+    public void onAttachedToWindow() {
+        super.onAttachedToWindow();
 
         GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
             "Casting:Started",
             "Casting:Paused",
             "Casting:Playing",
             "Casting:Stopped");
     }
 
@@ -67,21 +72,27 @@ public class MediaCastingBar extends Rel
         setVisibility(VISIBLE);
     }
 
     public void hide() {
         setVisibility(GONE);
     }
 
     public void onDestroy() {
+    }
+
+    @Override
+    public void onDetachedFromWindow() {
         GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
             "Casting:Started",
             "Casting:Paused",
             "Casting:Playing",
             "Casting:Stopped");
+
+        super.onDetachedFromWindow();
     }
 
     // View.OnClickListener implementation
     @Override
     public void onClick(View v) {
         final int viewId = v.getId();
 
         if (viewId == R.id.media_play) {
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -22,16 +22,17 @@ import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.annotation.ReflectionTarget;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.fxa.FxAccountPushHandler;
 import org.mozilla.gecko.gcm.GcmTokenClient;
 import org.mozilla.gecko.push.autopush.AutopushClientException;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import java.io.File;
 import java.io.IOException;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
@@ -249,17 +250,17 @@ public class PushService implements Bund
     }
 
     protected void unregisterGeckoEventListener() {
         Log.d(LOG_TAG, "Unregistered Gecko event listener.");
         EventDispatcher.getInstance().unregisterBackgroundThreadListener(this, GECKO_EVENTS);
     }
 
     @Override
-    public void handleMessage(final String event, final Bundle message, final EventCallback callback) {
+    public void handleMessage(final String event, final GeckoBundle message, final EventCallback callback) {
         Log.i(LOG_TAG, "Handling event: " + event);
         ThreadUtils.assertOnBackgroundThread();
 
         final Context context = GeckoAppShell.getApplicationContext();
         // We're invoked in response to a Gecko message on a background thread.  We should always
         // be able to safely retrieve the current Gecko profile.
         final GeckoProfile geckoProfile = GeckoProfile.get(context);
 
--- a/mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java
@@ -92,17 +92,19 @@ public class SiteIdentityPopup extends A
     private final OnButtonClickListener mContentButtonClickListener;
 
     public SiteIdentityPopup(Context context) {
         super(context);
 
         mResources = mContext.getResources();
 
         mContentButtonClickListener = new ContentNotificationButtonListener();
+    }
 
+    void registerListeners() {
         GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
                                                                   "Doorhanger:Logins",
                                                                   "Permissions:CheckResult");
     }
 
     @Override
     protected void init() {
         super.init();
@@ -542,16 +544,19 @@ public class SiteIdentityPopup extends A
         }
 
         if (lastVisibleDoorHanger != null) {
             lastVisibleDoorHanger.hideDivider();
         }
     }
 
     void destroy() {
+    }
+
+    void unregisterListeners() {
         GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
                                                                     "Doorhanger:Logins",
                                                                     "Permissions:CheckResult");
     }
 
     @Override
     public void dismiss() {
         super.dismiss();
--- a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java
@@ -163,16 +163,18 @@ public class ToolbarDisplayLayout extend
     }
 
     @Override
     public void onAttachedToWindow() {
         super.onAttachedToWindow();
 
         mIsAttached = true;
 
+        mSiteIdentityPopup.registerListeners();
+
         mSiteSecurity.setOnClickListener(new Button.OnClickListener() {
             @Override
             public void onClick(View view) {
                 mSiteIdentityPopup.show();
             }
         });
 
         mStop.setOnClickListener(new Button.OnClickListener() {
@@ -189,16 +191,17 @@ public class ToolbarDisplayLayout extend
             }
         });
     }
 
     @Override
     public void onDetachedFromWindow() {
         super.onDetachedFromWindow();
         mIsAttached = false;
+        mSiteIdentityPopup.unregisterListeners();
     }
 
     @Override
     public void setNextFocusDownId(int nextId) {
         mStop.setNextFocusDownId(nextId);
         mSiteSecurity.setNextFocusDownId(nextId);
         mPageActionLayout.setNextFocusDownId(nextId);
     }
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -124,16 +124,17 @@ gujar.sources += [geckoview_source_dir +
     'util/Clipboard.java',
     'util/ContextUtils.java',
     'util/DateUtil.java',
     'util/EventCallback.java',
     'util/FileUtils.java',
     'util/FloatUtils.java',
     'util/GamepadUtils.java',
     'util/GeckoBackgroundThread.java',
+    'util/GeckoBundle.java',
     'util/GeckoEventListener.java',
     'util/GeckoJarReader.java',
     'util/GeckoRequest.java',
     'util/HardwareCodecCapabilityUtils.java',
     'util/HardwareUtils.java',
     'util/INIParser.java',
     'util/INISection.java',
     'util/InputOptionsUtils.java',
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
@@ -1,19 +1,22 @@
 /* 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/. */
 
 package org.mozilla.gecko;
 
 import org.mozilla.gecko.annotation.ReflectionTarget;
 import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
 import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.mozglue.JNIObject;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.NativeEventListener;
 import org.mozilla.gecko.util.NativeJSContainer;
 import org.mozilla.gecko.util.NativeJSObject;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -22,53 +25,83 @@ import android.os.Bundle;
 import android.os.Handler;
 import android.util.Log;
 
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
 
 @RobocopTarget
-public final class EventDispatcher {
+public final class EventDispatcher extends JNIObject {
     private static final String LOGTAG = "GeckoEventDispatcher";
     /* package */ static final String GUID = "__guid__";
     private static final String STATUS_ERROR = "error";
     private static final String STATUS_SUCCESS = "success";
 
     private static final EventDispatcher INSTANCE = new EventDispatcher();
 
     /**
      * The capacity of a HashMap is rounded up to the next power-of-2. Every time the size
      * of the map goes beyond 75% of the capacity, the map is rehashed. Therefore, to
      * empirically determine the initial capacity that avoids rehashing, we need to
      * determine the initial size, divide it by 75%, and round up to the next power-of-2.
      */
     private static final int DEFAULT_GECKO_NATIVE_EVENTS_COUNT = 0; // Default for HashMap
     private static final int DEFAULT_GECKO_JSON_EVENTS_COUNT = 256; // Empirically measured
+    private static final int DEFAULT_GECKO_EVENTS_COUNT = 0; // Default for HashMap
     private static final int DEFAULT_UI_EVENTS_COUNT = 0; // Default for HashMap
     private static final int DEFAULT_BACKGROUND_EVENTS_COUNT = 0; // Default for HashMap
 
+    // Legacy events.
     private final Map<String, List<NativeEventListener>> mGeckoThreadNativeListeners =
         new HashMap<String, List<NativeEventListener>>(DEFAULT_GECKO_NATIVE_EVENTS_COUNT);
     private final Map<String, List<GeckoEventListener>> mGeckoThreadJSONListeners =
         new HashMap<String, List<GeckoEventListener>>(DEFAULT_GECKO_JSON_EVENTS_COUNT);
+
+    // GeckoBundle-based events.
+    private final Map<String, List<BundleEventListener>> mGeckoThreadListeners =
+        new HashMap<String, List<BundleEventListener>>(DEFAULT_GECKO_EVENTS_COUNT);
     private final Map<String, List<BundleEventListener>> mUiThreadListeners =
         new HashMap<String, List<BundleEventListener>>(DEFAULT_UI_EVENTS_COUNT);
     private final Map<String, List<BundleEventListener>> mBackgroundThreadListeners =
         new HashMap<String, List<BundleEventListener>>(DEFAULT_BACKGROUND_EVENTS_COUNT);
 
+    private boolean mAttachedToGecko;
+
     @ReflectionTarget
+    @WrapForJNI(calledFrom = "gecko")
     public static EventDispatcher getInstance() {
         return INSTANCE;
     }
 
-    public EventDispatcher() {
+    /* package */ EventDispatcher() {
+    }
+
+    @WrapForJNI(dispatchTo = "gecko") @Override // JNIObject
+    protected native void disposeNative();
+
+    @WrapForJNI private static final int DETACHED = 0;
+    @WrapForJNI private static final int ATTACHED = 1;
+    @WrapForJNI private static final int REATTACHING = 2;
+
+    @WrapForJNI(calledFrom = "gecko")
+    private synchronized void setAttachedToGecko(final int state) {
+        if (mAttachedToGecko && state == DETACHED) {
+            if (GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+                disposeNative();
+            } else {
+                GeckoThread.queueNativeCallUntil(GeckoThread.State.JNI_READY,
+                        this, "disposeNative");
+            }
+        }
+        mAttachedToGecko = (state == ATTACHED);
     }
 
     private <T> void registerListener(final Class<?> listType,
                                       final Map<String, List<T>> listenersMap,
                                       final T listener,
                                       final String[] events) {
         try {
             synchronized (listenersMap) {
@@ -96,16 +129,17 @@ public final class EventDispatcher {
                                              final String[] events) {
         if (AppConstants.RELEASE_OR_BETA) {
             // for performance reasons, we only check for
             // already-registered listeners in non-release builds.
             return;
         }
         for (final Map<String, ?> listenersMap : Arrays.asList(mGeckoThreadNativeListeners,
                                                                mGeckoThreadJSONListeners,
+                                                               mGeckoThreadListeners,
                                                                mUiThreadListeners,
                                                                mBackgroundThreadListeners)) {
             if (listenersMap == allowedMap) {
                 continue;
             }
             synchronized (listenersMap) {
                 for (final String event : events) {
                     if (listenersMap.get(event) != null) {
@@ -126,30 +160,39 @@ public final class EventDispatcher {
                 if ((listeners == null ||
                      !listeners.remove(listener)) && !AppConstants.RELEASE_OR_BETA) {
                     throw new IllegalArgumentException(event + " was not registered");
                 }
             }
         }
     }
 
-    public void registerGeckoThreadListener(final NativeEventListener listener,
+    public void registerGeckoThreadListener(final BundleEventListener listener,
                                             final String... events) {
-        checkNotRegisteredElsewhere(mGeckoThreadNativeListeners, events);
+        checkNotRegisteredElsewhere(mGeckoThreadListeners, events);
 
         // For listeners running on the Gecko thread, we want to notify the listeners
         // outside of our synchronized block, because the listeners may take an
         // indeterminate amount of time to run. Therefore, to ensure concurrency when
         // iterating the list outside of the synchronized block, we use a
         // CopyOnWriteArrayList.
         registerListener(CopyOnWriteArrayList.class,
+                         mGeckoThreadListeners, listener, events);
+    }
+
+    @Deprecated // Use BundleEventListener instead
+    public void registerGeckoThreadListener(final NativeEventListener listener,
+                                            final String... events) {
+        checkNotRegisteredElsewhere(mGeckoThreadNativeListeners, events);
+
+        registerListener(CopyOnWriteArrayList.class,
                          mGeckoThreadNativeListeners, listener, events);
     }
 
-    @Deprecated // Use NativeEventListener instead
+    @Deprecated // Use BundleEventListener instead
     public void registerGeckoThreadListener(final GeckoEventListener listener,
                                             final String... events) {
         checkNotRegisteredElsewhere(mGeckoThreadJSONListeners, events);
 
         registerListener(CopyOnWriteArrayList.class,
                          mGeckoThreadJSONListeners, listener, events);
     }
 
@@ -165,22 +208,28 @@ public final class EventDispatcher {
     public void registerBackgroundThreadListener(final BundleEventListener listener,
                                                  final String... events) {
         checkNotRegisteredElsewhere(mBackgroundThreadListeners, events);
 
         registerListener(ArrayList.class,
                          mBackgroundThreadListeners, listener, events);
     }
 
+    public void unregisterGeckoThreadListener(final BundleEventListener listener,
+                                              final String... events) {
+        unregisterListener(mGeckoThreadListeners, listener, events);
+    }
+
+    @Deprecated // Use BundleEventListener instead
     public void unregisterGeckoThreadListener(final NativeEventListener listener,
                                               final String... events) {
         unregisterListener(mGeckoThreadNativeListeners, listener, events);
     }
 
-    @Deprecated // Use NativeEventListener instead
+    @Deprecated // Use BundleEventListener instead
     public void unregisterGeckoThreadListener(final GeckoEventListener listener,
                                               final String... events) {
         unregisterListener(mGeckoThreadJSONListeners, listener, events);
     }
 
     public void unregisterUiThreadListener(final BundleEventListener listener,
                                            final String... events) {
         unregisterListener(mUiThreadListeners, listener, events);
@@ -261,69 +310,91 @@ public final class EventDispatcher {
             Log.e(LOGTAG, "Cannot parse JSON", e);
         } catch (final UnsupportedOperationException e) {
             Log.e(LOGTAG, "Cannot convert message to JSON", e);
         }
 
         return true;
     }
 
-    /**
-     * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners).
-     *
-     * @param message Bundle message with "type" value specifying the event type.
-     */
-    public void dispatch(final Bundle message) {
-        dispatch(message, /* callback */ null);
-    }
+    @WrapForJNI
+    private native boolean hasGeckoListener(final String event);
 
-    /**
-     * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners).
-     *
-     * @param message Bundle message with "type" value specifying the event type.
-     * @param callback Optional object for callbacks from events.
-     */
-    public void dispatch(final Bundle message, final EventCallback callback) {
-        if (message == null) {
-            throw new IllegalArgumentException("Null message");
-        }
-
-        final String type = message.getCharSequence("type").toString();
-        if (type == null) {
-            Log.e(LOGTAG, "Bundle message must have a type property");
-            return;
-        }
-        dispatchToThreads(type, /* js */ null, message, /* callback */ callback);
-    }
+    @WrapForJNI(dispatchTo = "gecko")
+    private native void dispatchToGecko(final String event, final GeckoBundle data,
+                                        final EventCallback callback);
 
     /**
      * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners).
      *
      * @param type Event type
      * @param message Bundle message
      */
-    public void dispatch(final String type, final Bundle message) {
+    public void dispatch(final String type, final GeckoBundle message) {
         dispatch(type, message, /* callback */ null);
     }
 
     /**
      * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners).
      *
      * @param type Event type
      * @param message Bundle message
      * @param callback Optional object for callbacks from events.
      */
-    public void dispatch(final String type, final Bundle message, final EventCallback callback) {
+    public void dispatch(final String type, final GeckoBundle message,
+                         final EventCallback callback) {
+        synchronized (this) {
+            if (mAttachedToGecko && hasGeckoListener(type)) {
+                dispatchToGecko(type, message, JavaCallbackDelegate.wrap(callback));
+                return;
+            }
+        }
+
         dispatchToThreads(type, /* js */ null, message, /* callback */ callback);
     }
 
+    @WrapForJNI(calledFrom = "gecko")
     private boolean dispatchToThreads(final String type,
                                       final NativeJSObject jsMessage,
-                                      final Bundle bundleMessage,
+                                      final GeckoBundle bundleMessage,
                                       final EventCallback callback) {
+        final List<BundleEventListener> geckoListeners;
+        synchronized (mGeckoThreadListeners) {
+            geckoListeners = mGeckoThreadListeners.get(type);
+        }
+        if (geckoListeners != null && !geckoListeners.isEmpty()) {
+            final boolean onGeckoThread = ThreadUtils.isOnGeckoThread();
+            final EventCallback wrappedCallback = JavaCallbackDelegate.wrap(callback);
+            final GeckoBundle messageAsBundle;
+            try {
+                messageAsBundle = jsMessage != null ?
+                        convertBundle(jsMessage.toBundle()) : bundleMessage;
+            } catch (final NativeJSObject.InvalidPropertyException e) {
+                Log.e(LOGTAG, "Exception occurred while handling " + type, e);
+                return true;
+            }
+
+            for (final BundleEventListener listener : geckoListeners) {
+                // For other threads, we always dispatch asynchronously. However, for
+                // Gecko listeners only, we dispatch synchronously if we're already on
+                // Gecko thread.
+                if (onGeckoThread) {
+                    listener.handleMessage(type, messageAsBundle, wrappedCallback);
+                    continue;
+                }
+                ThreadUtils.sGeckoHandler.post(new Runnable() {
+                    @Override
+                    public void run() {
+                        listener.handleMessage(type, messageAsBundle, wrappedCallback);
+                    }
+                });
+            }
+            return true;
+        }
+
         if (dispatchToThread(type, jsMessage, bundleMessage, callback,
                              mUiThreadListeners, ThreadUtils.getUiHandler())) {
             return true;
         }
 
         if (dispatchToThread(type, jsMessage, bundleMessage, callback,
                              mBackgroundThreadListeners, ThreadUtils.getBackgroundHandler())) {
             return true;
@@ -348,19 +419,49 @@ public final class EventDispatcher {
                 throw new IllegalStateException(
                         "Dispatching Bundle message to Gecko listener " + type);
             }
         }
 
         return false;
     }
 
+    // XXX: temporary helper for converting Bundle to GeckoBundle.
+    private GeckoBundle convertBundle(final Bundle bundle) {
+        if (bundle == null) {
+            return null;
+        }
+
+        final Set<String> keys = bundle.keySet();
+        final GeckoBundle out = new GeckoBundle(keys.size());
+
+        for (final String key : keys) {
+            final Object value = bundle.get(key);
+
+            if (value instanceof Bundle) {
+                out.putBundle(key, convertBundle((Bundle) value));
+
+            } else if (value instanceof Bundle[]) {
+                final Bundle[] inArray = (Bundle[]) value;
+                final GeckoBundle[] outArray = new GeckoBundle[inArray.length];
+                for (int i = 0; i < inArray.length; i++) {
+                    outArray[i] = convertBundle(inArray[i]);
+                }
+                out.putBundleArray(key, outArray);
+
+            } else {
+                out.put(key, value);
+            }
+        }
+        return out;
+    }
+
     private boolean dispatchToThread(final String type,
                                      final NativeJSObject jsMessage,
-                                     final Bundle bundleMessage,
+                                     final GeckoBundle bundleMessage,
                                      final EventCallback callback,
                                      final Map<String, List<BundleEventListener>> listenersMap,
                                      final Handler thread) {
         // We need to hold the lock throughout dispatching, to ensure the listeners list
         // is consistent, while we iterate over it. We don't have to worry about listeners
         // running for a long time while we have the lock, because the listeners will run
         // on a separate thread.
         synchronized (listenersMap) {
@@ -371,30 +472,34 @@ public final class EventDispatcher {
 
             if (listeners.isEmpty()) {
                 Log.w(LOGTAG, "No listeners for " + type + " in dispatchToThread");
 
                 // There were native listeners, and they're gone.
                 return false;
             }
 
-            final Bundle messageAsBundle;
+            final GeckoBundle messageAsBundle;
             try {
-                messageAsBundle = jsMessage != null ? jsMessage.toBundle() : bundleMessage;
+                messageAsBundle = jsMessage != null ?
+                        convertBundle(jsMessage.toBundle()) : bundleMessage;
             } catch (final NativeJSObject.InvalidPropertyException e) {
                 Log.e(LOGTAG, "Exception occurred while handling " + type, e);
                 return true;
             }
 
+            // Use a delegate to make sure callbacks happen on a specific thread.
+            final EventCallback wrappedCallback = JavaCallbackDelegate.wrap(callback);
+
             // Event listeners will call | callback.sendError | if applicable.
             for (final BundleEventListener listener : listeners) {
                 thread.post(new Runnable() {
                     @Override
                     public void run() {
-                        listener.handleMessage(type, messageAsBundle, callback);
+                        listener.handleMessage(type, messageAsBundle, wrappedCallback);
                     }
                 });
             }
             return true;
         }
     }
 
     public boolean dispatchEvent(final JSONObject message, final EventCallback callback) {
@@ -449,16 +554,99 @@ public final class EventDispatcher {
                 GeckoAppShell.notifyObservers(topic, wrapper.toString(),
                                               GeckoThread.State.PROFILE_READY);
             }
         } catch (final JSONException e) {
             Log.e(LOGTAG, "Unable to send response", e);
         }
     }
 
+    private static class NativeCallbackDelegate extends JNIObject implements EventCallback {
+        @WrapForJNI(calledFrom = "gecko")
+        private NativeCallbackDelegate() {
+        }
+
+        @Override // JNIObject
+        protected void disposeNative() {
+            // We dispose in finalize().
+            throw new UnsupportedOperationException();
+        }
+
+        @WrapForJNI(dispatchTo = "proxy") @Override // EventCallback
+        public native void sendSuccess(Object response);
+
+        @WrapForJNI(dispatchTo = "proxy") @Override // EventCallback
+        public native void sendError(Object response);
+
+        @WrapForJNI(dispatchTo = "gecko") @Override // Object
+        protected native void finalize();
+    }
+
+    private static class JavaCallbackDelegate implements EventCallback {
+        private final Thread originalThread = Thread.currentThread();
+        private final EventCallback callback;
+
+        public static EventCallback wrap(final EventCallback callback) {
+            if (callback == null) {
+                return null;
+            }
+            if (callback instanceof NativeCallbackDelegate) {
+                // NativeCallbackDelegate always posts to Gecko thread if needed.
+                return callback;
+            }
+            return new JavaCallbackDelegate(callback);
+        }
+
+        JavaCallbackDelegate(final EventCallback callback) {
+            this.callback = callback;
+        }
+
+        private void makeCallback(final boolean callSuccess, final Object response) {
+            // Call back synchronously if we happen to be on the same thread as the thread
+            // making the original request.
+            if (ThreadUtils.isOnThread(originalThread)) {
+                if (callSuccess) {
+                    callback.sendSuccess(response);
+                } else {
+                    callback.sendError(response);
+                }
+                return;
+            }
+
+            // Make callback on the thread of the original request, if the original thread
+            // is the UI or Gecko thread. Otherwise default to the background thread.
+            final Handler handler =
+                    originalThread == ThreadUtils.getUiThread() ? ThreadUtils.getUiHandler() :
+                    originalThread == ThreadUtils.sGeckoThread ? ThreadUtils.sGeckoHandler :
+                                                                 ThreadUtils.getBackgroundHandler();
+            final EventCallback callback = this.callback;
+
+            handler.post(new Runnable() {
+                @Override
+                public void run() {
+                    if (callSuccess) {
+                        callback.sendSuccess(response);
+                    } else {
+                        callback.sendError(response);
+                    }
+                }
+            });
+        }
+
+        @Override // EventCallback
+        public void sendSuccess(Object response) {
+            makeCallback(/* success */ true, response);
+        }
+
+        @Override // EventCallback
+        public void sendError(Object response) {
+            makeCallback(/* success */ false, response);
+        }
+    }
+
     /* package */ static class GeckoEventCallback implements EventCallback {
         private final String guid;
         private final String type;
         private boolean sent;
 
         public GeckoEventCallback(final String guid, final String type) {
             this.guid = guid;
             this.type = type;
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
@@ -3,28 +3,28 @@
  * 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/. */
 
 package org.mozilla.gecko;
 
 import android.app.Activity;
 import android.content.Context;
 import android.content.SharedPreferences;
-import android.os.Bundle;
 import android.support.annotation.WorkerThread;
 import android.text.TextUtils;
 import android.util.Log;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
 import org.mozilla.gecko.GeckoProfileDirectories.NoSuchProfileException;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.util.FileUtils;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.INIParser;
 import org.mozilla.gecko.util.INISection;
 import org.mozilla.gecko.util.IntentUtils;
 
 import java.io.BufferedWriter;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.FileReader;
@@ -989,14 +989,14 @@ public final class GeckoProfile {
      * such as adding default bookmarks.
      *
      * This is public for use *from tests only*!
      */
     @RobocopTarget
     public void enqueueInitialization(final File profileDir) {
         Log.i(LOGTAG, "Enqueuing profile init.");
 
-        final Bundle message = new Bundle(2);
-        message.putCharSequence("name", getName());
-        message.putCharSequence("path", profileDir.getAbsolutePath());
+        final GeckoBundle message = new GeckoBundle(2);
+        message.putString("name", getName());
+        message.putString("path", profileDir.getAbsolutePath());
         EventDispatcher.getInstance().dispatch("Profile:Create", message);
     }
 }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java
@@ -513,17 +513,16 @@ public class GeckoThread extends Thread 
         GeckoLoader.nativeRun(args);
 
         // And... we're done.
         setState(State.EXITED);
 
         try {
             final JSONObject msg = new JSONObject();
             msg.put("type", "Gecko:Exited");
-            GeckoAppShell.getGeckoInterface().getAppEventDispatcher().dispatchEvent(msg, null);
             EventDispatcher.getInstance().dispatchEvent(msg, null);
         } catch (final JSONException e) {
             Log.e(LOGTAG, "unable to dispatch event", e);
         }
 
         // Remove pumpMessageLoop() idle handler
         Looper.myQueue().removeIdleHandler(idleHandler);
     }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoView.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoView.java
@@ -38,16 +38,18 @@ import android.view.inputmethod.EditorIn
 import android.view.inputmethod.InputConnection;
 
 public class GeckoView extends LayerView
     implements ContextGetter, GeckoEventListener, NativeEventListener {
 
     private static final String DEFAULT_SHARED_PREFERENCES_FILE = "GeckoView";
     private static final String LOGTAG = "GeckoView";
 
+    private final EventDispatcher eventDispatcher = new EventDispatcher();
+
     private ChromeDelegate mChromeDelegate;
     private ContentDelegate mContentDelegate;
 
     private InputConnectionListener mInputConnectionListener;
 
     protected boolean onAttachedToWindowCalled;
     protected String chromeURI = getGeckoInterface().getDefaultChromeURI();
     protected int screenId = 0; // default to the primary screen
@@ -112,21 +114,21 @@ public class GeckoView extends LayerView
     }
 
     @WrapForJNI(dispatchTo = "proxy")
     protected static final class Window extends JNIObject {
         @WrapForJNI(skip = true)
         /* package */ Window() {}
 
         static native void open(Window instance, GeckoView view, Object compositor,
-                                String chromeURI, int screenId);
+                                EventDispatcher dispatcher, String chromeURI, int screenId);
 
         @Override protected native void disposeNative();
         native void close();
-        native void reattach(GeckoView view, Object compositor);
+        native void reattach(GeckoView view, Object compositor, EventDispatcher dispatcher);
         native void loadUri(String uri, int flags);
     }
 
     // Object to hold onto our nsWindow connection when GeckoView gets destroyed.
     private static class StateBinder extends Binder implements Parcelable {
         public final Parcelable superState;
         public final Window window;
 
@@ -192,17 +194,17 @@ public class GeckoView extends LayerView
         if (context instanceof Activity && getGeckoInterface() == null) {
             setGeckoInterface(new BaseGeckoInterface(context));
             GeckoAppShell.setContextGetter(this);
         }
 
         // Perform common initialization for Fennec/GeckoView.
         GeckoAppShell.setLayerView(this);
 
-        initializeView(EventDispatcher.getInstance());
+        initializeView();
     }
 
     @Override
     protected Parcelable onSaveInstanceState()
     {
         final Parcelable superState = super.onSaveInstanceState();
         stateSaved = true;
         return new StateBinder(superState, this.window);
@@ -225,31 +227,33 @@ public class GeckoView extends LayerView
         // We have to always call super.onRestoreInstanceState because View keeps
         // track of these calls and throws an exception when we don't call it.
         super.onRestoreInstanceState(stateBinder.superState);
     }
 
     protected void openWindow() {
 
         if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
-            Window.open(window, this, getCompositor(),
+            Window.open(window, this, getCompositor(), eventDispatcher,
                         chromeURI, screenId);
         } else {
             GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, Window.class,
                     "open", window, GeckoView.class, this, Object.class, getCompositor(),
+                    EventDispatcher.class, eventDispatcher,
                     String.class, chromeURI, screenId);
         }
     }
 
     protected void reattachWindow() {
         if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
-            window.reattach(this, getCompositor());
+            window.reattach(this, getCompositor(), eventDispatcher);
         } else {
             GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY,
-                    window, "reattach", GeckoView.class, this, Object.class, getCompositor());
+                    window, "reattach", GeckoView.class, this,
+                    Object.class, getCompositor(), EventDispatcher.class, eventDispatcher);
         }
     }
 
     @Override
     public void onAttachedToWindow()
     {
         final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics();
 
@@ -496,16 +500,20 @@ public class GeckoView extends LayerView
         return DEFAULT_SHARED_PREFERENCES_FILE;
     }
 
     @Override
     public SharedPreferences getSharedPreferences() {
         return getContext().getSharedPreferences(getSharedPreferencesFile(), 0);
     }
 
+    public EventDispatcher getEventDispatcher() {
+        return eventDispatcher;
+    }
+
     /**
     * Wrapper for a browser in the GeckoView container. Associated with a browser
     * element in the Gecko system.
     */
     public class Browser {
         private final int mId;
         private Browser(int Id) {
             mId = Id;
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoLayerClient.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoLayerClient.java
@@ -4,17 +4,16 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.gfx;
 
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.annotation.WrapForJNI;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.gfx.LayerView.DrawListener;
-import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.util.FloatUtils;
 import org.mozilla.gecko.AppConstants;
 
 import android.content.Context;
 import android.graphics.Color;
 import android.graphics.Matrix;
 import android.graphics.PointF;
 import android.graphics.RectF;
@@ -82,34 +81,34 @@ class GeckoLayerClient implements LayerV
      */
     private volatile boolean mContentDocumentIsDisplayed;
 
     private SynthesizedEventState mPointerState;
 
     @WrapForJNI(stubName = "ClearColor")
     private volatile int mClearColor = Color.WHITE;
 
-    public GeckoLayerClient(Context context, LayerView view, EventDispatcher eventDispatcher) {
+    public GeckoLayerClient(Context context, LayerView view) {
         // we can fill these in with dummy values because they are always written
         // to before being read
         mContext = context;
         mScreenSize = new IntSize(0, 0);
         mWindowSize = new IntSize(0, 0);
         mCurrentViewTransform = new ViewTransform(0, 0, 1);
 
         mForceRedraw = true;
         DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
         mViewportMetrics = new ImmutableViewportMetrics(displayMetrics)
                            .setViewportSize(view.getWidth(), view.getHeight());
 
         mFrameMetrics = mViewportMetrics;
 
         mDrawListeners = new ArrayList<DrawListener>();
         mToolbarAnimator = new DynamicToolbarAnimator(this);
-        mPanZoomController = PanZoomController.Factory.create(this, view, eventDispatcher);
+        mPanZoomController = PanZoomController.Factory.create(this, view);
         mView = view;
         mView.setListener(this);
         mContentDocumentIsDisplayed = true;
     }
 
     public void setOverscrollHandler(final Overscroll listener) {
         mPanZoomController.setOverscrollHandler(listener);
     }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerView.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerView.java
@@ -8,17 +8,16 @@ package org.mozilla.gecko.gfx;
 import java.nio.ByteBuffer;
 import java.nio.IntBuffer;
 
 import org.mozilla.gecko.AndroidGamepadManager;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.annotation.WrapForJNI;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.AppConstants.Versions;
-import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoAccessibility;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoThread;
 import org.mozilla.gecko.mozglue.JNIObject;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.content.Context;
 import android.graphics.Canvas;
@@ -162,18 +161,18 @@ public class LayerView extends FrameLayo
 
         mOverscroll = new OverscrollEdgeEffect(this);
     }
 
     public LayerView(Context context) {
         this(context, null);
     }
 
-    public void initializeView(EventDispatcher eventDispatcher) {
-        mLayerClient = new GeckoLayerClient(getContext(), this, eventDispatcher);
+    public void initializeView() {
+        mLayerClient = new GeckoLayerClient(getContext(), this);
         if (mOverscroll != null) {
             mLayerClient.setOverscrollHandler(mOverscroll);
         }
 
         mPanZoomController = mLayerClient.getPanZoomController();
         mToolbarAnimator = mLayerClient.getDynamicToolbarAnimator();
 
         mRenderer = new LayerRenderer(this);
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java
@@ -1,29 +1,28 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * 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/. */
 
 package org.mozilla.gecko.gfx;
 
 import org.mozilla.gecko.GeckoAppShell;
-import org.mozilla.gecko.EventDispatcher;
 
 import android.graphics.PointF;
 import android.view.KeyEvent;
 import android.view.MotionEvent;
 import android.view.View;
 
 public interface PanZoomController {
     // Threshold for sending touch move events to content
     public static final float CLICK_THRESHOLD = 1 / 50f * GeckoAppShell.getDpi();
 
     static class Factory {
-        static PanZoomController create(PanZoomTarget target, View view, EventDispatcher dispatcher) {
+        static PanZoomController create(PanZoomTarget target, View view) {
             return new NativePanZoomController(target, view);
         }
     }
 
     public void destroy();
     public void attach();
 
     public boolean onTouchEvent(MotionEvent event);
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java
@@ -2,24 +2,22 @@
  * 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/. */
 
 package org.mozilla.gecko.util;
 
 import org.mozilla.gecko.annotation.RobocopTarget;
 
-import android.os.Bundle;
-
 @RobocopTarget
 public interface BundleEventListener {
     /**
      * Handles a message sent from Gecko.
      *
      * @param event    The name of the event being sent.
      * @param message  The message data.
      * @param callback The callback interface for this message. A callback is provided only if the
      *                 originating Messaging.sendRequest call included a callback argument;
      *                 otherwise, callback will be null. All listeners for a given event are given
      *                 the same callback object, and exactly one listener must handle the callback.
      */
-    void handleMessage(String event, Bundle message, EventCallback callback);
+    void handleMessage(String event, GeckoBundle message, EventCallback callback);
 }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java
@@ -1,20 +1,22 @@
 package org.mozilla.gecko.util;
 
 import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
 
 /**
  * Callback interface for Gecko requests.
  *
  * For each instance of EventCallback, exactly one of sendResponse, sendError, or sendCancel
  * must be called to prevent observer leaks. If more than one send* method is called, or if a
  * single send method is called multiple times, an {@link IllegalStateException} will be thrown.
  */
 @RobocopTarget
+@WrapForJNI(calledFrom = "gecko")
 public interface EventCallback {
     /**
      * Sends a success response with the given data.
      *
      * @param response The response data to send to Gecko. Can be any of the types accepted by
      *                 JSONObject#put(String, Object).
      */
     public void sendSuccess(Object response);
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java
@@ -0,0 +1,470 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+import android.support.v4.util.SimpleArrayMap;
+
+import java.lang.reflect.Array;
+import java.util.Set;
+
+/**
+ * A lighter-weight version of Bundle that adds support for type coercion (e.g.
+ * int to double) in order to better cooperate with JS objects.
+ */
+@RobocopTarget
+public final class GeckoBundle {
+    private static final String LOGTAG = "GeckoBundle";
+    private static final boolean DEBUG = false;
+
+    @WrapForJNI(calledFrom = "gecko")
+    private static final boolean[] EMPTY_BOOLEAN_ARRAY = new boolean[0];
+    private static final int[] EMPTY_INT_ARRAY = new int[0];
+    private static final double[] EMPTY_DOUBLE_ARRAY = new double[0];
+    private static final String[] EMPTY_STRING_ARRAY = new String[0];
+    private static final GeckoBundle[] EMPTY_BUNDLE_ARRAY = new GeckoBundle[0];
+
+    @WrapForJNI(calledFrom = "gecko")
+    private static Object box(boolean b) { return b; }
+    @WrapForJNI(calledFrom = "gecko")
+    private static Object box(int i) { return i; }
+    @WrapForJNI(calledFrom = "gecko")
+    private static Object box(double d) { return d; }
+    @WrapForJNI(calledFrom = "gecko")
+    private static boolean unboxBoolean(Boolean b) { return b; }
+    @WrapForJNI(calledFrom = "gecko")
+    private static int unboxInteger(Integer i) { return i; }
+    @WrapForJNI(calledFrom = "gecko")
+    private static double unboxDouble(Double d) { return d; }
+
+    private SimpleArrayMap<String, Object> mMap;
+
+    /**
+     * Construct an empty GeckoBundle.
+     */
+    public GeckoBundle() {
+        mMap = new SimpleArrayMap<>();
+    }
+
+    /**
+     * Construct an empty GeckoBundle with specific capacity.
+     *
+     * @param capacity Initial capacity.
+     */
+    public GeckoBundle(final int capacity) {
+        mMap = new SimpleArrayMap<>(capacity);
+    }
+
+    /**
+     * Construct a copy of another GeckoBundle.
+     *
+     * @param bundle GeckoBundle to copy from.
+     */
+    public GeckoBundle(final GeckoBundle bundle) {
+        mMap = new SimpleArrayMap<>(bundle.mMap);
+    }
+
+    @WrapForJNI(calledFrom = "gecko")
+    private GeckoBundle(final String[] keys, final Object[] values) {
+        final int len = keys.length;
+        mMap = new SimpleArrayMap<>(len);
+        for (int i = 0; i < len; i++) {
+            mMap.put(keys[i], values[i]);
+        }
+    }
+
+    /**
+     * Clear all mappings.
+     */
+    public void clear() {
+        mMap.clear();
+    }
+
+    /**
+     * Returns whether a mapping exists.
+     *
+     * @param key Key to look for.
+     * @return True if the specified key exists.
+     */
+    public boolean containsKey(final String key) {
+        return mMap.containsKey(key) && mMap.get(key) != null;
+    }
+
+    /**
+     * Returns the value associated with a mapping as an Object.
+     *
+     * @param key Key to look for.
+     * @return Mapping value or null if the mapping does not exist.
+     */
+    public Object get(final String key) {
+        return mMap.get(key);
+    }
+
+    /**
+     * Returns the value associated with a boolean mapping, or defaultValue if the mapping
+     * does not exist.
+     *
+     * @param key Key to look for.
+     * @param defaultValue Value to return if mapping does not exist.
+     * @return Boolean value
+     */
+    public boolean getBoolean(final String key, final boolean defaultValue) {
+        final Object value = mMap.get(key);
+        return value == null ? defaultValue : (Boolean) value;
+    }
+
+    /**
+     * Returns the value associated with a boolean mapping, or false if the mapping does
+     * not exist.
+     *
+     * @param key Key to look for.
+     * @return Boolean value
+     */
+    public boolean getBoolean(final String key) {
+        return getBoolean(key, false);
+    }
+
+    /**
+     * Returns the value associated with a boolean array mapping, or null if the mapping
+     * does not exist.
+     *
+     * @param key Key to look for.
+     * @return Boolean array value
+     */
+    public boolean[] getBooleanArray(final String key) {
+        final Object value = mMap.get(key);
+        return Array.getLength(value) == 0 ? EMPTY_BOOLEAN_ARRAY : (boolean[]) value;
+    }
+
+    /**
+     * Returns the value associated with a double mapping, or defaultValue if the mapping
+     * does not exist.
+     *
+     * @param key Key to look for.
+     * @param defaultValue Value to return if mapping does not exist.
+     * @return Double value
+     */
+    public double getDouble(final String key, final double defaultValue) {
+        final Object value = mMap.get(key);
+        return value == null ? defaultValue : ((Number) value).doubleValue();
+    }
+
+    /**
+     * Returns the value associated with a double mapping, or 0.0 if the mapping does not
+     * exist.
+     *
+     * @param key Key to look for.
+     * @return Double value
+     */
+    public double getDouble(final String key) {
+        return getDouble(key, 0.0);
+    }
+
+    private double[] getDoubleArray(final int[] array) {
+        final int len = array.length;
+        final double[] ret = new double[len];
+        for (int i = 0; i < len; i++) {
+            ret[i] = (double) array[i];
+        }
+        return ret;
+    }
+
+    /**
+     * Returns the value associated with a double array mapping, or null if the mapping
+     * does not exist.
+     *
+     * @param key Key to look for.
+     * @return Double array value
+     */
+    public double[] getDoubleArray(final String key) {
+        final Object value = mMap.get(key);
+        return Array.getLength(value) == 0 ? EMPTY_DOUBLE_ARRAY :
+               value instanceof int[] ? getDoubleArray((int[]) value) : (double[]) value;
+    }
+
+    /**
+     * Returns the value associated with an int mapping, or defaultValue if the mapping
+     * does not exist.
+     *
+     * @param key Key to look for.
+     * @param defaultValue Value to return if mapping does not exist.
+     * @return Int value
+     */
+    public int getInt(final String key, final int defaultValue) {
+        final Object value = mMap.get(key);
+        return value == null ? defaultValue : ((Number) value).intValue();
+    }
+
+    /**
+     * Returns the value associated with an int mapping, or 0 if the mapping does not
+     * exist.
+     *
+     * @param key Key to look for.
+     * @return Int value
+     */
+    public int getInt(final String key) {
+        return getInt(key, 0);
+    }
+
+    private int[] getIntArray(final double[] array) {
+        final int len = array.length;
+        final int[] ret = new int[len];
+        for (int i = 0; i < len; i++) {
+            ret[i] = (int) array[i];
+        }
+        return ret;
+    }
+
+    /**
+     * Returns the value associated with an int array mapping, or null if the mapping does
+     * not exist.
+     *
+     * @param key Key to look for.
+     * @return Int array value
+     */
+    public int[] getIntArray(final String key) {
+        final Object value = mMap.get(key);
+        return Array.getLength(value) == 0 ? EMPTY_INT_ARRAY :
+               value instanceof double[] ? getIntArray((double[]) value) : (int[]) value;
+    }
+
+    /**
+     * Returns the value associated with a String mapping, or defaultValue if the mapping
+     * does not exist.
+     *
+     * @param key Key to look for.
+     * @param defaultValue Value to return if mapping does not exist.
+     * @return String value
+     */
+    public String getString(final String key, final String defaultValue) {
+        final Object value = mMap.get(key);
+        return value == null ? defaultValue : (String) value;
+    }
+
+    /**
+     * Returns the value associated with a String mapping, or null if the mapping does not
+     * exist.
+     *
+     * @param key Key to look for.
+     * @return String value
+     */
+    public String getString(final String key) {
+        return getString(key, null);
+    }
+
+    // The only case where we convert String[] to/from GeckoBundle[] is if every element
+    // is null.
+    private int getNullArrayLength(final Object array) {
+        final int len = Array.getLength(array);
+        for (int i = 0; i < len; i++) {
+            if (Array.get(array, i) != null) {
+                throw new ClassCastException("Cannot cast array type");
+            }
+        }
+        return len;
+    }
+
+    /**
+     * Returns the value associated with a String array mapping, or null if the mapping
+     * does not exist.
+     *
+     * @param key Key to look for.
+     * @return String array value
+     */
+    public String[] getStringArray(final String key) {
+        final Object value = mMap.get(key);
+        return Array.getLength(value) == 0 ? EMPTY_STRING_ARRAY :
+               !(value instanceof String[]) ? new String[getNullArrayLength(value)] :
+                                              (String[]) value;
+    }
+
+    /**
+     * Returns the value associated with a GeckoBundle mapping, or null if the mapping
+     * does not exist.
+     *
+     * @param key Key to look for.
+     * @return GeckoBundle value
+     */
+    public GeckoBundle getBundle(final String key) {
+        return (GeckoBundle) mMap.get(key);
+    }
+
+    /**
+     * Returns the value associated with a GeckoBundle array mapping, or null if the
+     * mapping does not exist.
+     *
+     * @param key Key to look for.
+     * @return GeckoBundle array value
+     */
+    public GeckoBundle[] getBundleArray(final String key) {
+        final Object value = mMap.get(key);
+        return Array.getLength(value) == 0 ? EMPTY_BUNDLE_ARRAY :
+               !(value instanceof GeckoBundle[]) ? new GeckoBundle[getNullArrayLength(value)] :
+                                                   (GeckoBundle[]) value;
+    }
+
+    /**
+     * Returns whether this GeckoBundle has no mappings.
+     *
+     * @return True if no mapping exists.
+     */
+    public boolean isEmpty() {
+        return mMap.isEmpty();
+    }
+
+    /**
+     * Returns an array of all mapped keys.
+     *
+     * @return String array containing all mapped keys.
+     */
+    @WrapForJNI(calledFrom = "gecko")
+    public String[] keys() {
+        final int len = mMap.size();
+        final String[] ret = new String[len];
+        for (int i = 0; i < len; i++) {
+            ret[i] = mMap.keyAt(i);
+        }
+        return ret;
+    }
+
+    @WrapForJNI(calledFrom = "gecko")
+    private Object[] values() {
+        final int len = mMap.size();
+        final Object[] ret = new Object[len];
+        for (int i = 0; i < len; i++) {
+            ret[i] = mMap.valueAt(i);
+        }
+        return ret;
+    }
+
+    /**
+     * @hide
+     * XXX: temporary helper for converting Bundle to GeckoBundle.
+     */
+    public void put(final String key, final Object value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to a boolean value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putBoolean(final String key, final boolean value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to a boolean array value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putBooleanArray(final String key, final boolean[] value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to a double value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putDouble(final String key, final double value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to a double array value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putDoubleArray(final String key, final double[] value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to an int value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putInt(final String key, final int value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to an int array value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putIntArray(final String key, final int[] value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to a String value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putString(final String key, final String value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to a String array value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putStringArray(final String key, final String[] value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to a GeckoBundle value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putBundle(final String key, final GeckoBundle value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Map a key to a GeckoBundle array value.
+     *
+     * @param key Key to map.
+     * @param value Value to map to.
+     */
+    public void putBundleArray(final String key, final GeckoBundle[] value) {
+        mMap.put(key, value);
+    }
+
+    /**
+     * Remove a mapping.
+     *
+     * @param key Key to remove.
+     */
+    public void remove(final String key) {
+        mMap.remove(key);
+    }
+
+    /**
+     * Returns number of mappings in this GeckoBundle.
+     *
+     * @return Number of mappings.
+     */
+    public int size() {
+        return mMap.size();
+    }
+}
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountDeviceRegistrator.java
@@ -1,17 +1,16 @@
 /* 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/. */
 
 package org.mozilla.gecko.fxa;
 
 import android.content.Context;
 import android.content.Intent;
-import android.os.Bundle;
 import android.support.annotation.Nullable;
 import android.text.TextUtils;
 import android.util.Log;
 
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountClient;
 import org.mozilla.gecko.background.fxa.FxAccountClient20;
 import org.mozilla.gecko.background.fxa.FxAccountClient20.AccountStatusResponse;
@@ -19,16 +18,17 @@ import org.mozilla.gecko.background.fxa.
 import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
 import org.mozilla.gecko.background.fxa.FxAccountRemoteError;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount.InvalidFxAState;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 
 import java.io.UnsupportedEncodingException;
 import java.lang.ref.WeakReference;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.security.GeneralSecurityException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -80,37 +80,37 @@ public class FxAccountDeviceRegistrator 
     geckoIntent.putExtra("data", "android-fxa-subscribe");
     final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
     geckoIntent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME", fxAccount.getProfile());
     context.startService(geckoIntent);
     // -> handleMessage()
   }
 
   @Override
-  public void handleMessage(String event, Bundle message, EventCallback callback) {
+  public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
     if ("FxAccountsPush:Subscribe:Response".equals(event)) {
       try {
         doFxaRegistration(message.getBundle("subscription"));
       } catch (InvalidFxAState e) {
         Log.d(LOG_TAG, "Invalid state when trying to register with FxA ", e);
       }
     } else {
       Log.e(LOG_TAG, "No action defined for " + event);
     }
   }
 
-  private void doFxaRegistration(Bundle subscription) throws InvalidFxAState {
+  private void doFxaRegistration(GeckoBundle subscription) throws InvalidFxAState {
     final Context context = this.context.get();
     if (this.context == null) {
       throw new IllegalStateException("Application context has been gc'ed");
     }
     doFxaRegistration(context, subscription, true);
   }
 
-  private static void doFxaRegistration(final Context context, final Bundle subscription, final boolean allowRecursion) throws InvalidFxAState {
+  private static void doFxaRegistration(final Context context, final GeckoBundle subscription, final boolean allowRecursion) throws InvalidFxAState {
     String pushCallback = subscription.getString("pushCallback");
     String pushPublicKey = subscription.getString("pushPublicKey");
     String pushAuthKey = subscription.getString("pushAuthKey");
 
     final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
     if (fxAccount == null) {
       Log.e(LOG_TAG, "AndroidFxAccount is null");
       return;
@@ -222,17 +222,17 @@ public class FxAccountDeviceRegistrator 
   /**
    * Will call delegate#complete in all cases
    */
   private static void recoverFromDeviceSessionConflict(final FxAccountClientRemoteException error,
                                                        final FxAccountClient fxAccountClient,
                                                        final byte[] sessionToken,
                                                        final AndroidFxAccount fxAccount,
                                                        final Context context,
-                                                       final Bundle subscription,
+                                                       final GeckoBundle subscription,
                                                        final boolean allowRecursion) {
     Log.w(LOG_TAG, "device session conflict, attempting to ascertain the correct device id");
     fxAccountClient.deviceList(sessionToken, new RequestDelegate<FxAccountDevice[]>() {
       private void onError() {
         Log.e(LOG_TAG, "failed to recover from device-session conflict");
         logErrorAndResetDeviceRegistrationVersion(error, fxAccount);
       }
 
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountPushHandler.java
@@ -1,34 +1,34 @@
 package org.mozilla.gecko.fxa;
 
 import android.accounts.Account;
 import android.accounts.AccountManager;
 import android.content.Context;
-import android.os.Bundle;
 import android.text.TextUtils;
 import android.util.Log;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.util.GeckoBundle;
 
 public class FxAccountPushHandler {
     private static final String LOG_TAG = "FxAccountPush";
 
     private static final String COMMAND_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected";
     private static final String COMMAND_COLLECTION_CHANGED = "sync:collection_changed";
 
     private static final String CLIENTS_COLLECTION = "clients";
 
     // Forbid instantiation
     private FxAccountPushHandler() {}
 
-    public static void handleFxAPushMessage(Context context, Bundle bundle) {
+    public static void handleFxAPushMessage(Context context, GeckoBundle bundle) {
         Log.i(LOG_TAG, "Handling FxA Push Message");
         String rawMessage = bundle.getString("message");
         JSONObject message = null;
         if (!TextUtils.isEmpty(rawMessage)) {
             try {
                 message = new JSONObject(rawMessage);
             } catch (JSONException e) {
                 Log.e(LOG_TAG, "Could not parse JSON", e);
--- a/mobile/android/tests/browser/robocop/robocop.ini
+++ b/mobile/android/tests/browser/robocop/robocop.ini
@@ -1,9 +1,9 @@
-[DEFAULT]
+[default]
 subsuite = robocop
 
 [src/org/mozilla/gecko/tests/testGeckoProfile.java]
 [src/org/mozilla/gecko/tests/testAboutPage.java]
 [src/org/mozilla/gecko/tests/testActivityStreamContextMenu.java]
 [src/org/mozilla/gecko/tests/testAddonManager.java]
 # disabled on 4.3, bug 1144918
 skip-if = android_version == "18"
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testEventDispatcher.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testEventDispatcher.java
@@ -2,18 +2,20 @@
  * 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/. */
 
 package org.mozilla.gecko.tests;
 
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.*;
 
 import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.NativeEventListener;
 import org.mozilla.gecko.util.NativeJSObject;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.os.Bundle;
 
 import org.json.JSONArray;
@@ -32,16 +34,18 @@ public class testEventDispatcher extends
     private static final String GECKO_RESPONSE_EVENT = "Robocop:TestGeckoResponse";
     private static final String NATIVE_EVENT = "Robocop:TestNativeEvent";
     private static final String NATIVE_RESPONSE_EVENT = "Robocop:TestNativeResponse";
     private static final String NATIVE_EXCEPTION_EVENT = "Robocop:TestNativeException";
     private static final String UI_EVENT = "Robocop:TestUIEvent";
     private static final String UI_RESPONSE_EVENT = "Robocop:TestUIResponse";
     private static final String BACKGROUND_EVENT = "Robocop:TestBackgroundEvent";
     private static final String BACKGROUND_RESPONSE_EVENT = "Robocop:TestBackgrondResponse";
+    private static final String JS_EVENT = "Robocop:TestJSEvent";
+    private static final String JS_RESPONSE_EVENT = "Robocop:TestJSResponse";
 
     private static final long WAIT_FOR_BUNDLE_EVENT_TIMEOUT_MILLIS = 20000; // 20 seconds
 
     private NativeJSObject savedMessage;
 
     private boolean handledGeckoEvent;
     private boolean handledNativeEvent;
     private boolean handledAsyncEvent;
@@ -50,33 +54,25 @@ public class testEventDispatcher extends
     public void setUp() throws Exception {
         super.setUp();
 
         EventDispatcher.getInstance().registerGeckoThreadListener(
                 (GeckoEventListener) this, GECKO_EVENT, GECKO_RESPONSE_EVENT);
         EventDispatcher.getInstance().registerGeckoThreadListener(
                 (NativeEventListener) this,
                 NATIVE_EVENT, NATIVE_RESPONSE_EVENT, NATIVE_EXCEPTION_EVENT);
-        EventDispatcher.getInstance().registerUiThreadListener(
-                this, UI_EVENT, UI_RESPONSE_EVENT);
-        EventDispatcher.getInstance().registerBackgroundThreadListener(
-                this, BACKGROUND_EVENT, BACKGROUND_RESPONSE_EVENT);
     }
 
     @Override
     public void tearDown() throws Exception {
         EventDispatcher.getInstance().unregisterGeckoThreadListener(
                 (GeckoEventListener) this, GECKO_EVENT, GECKO_RESPONSE_EVENT);
         EventDispatcher.getInstance().unregisterGeckoThreadListener(
                 (NativeEventListener) this,
                 NATIVE_EVENT, NATIVE_RESPONSE_EVENT, NATIVE_EXCEPTION_EVENT);
-        EventDispatcher.getInstance().unregisterUiThreadListener(
-                this, UI_EVENT, UI_RESPONSE_EVENT);
-        EventDispatcher.getInstance().unregisterBackgroundThreadListener(
-                this, BACKGROUND_EVENT, BACKGROUND_RESPONSE_EVENT);
 
         super.tearDown();
     }
 
     private synchronized void waitForAsyncEvent() {
         final long startTime = System.nanoTime();
         while (!handledAsyncEvent) {
             if (System.nanoTime() - startTime
@@ -109,55 +105,119 @@ public class testEventDispatcher extends
         getJS().syncCall("send_test_message", NATIVE_EVENT);
         fAssertTrue("Should have handled native event synchronously", handledNativeEvent);
 
         getJS().syncCall("send_message_for_response", NATIVE_RESPONSE_EVENT, "success");
         getJS().syncCall("send_message_for_response", NATIVE_RESPONSE_EVENT, "error");
 
         getJS().syncCall("send_test_message", NATIVE_EXCEPTION_EVENT);
 
-        getJS().syncCall("send_test_message", UI_EVENT);
-        waitForAsyncEvent();
-
-        getJS().syncCall("send_message_for_response", UI_RESPONSE_EVENT, "success");
-        waitForAsyncEvent();
-
-        getJS().syncCall("send_message_for_response", UI_RESPONSE_EVENT, "error");
-        waitForAsyncEvent();
+        // Test global EventDispatcher.
+        testScope("global");
 
-        getJS().syncCall("send_test_message", BACKGROUND_EVENT);
-        waitForAsyncEvent();
-
-        getJS().syncCall("send_message_for_response", BACKGROUND_RESPONSE_EVENT, "success");
-        waitForAsyncEvent();
-
-        getJS().syncCall("send_message_for_response", BACKGROUND_RESPONSE_EVENT, "error");
-        waitForAsyncEvent();
+        // Test GeckoView-specific EventDispatcher.
+        testScope("window");
 
         getJS().syncCall("finish_test");
     }
 
+    private static EventDispatcher getDispatcher(final String scope) {
+        if ("global".equals(scope)) {
+            return EventDispatcher.getInstance();
+        }
+        if ("window".equals(scope)) {
+            return GeckoApp.getEventDispatcher();
+        }
+        fFail("scope argument should be valid string");
+        return null;
+    }
+
+    private void testScope(final String scope) {
+        // Test UI thread events.
+        getDispatcher(scope).registerUiThreadListener(
+                this, UI_EVENT, UI_RESPONSE_EVENT);
+
+        testThreadEvents(scope, UI_EVENT, UI_RESPONSE_EVENT);
+
+        getDispatcher(scope).unregisterUiThreadListener(
+                this, UI_EVENT, UI_RESPONSE_EVENT);
+
+        // Test background thread events.
+        getDispatcher(scope).registerBackgroundThreadListener(
+                this, BACKGROUND_EVENT, BACKGROUND_RESPONSE_EVENT);
+
+        testThreadEvents(scope, BACKGROUND_EVENT, BACKGROUND_RESPONSE_EVENT);
+
+        getDispatcher(scope).unregisterBackgroundThreadListener(
+                this, BACKGROUND_EVENT, BACKGROUND_RESPONSE_EVENT);
+
+        // Test Gecko thread events in JS.
+        getJS().syncCall("register_js_events", scope, JS_EVENT, JS_RESPONSE_EVENT);
+
+        getJS().syncCall("dispatch_test_message", scope, JS_EVENT);
+        getJS().syncCall("dispatch_message_for_response", scope, JS_RESPONSE_EVENT, "success");
+        getJS().syncCall("dispatch_message_for_response", scope, JS_RESPONSE_EVENT, "error");
+
+        dispatchMessage(scope, JS_EVENT);
+        dispatchMessageForResponse(scope, JS_RESPONSE_EVENT, "success");
+        dispatchMessageForResponse(scope, JS_RESPONSE_EVENT, "error");
+
+        getJS().syncCall("unregister_js_events", scope, JS_EVENT, JS_RESPONSE_EVENT);
+    }
+
+    private void testThreadEvents(final String scope, final String event, final String responseEvent) {
+        getJS().syncCall("send_test_message", event);
+        waitForAsyncEvent();
+
+        getJS().syncCall("send_message_for_response", responseEvent, "success");
+        waitForAsyncEvent();
+
+        getJS().syncCall("send_message_for_response", responseEvent, "error");
+        waitForAsyncEvent();
+
+        getJS().syncCall("dispatch_test_message", scope, event);
+        waitForAsyncEvent();
+
+        getJS().syncCall("dispatch_message_for_response", scope, responseEvent, "success");
+        waitForAsyncEvent();
+
+        getJS().syncCall("dispatch_message_for_response", scope, responseEvent, "error");
+        waitForAsyncEvent();
+
+        dispatchMessage(scope, event);
+        waitForAsyncEvent();
+
+        dispatchMessageForResponse(scope, responseEvent, "success");
+        waitForAsyncEvent();
+
+        dispatchMessageForResponse(scope, responseEvent, "error");
+        waitForAsyncEvent();
+    }
+
     @Override
-    public void handleMessage(final String event, final Bundle message,
+    public void handleMessage(final String event, final GeckoBundle message,
                               final EventCallback callback) {
 
         if (UI_EVENT.equals(event) || UI_RESPONSE_EVENT.equals(event)) {
             fAssertTrue("UI event should be on UI thread", ThreadUtils.isOnUiThread());
 
         } else if (BACKGROUND_EVENT.equals(event) || BACKGROUND_RESPONSE_EVENT.equals(event)) {
             fAssertTrue("Background event should be on background thread",
                         ThreadUtils.isOnBackgroundThread());
 
         } else {
             fFail("Event type should be valid: " + event);
         }
 
         if (UI_EVENT.equals(event) || BACKGROUND_EVENT.equals(event)) {
             checkBundle(message);
             checkBundle(message.getBundle("object"));
+            fAssertSame("Bundle null object has correct value", null, message.getBundle("nullObject"));
+
+            // XXX add objectArray check when we remove NativeJSObject
 
         } else if (UI_RESPONSE_EVENT.equals(event) || BACKGROUND_RESPONSE_EVENT.equals(event)) {
             final String response = message.getString("response");
             if ("success".equals(response)) {
                 callback.sendSuccess(response);
             } else if ("error".equals(response)) {
                 callback.sendError(response);
             } else {
@@ -319,16 +379,72 @@ public class testEventDispatcher extends
 
         final String[] stringArray = bundle.getStringArray("stringArray");
         fAssertNotNull("Bundle string array should exist", stringArray);
         fAssertEquals("Bundle string array has correct length", 2, stringArray.length);
         fAssertEquals("Bundle string array index 0 has correct value", "bar", stringArray[0]);
         fAssertEquals("Bundle string array index 1 has correct value", "baz", stringArray[1]);
     }
 
+    private void checkBundle(final GeckoBundle bundle) {
+        fAssertEquals("Bundle boolean has correct value", true, bundle.getBoolean("boolean"));
+        fAssertEquals("Bundle int has correct value", 1, bundle.getInt("int"));
+        fAssertEquals("Bundle double has correct value", 0.5, bundle.getDouble("double"));
+        fAssertEquals("Bundle string has correct value", "foo", bundle.getString("string"));
+
+        fAssertSame("Bundle null string has correct value", null, bundle.getString("nullString"));
+        fAssertEquals("Bundle empty string has correct value", "", bundle.getString("emptyString"));
+        fAssertEquals("Bundle default null string is correct", "foo",
+                      bundle.getString("nullString", "foo"));
+        fAssertEquals("Bundle default empty string is correct", "",
+                      bundle.getString("emptyString", "foo"));
+
+        final boolean[] booleanArray = bundle.getBooleanArray("booleanArray");
+        fAssertNotNull("Bundle boolean array should exist", booleanArray);
+        fAssertEquals("Bundle boolean array has correct length", 2, booleanArray.length);
+        fAssertEquals("Bundle boolean array index 0 has correct value", false, booleanArray[0]);
+        fAssertEquals("Bundle boolean array index 1 has correct value", true, booleanArray[1]);
+
+        final int[] intArray = bundle.getIntArray("intArray");
+        fAssertNotNull("Bundle int array should exist", intArray);
+        fAssertEquals("Bundle int array has correct length", 2, intArray.length);
+        fAssertEquals("Bundle int array index 0 has correct value", 2, intArray[0]);
+        fAssertEquals("Bundle int array index 1 has correct value", 3, intArray[1]);
+
+        final double[] doubleArray = bundle.getDoubleArray("doubleArray");
+        fAssertNotNull("Bundle double array should exist", doubleArray);
+        fAssertEquals("Bundle double array has correct length", 2, doubleArray.length);
+        fAssertEquals("Bundle double array index 0 has correct value", 1.5, doubleArray[0]);
+        fAssertEquals("Bundle double array index 1 has correct value", 2.5, doubleArray[1]);
+
+        final String[] stringArray = bundle.getStringArray("stringArray");
+        fAssertNotNull("Bundle string array should exist", stringArray);
+        fAssertEquals("Bundle string array has correct length", 2, stringArray.length);
+        fAssertEquals("Bundle string array index 0 has correct value", "bar", stringArray[0]);
+        fAssertEquals("Bundle string array index 1 has correct value", "baz", stringArray[1]);
+
+        final boolean[] emptyBooleanArray = bundle.getBooleanArray("emptyBooleanArray");
+        fAssertNotNull("Bundle empty boolean array should exist", emptyBooleanArray);
+        fAssertEquals("Bundle empty boolean array has correct length", 0, emptyBooleanArray.length);
+
+        final int[] emptyIntArray = bundle.getIntArray("emptyIntArray");
+        fAssertNotNull("Bundle empty int array should exist", emptyIntArray);
+        fAssertEquals("Bundle empty int array has correct length", 0, emptyIntArray.length);
+
+        final double[] emptyDoubleArray = bundle.getDoubleArray("emptyDoubleArray");
+        fAssertNotNull("Bundle empty double array should exist", emptyDoubleArray);
+        fAssertEquals("Bundle empty double array has correct length", 0, emptyDoubleArray.length);
+
+        final String[] emptyStringArray = bundle.getStringArray("emptyStringArray");
+        fAssertNotNull("Bundle empty String array should exist", emptyStringArray);
+        fAssertEquals("Bundle empty String array has correct length", 0, emptyStringArray.length);
+
+        // XXX add mixedArray check when we remove NativeJSObject
+    }
+
     private void checkJSONObject(final JSONObject object) throws JSONException {
         fAssertEquals("JSON boolean has correct value", true, object.getBoolean("boolean"));
         fAssertEquals("JSON int has correct value", 1, object.getInt("int"));
         fAssertEquals("JSON double has correct value", 0.5, object.getDouble("double"));
         fAssertEquals("JSON string has correct value", "foo", object.getString("string"));
 
         final JSONArray booleanArray = object.getJSONArray("booleanArray");
         fAssertNotNull("JSON boolean array should exist", booleanArray);
@@ -442,9 +558,76 @@ public class testEventDispatcher extends
         fAssertEquals("Native optDouble returns fallback value if null",
             -3.1415926535, object.optDouble("null", -3.1415926535));
         fAssertEquals("Native optString returns fallback value if null",
             "baz", object.optString("null", "baz"));
 
         fAssertNotEquals("Native optString does not return fallback value if emptyString",
             "baz", object.optString("emptyString", "baz"));
     }
+
+    private static GeckoBundle createInnerBundle() {
+        final GeckoBundle bundle = new GeckoBundle();
+
+        bundle.putBoolean("boolean", true);
+        bundle.putBooleanArray("booleanArray", new boolean[] {false, true});
+
+        bundle.putInt("int", 1);
+        bundle.putIntArray("intArray", new int[] {2, 3});
+
+        bundle.putDouble("double", 0.5);
+        bundle.putDoubleArray("doubleArray", new double[] {1.5, 2.5});
+
+        bundle.putString("string", "foo");
+        bundle.putString("nullString", null);
+        bundle.putString("emptyString", "");
+        bundle.putStringArray("stringArray", new String[] {"bar", "baz"});
+
+        bundle.putBooleanArray("emptyBooleanArray", new boolean[0]);
+        bundle.putIntArray("emptyIntArray", new int[0]);
+        bundle.putDoubleArray("emptyDoubleArray", new double[0]);
+        bundle.putStringArray("emptyStringArray", new String[0]);
+
+        return bundle;
+    }
+
+    private static GeckoBundle createBundle() {
+        final GeckoBundle outer = createInnerBundle();
+        final GeckoBundle inner = createInnerBundle();
+
+        outer.putBundle("object", inner);
+        outer.putBundle("nullObject", null);
+        outer.putBundleArray("objectArray", new GeckoBundle[] {null, inner});
+        outer.putBundleArray("emptyObjectArray", new GeckoBundle[0]);
+
+        return outer;
+    }
+
+    public void dispatchMessage(final String scope, final String type) {
+        getDispatcher(scope).dispatch(type, createBundle());
+    }
+
+    public void dispatchMessageForResponse(final String scope, final String type,
+                                           final String response) {
+        final GeckoBundle bundle = new GeckoBundle(1);
+        bundle.putString("response", response);
+
+        getDispatcher(scope).dispatch(type, bundle, new EventCallback() {
+            @Override
+            public void sendSuccess(final Object result) {
+                // If the original request was on the UI thread, the response would happen
+                // on the UI thread as well. Otherwise, the response happens on the
+                // background thread. In this case, because the request was on the testing
+                // thread, the response thread defaults to the background thread.
+                fAssertTrue("JS success response should be on background thread",
+                            ThreadUtils.isOnBackgroundThread());
+                fAssertEquals("JS success response is correct", response, result);
+            }
+
+            @Override
+            public void sendError(final Object error) {
+                fAssertTrue("JS error response should be on background thread",
+                            ThreadUtils.isOnBackgroundThread());
+                fAssertEquals("JS error response is correct", response, error);
+            }
+        });
+    }
 }
--- a/mobile/android/tests/browser/robocop/testEventDispatcher.js
+++ b/mobile/android/tests/browser/robocop/testEventDispatcher.js
@@ -1,44 +1,168 @@
 Components.utils.import("resource://gre/modules/Messaging.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
 
 var java = new JavaBridge(this);
 
 do_register_cleanup(() => {
   java.disconnect();
 });
 do_test_pending();
 
-function send_test_message(type) {
+function get_test_message() {
   let innerObject = {
     boolean: true,
     booleanArray: [false, true],
     int: 1,
     intArray: [2, 3],
     double: 0.5,
     doubleArray: [1.5, 2.5],
     null: null,
+    string: "foo",
+    nullString: null,
     emptyString: "",
-    string: "foo",
     stringArray: ["bar", "baz"],
-  }
+    emptyBooleanArray: [],
+    emptyIntArray: [],
+    emptyDoubleArray: [],
+    emptyStringArray: [],
+    // XXX enable when we remove NativeJSObject
+    // mixedArray: [1, 1.5],
+  };
 
   // Make a copy
   let outerObject = JSON.parse(JSON.stringify(innerObject));
 
+  outerObject.object = innerObject;
+  outerObject.nullObject = null;
+  outerObject.objectArray = [null, innerObject];
+  outerObject.emptyObjectArray = [];
+  return outerObject;
+}
+
+function send_test_message(type) {
+  let outerObject = get_test_message();
   outerObject.type = type;
-  outerObject.object = innerObject;
-  outerObject.objectArray = [null, innerObject];
 
   Messaging.sendRequest(outerObject);
 }
 
+function get_dispatcher(scope) {
+  if (scope === 'global') {
+    return Services.androidBridge;
+  }
+  if (scope === 'window') {
+    let win = Services.wm.getMostRecentWindow("navigator:browser");
+    let view = win.arguments[0].QueryInterface(Components.interfaces.nsIAndroidView);
+    ok(view, "View object should exist in window arguments: " + view);
+    return view;
+  }
+  ok(false, "Invalid scope argument: " + scope);
+}
+
+function dispatch_test_message(scope, type) {
+  let data = get_test_message();
+  get_dispatcher(scope).dispatch(type, data);
+}
+
 function send_message_for_response(type, response) {
   Messaging.sendRequestForResult({
     type: type,
     response: response,
   }).then(result => do_check_eq(result, response),
           error => do_check_eq(error, response));
 }
 
+function dispatch_message_for_response(scope, type, response) {
+  get_dispatcher(scope).dispatch(type, {
+    response: response,
+  }, {
+    onSuccess: result => do_check_eq(result, response),
+    onError: error => do_check_eq(error, response),
+  });
+}
+
+let listener = {
+  _checkObject: function (obj) {
+    do_check_eq(obj.boolean, true);
+    do_check_eq(obj.booleanArray.length, 2);
+    do_check_eq(obj.booleanArray[0], false);
+    do_check_eq(obj.booleanArray[1], true);
+
+    do_check_eq(obj.int, 1);
+    do_check_eq(obj.intArray.length, 2);
+    do_check_eq(obj.intArray[0], 2);
+    do_check_eq(obj.intArray[1], 3);
+
+    do_check_eq(obj.double, 0.5);
+    do_check_eq(obj.doubleArray.length, 2);
+    do_check_eq(obj.doubleArray[0], 1.5);
+    do_check_eq(obj.doubleArray[1], 2.5);
+
+    do_check_eq(obj.string, "foo");
+    do_check_eq(obj.nullString, null);
+    do_check_eq(obj.emptyString, "");
+
+    do_check_eq(obj.stringArray.length, 2);
+    do_check_eq(obj.stringArray[0], "bar");
+    do_check_eq(obj.stringArray[1], "baz");
+
+    do_check_eq(obj.emptyBooleanArray.length, 0);
+    do_check_eq(obj.emptyIntArray.length, 0);
+    do_check_eq(obj.emptyDoubleArray.length, 0);
+    do_check_eq(obj.emptyStringArray.length, 0);
+  },
+
+  onEvent: function (event, data, callback) {
+    do_check_eq(event, this._type);
+    this._callCount++;
+
+    this._checkObject(data);
+
+    this._checkObject(data.object);
+    do_check_eq(data.nullObject, null);
+
+    do_check_eq(data.objectArray.length, 2);
+    do_check_eq(data.objectArray[0], null);
+    this._checkObject(data.objectArray[1]);
+    do_check_eq(data.emptyObjectArray.length, 0);
+  }
+};
+
+let callbackListener = {
+  onEvent: function (event, data, callback) {
+    do_check_eq(event, this._type);
+    this._callCount++;
+
+    if (data.response == "success") {
+      callback.onSuccess(data.response);
+    } else if (data.response == "error") {
+      callback.onError(data.response);
+    } else {
+      ok(false, "Response type should be valid: " + data.response);
+    }
+  }
+};
+
+function register_js_events(scope, type, callbackType) {
+  listener._type = type;
+  listener._callCount = 0;
+
+  callbackListener._type = callbackType;
+  callbackListener._callCount = 0;
+
+  get_dispatcher(scope).registerListener(listener, type);
+  get_dispatcher(scope).registerListener(callbackListener, callbackType);
+}
+
+function unregister_js_events(scope, type, callbackType) {
+  get_dispatcher(scope).unregisterListener(listener, type);
+  get_dispatcher(scope).unregisterListener(callbackListener, callbackType);
+
+  // Listeners should have been called 6 times total.
+  do_check_eq(listener._callCount, 2);
+  do_check_eq(callbackListener._callCount, 4);
+}
+
 function finish_test() {
   do_test_finished();
 }
--- a/widget/android/AndroidBridge.cpp
+++ b/widget/android/AndroidBridge.cpp
@@ -37,16 +37,17 @@
 #include "nsIDOMClientRect.h"
 #include "mozilla/ClearOnShutdown.h"
 #include "nsPrintfCString.h"
 #include "NativeJSContainer.h"
 #include "nsContentUtils.h"
 #include "nsIScriptError.h"
 #include "nsIHttpChannel.h"
 
+#include "EventDispatcher.h"
 #include "MediaCodec.h"
 #include "SurfaceTexture.h"
 #include "GLContextProvider.h"
 
 #include "mozilla/TimeStamp.h"
 #include "mozilla/UniquePtr.h"
 #include "mozilla/dom/ContentChild.h"
 #include "nsIObserverService.h"
@@ -728,20 +729,25 @@ AndroidBridge::GetGlobalContextRef() {
     }
 
     sGlobalContext = env->NewGlobalRef(appContext);
     MOZ_ASSERT(sGlobalContext);
     return sGlobalContext;
 }
 
 /* Implementation file */
-NS_IMPL_ISUPPORTS(nsAndroidBridge, nsIAndroidBridge)
+NS_IMPL_ISUPPORTS(nsAndroidBridge, nsIAndroidEventDispatcher, nsIAndroidBridge)
 
 nsAndroidBridge::nsAndroidBridge()
 {
+  RefPtr<widget::EventDispatcher> dispatcher = new widget::EventDispatcher();
+  dispatcher->Attach(java::EventDispatcher::GetInstance(),
+                     /* window */ nullptr);
+  mEventDispatcher = dispatcher;
+
   AddObservers();
 }
 
 nsAndroidBridge::~nsAndroidBridge()
 {
   RemoveObservers();
 }
 
--- a/widget/android/AndroidBridge.h
+++ b/widget/android/AndroidBridge.h
@@ -398,24 +398,27 @@ private:
 class nsAndroidBridge final : public nsIAndroidBridge,
                               public nsIObserver
 {
 public:
   NS_DECL_ISUPPORTS
   NS_DECL_NSIANDROIDBRIDGE
   NS_DECL_NSIOBSERVER
 
+  NS_FORWARD_NSIANDROIDEVENTDISPATCHER(mEventDispatcher->)
+
   nsAndroidBridge();
 
 private:
   ~nsAndroidBridge();
 
   void AddObservers();
   void RemoveObservers();
 
   void UpdateAudioPlayingWindows(uint64_t aWindowId, bool aPlaying);
 
   nsTArray<uint64_t> mAudioPlayingWindows;
+  nsCOMPtr<nsIAndroidEventDispatcher> mEventDispatcher;
 
 protected:
 };
 
 #endif /* AndroidBridge_h__ */
new file mode 100644
--- /dev/null
+++ b/widget/android/EventDispatcher.cpp
@@ -0,0 +1,938 @@
+/* -*- Mode: c++; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: set sw=4 ts=4 expandtab:
+ * 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/. */
+
+#include "EventDispatcher.h"
+
+#include "nsAppShell.h"
+#include "nsIXPConnect.h"
+#include "nsJSUtils.h"
+#include "xpcpublic.h"
+
+#include "mozilla/ScopeExit.h"
+#include "mozilla/dom/ScriptSettings.h"
+
+namespace mozilla {
+namespace widget {
+
+namespace detail {
+
+bool
+CheckJS(JSContext* aCx, bool aResult)
+{
+    if (!aResult) {
+        JS_ClearPendingException(aCx);
+    }
+    return aResult;
+}
+
+nsresult
+BoxString(JSContext* aCx, JS::HandleValue aData, jni::Object::LocalRef& aOut)
+{
+    if (aData.isNullOrUndefined()) {
+        aOut = nullptr;
+        return NS_OK;
+    }
+
+    MOZ_ASSERT(aData.isString());
+
+    JS::RootedString str(aCx, aData.toString());
+
+    if (JS_StringHasLatin1Chars(str)) {
+        nsAutoJSString autoStr;
+        NS_ENSURE_TRUE(CheckJS(aCx, autoStr.init(aCx, str)), NS_ERROR_FAILURE);
+
+        // StringParam can automatically convert a nsString to jstring.
+        aOut = jni::StringParam(autoStr, aOut.Env());
+        return NS_OK;
+    }
+
+    // Two-byte string
+    JNIEnv* const env = aOut.Env();
+    const char16_t* chars;
+    {
+        JS::AutoCheckCannotGC nogc;
+        size_t len = 0;
+        chars = JS_GetTwoByteStringCharsAndLength(aCx, nogc, str, &len);
+        if (chars) {
+            aOut = jni::String::LocalRef::Adopt(env,
+                    env->NewString(reinterpret_cast<const jchar*>(chars), len));
+        }
+    }
+    if (NS_WARN_IF(!CheckJS(aCx, !!chars) || !aOut)) {
+        env->ExceptionClear();
+        return NS_ERROR_FAILURE;
+    }
+    return NS_OK;
+}
+
+nsresult
+BoxObject(JSContext* aCx, JS::HandleValue aData, jni::Object::LocalRef& aOut);
+
+template<typename Type,
+         bool (JS::Value::*IsType)() const,
+         Type (JS::Value::*ToType)() const,
+         class ArrayType,
+         typename ArrayType::LocalRef (*NewArray)(const Type*, size_t)> nsresult
+BoxArrayPrimitive(JSContext* aCx, JS::HandleObject aData,
+                  jni::Object::LocalRef& aOut, size_t aLength,
+                  JS::HandleValue aElement)
+{
+    JS::RootedValue element(aCx);
+    auto data = MakeUnique<Type[]>(aLength);
+    data[0] = (aElement.get().*ToType)();
+
+    for (size_t i = 1; i < aLength; i++) {
+        NS_ENSURE_TRUE(CheckJS(aCx, JS_GetElement(aCx, aData, i, &element)),
+                       NS_ERROR_FAILURE);
+        NS_ENSURE_TRUE((element.get().*IsType)(), NS_ERROR_INVALID_ARG);
+
+        data[i] = (element.get().*ToType)();
+    }
+    aOut = (*NewArray)(data.get(), aLength);
+    return NS_OK;
+}
+
+template<bool (JS::Value::*IsType)() const,
+         class Type,
+         nsresult (*Box)(JSContext*, JS::HandleValue, jni::Object::LocalRef&)>
+nsresult
+BoxArrayObject(JSContext* aCx, JS::HandleObject aData,
+               jni::Object::LocalRef& aOut, size_t aLength,
+               JS::HandleValue aElement)
+{
+    auto out = jni::ObjectArray::New<Type>(aLength);
+    JS::RootedValue element(aCx);
+    jni::Object::LocalRef jniElement(aOut.Env());
+
+    nsresult rv = (*Box)(aCx, aElement, jniElement);
+    NS_ENSURE_SUCCESS(rv, rv);
+    out->SetElement(0, jniElement);
+
+    for (size_t i = 1; i < aLength; i++) {
+        NS_ENSURE_TRUE(CheckJS(aCx, JS_GetElement(aCx, aData, i, &element)),
+                       NS_ERROR_FAILURE);
+        NS_ENSURE_TRUE((element.get().*IsType)() || element.isNullOrUndefined(),
+                       NS_ERROR_INVALID_ARG);
+
+        rv = (*Box)(aCx, element, jniElement);
+        NS_ENSURE_SUCCESS(rv, rv);
+        out->SetElement(i, jniElement);
+    }
+    aOut = out;
+    return NS_OK;
+}
+
+nsresult
+BoxArray(JSContext* aCx, JS::HandleObject aData, jni::Object::LocalRef& aOut)
+{
+    uint32_t length = 0;
+    NS_ENSURE_TRUE(CheckJS(aCx, JS_GetArrayLength(aCx, aData, &length)),
+                   NS_ERROR_FAILURE);
+
+    if (!length) {
+        // Always represent empty arrays as an empty boolean array.
+        aOut = java::GeckoBundle::EMPTY_BOOLEAN_ARRAY();
+        return NS_OK;
+    }
+
+    // We only check the first element's type. If the array has mixed types,
+    // we'll throw an error during actual conversion.
+    JS::RootedValue element(aCx);
+    NS_ENSURE_TRUE(CheckJS(aCx, JS_GetElement(aCx, aData, 0, &element)),
+                   NS_ERROR_FAILURE);
+
+    if (element.isBoolean()) {
+        return BoxArrayPrimitive<bool, &JS::Value::isBoolean, &JS::Value::toBoolean,
+                                 jni::BooleanArray, &jni::BooleanArray::New>(
+                aCx, aData, aOut, length, element);
+    }
+
+    if (element.isInt32()) {
+        nsresult rv = BoxArrayPrimitive<
+                int32_t, &JS::Value::isInt32, &JS::Value::toInt32,
+                jni::IntArray, &jni::IntArray::New>(aCx, aData, aOut,
+                                                    length, element);
+        if (rv != NS_ERROR_INVALID_ARG) {
+            return rv;
+        }
+        // Not int32, but we can still try a double array.
+    }
+
+    if (element.isNumber()) {
+        return BoxArrayPrimitive<
+                double, &JS::Value::isNumber, &JS::Value::toNumber,
+                jni::DoubleArray, &jni::DoubleArray::New>(aCx, aData, aOut,
+                                                          length, element);
+    }
+
+    if (element.isString() || element.isNullOrUndefined()) {
+        nsresult rv = BoxArrayObject<&JS::Value::isString,
+                                     jni::String, &BoxString>(
+                aCx, aData, aOut, length, element);
+        if (element.isString() || rv != NS_ERROR_INVALID_ARG) {
+            return rv;
+        }
+        // First element was null/undefined, so it may still be an object array.
+    }
+
+    if (element.isObject() || element.isNullOrUndefined()) {
+        return BoxArrayObject<&JS::Value::isObject, jni::Object, &BoxObject>(
+                aCx, aData, aOut, length, element);
+    }
+
+    NS_WARNING("Unknown type");
+    return NS_ERROR_INVALID_ARG;
+}
+
+nsresult
+BoxValue(JSContext* aCx, JS::HandleValue aData, jni::Object::LocalRef& aOut);
+
+nsresult
+BoxObject(JSContext* aCx, JS::HandleValue aData, jni::Object::LocalRef& aOut)
+{
+    if (aData.isNullOrUndefined()) {
+        aOut = nullptr;
+        return NS_OK;
+    }
+
+    MOZ_ASSERT(aData.isObject());
+
+    JS::Rooted<JS::IdVector> ids(aCx, JS::IdVector(aCx));
+    JS::RootedObject obj(aCx, &aData.toObject());
+
+    bool isArray = false;
+    if (CheckJS(aCx, JS_IsArrayObject(aCx, obj, &isArray)) && isArray) {
+        return BoxArray(aCx, obj, aOut);
+    }
+
+    NS_ENSURE_TRUE(CheckJS(aCx, JS_Enumerate(aCx, obj, &ids)),
+                   NS_ERROR_FAILURE);
+
+    const size_t length = ids.length();
+    auto keys = jni::ObjectArray::New<jni::String>(length);
+    auto values = jni::ObjectArray::New<jni::Object>(length);
+
+    // Iterate through each property of the JS object.
+    for (size_t i = 0; i < ids.length(); i++) {
+        const JS::RootedId id(aCx, ids[i]);
+        JS::RootedValue idVal(aCx);
+        JS::RootedValue val(aCx);
+        jni::Object::LocalRef key(aOut.Env());
+        jni::Object::LocalRef value(aOut.Env());
+
+        NS_ENSURE_TRUE(CheckJS(aCx, JS_IdToValue(aCx, id, &idVal)),
+                       NS_ERROR_FAILURE);
+        NS_ENSURE_TRUE(idVal.isString(), NS_ERROR_FAILURE);
+        NS_ENSURE_SUCCESS(BoxString(aCx, idVal, key), NS_ERROR_FAILURE);
+        NS_ENSURE_TRUE(CheckJS(aCx, JS_GetPropertyById(aCx, obj, id, &val)),
+                       NS_ERROR_FAILURE);
+
+        nsresult rv = BoxValue(aCx, val, value);
+        if (rv == NS_ERROR_INVALID_ARG && !JS_IsExceptionPending(aCx)) {
+            nsAutoJSString autoStr;
+            if (CheckJS(aCx, autoStr.init(aCx, idVal.toString()))) {
+                JS_ReportErrorUTF8(
+                        aCx, u8"Invalid event data property %s",
+                        NS_ConvertUTF16toUTF8(autoStr).get());
+            }
+        }
+        NS_ENSURE_SUCCESS(rv, rv);
+
+        keys->SetElement(i, key);
+        values->SetElement(i, value);
+    }
+
+    aOut = java::GeckoBundle::New(keys, values);
+    return NS_OK;
+}
+
+nsresult
+BoxValue(JSContext* aCx, JS::HandleValue aData, jni::Object::LocalRef& aOut)
+{
+    if (aData.isNullOrUndefined()) {
+        aOut = nullptr;
+    } else if (aData.isBoolean()) {
+        aOut = java::GeckoBundle::Box(aData.toBoolean());
+    } else if (aData.isInt32()) {
+        aOut = java::GeckoBundle::Box(aData.toInt32());
+    } else if (aData.isNumber()) {
+        aOut = java::GeckoBundle::Box(aData.toNumber());
+    } else if (aData.isString()) {
+        return BoxString(aCx, aData, aOut);
+    } else if (aData.isObject()) {
+        return BoxObject(aCx, aData, aOut);
+    } else {
+        NS_WARNING("Unknown type");
+        return NS_ERROR_INVALID_ARG;
+    }
+    return NS_OK;
+}
+
+nsresult
+BoxData(const nsAString& aEvent, JSContext* aCx, JS::HandleValue aData,
+        jni::Object::LocalRef& aOut, bool aObjectOnly)
+{
+    nsresult rv = NS_ERROR_INVALID_ARG;
+
+    if (!aObjectOnly) {
+        rv = BoxValue(aCx, aData, aOut);
+    } else if (aData.isObject() || aData.isNullOrUndefined()) {
+        rv = BoxObject(aCx, aData, aOut);
+    }
+    if (rv != NS_ERROR_INVALID_ARG) {
+        return rv;
+    }
+
+    NS_ConvertUTF16toUTF8 event(aEvent);
+    if (JS_IsExceptionPending(aCx)) {
+        JS_ReportWarningUTF8(aCx, "Error dispatching %s", event.get());
+    } else {
+        JS_ReportErrorUTF8(aCx, "Invalid event data for %s", event.get());
+    }
+    return NS_ERROR_INVALID_ARG;
+}
+
+nsresult
+UnboxString(JSContext* aCx, const jni::Object::LocalRef& aData,
+            JS::MutableHandleValue aOut)
+{
+    if (!aData) {
+        aOut.setNull();
+        return NS_OK;
+    }
+
+    MOZ_ASSERT(aData.IsInstanceOf<jni::String>());
+
+    JNIEnv* const env = aData.Env();
+    const jstring jstr = jstring(aData.Get());
+    const size_t len = env->GetStringLength(jstr);
+    const jchar* const jchars = env->GetStringChars(jstr, nullptr);
+
+    if (NS_WARN_IF(!jchars)) {
+        env->ExceptionClear();
+        return NS_ERROR_FAILURE;
+    }
+
+    auto releaseStr = MakeScopeExit([env, jstr, jchars] {
+        env->ReleaseStringChars(jstr, jchars);
+        env->ExceptionClear();
+    });
+
+    JS::RootedString str(aCx, JS_NewUCStringCopyN(
+            aCx, reinterpret_cast<const char16_t*>(jchars), len));
+    NS_ENSURE_TRUE(CheckJS(aCx, !!str), NS_ERROR_FAILURE);
+
+    aOut.setString(str);
+    return NS_OK;
+}
+
+nsresult
+UnboxValue(JSContext* aCx, const jni::Object::LocalRef& aData,
+           JS::MutableHandleValue aOut);
+
+nsresult
+UnboxBundle(JSContext* aCx, const jni::Object::LocalRef& aData,
+            JS::MutableHandleValue aOut)
+{
+    if (!aData) {
+        aOut.setNull();
+        return NS_OK;
+    }
+
+    MOZ_ASSERT(aData.IsInstanceOf<java::GeckoBundle>());
+
+    JNIEnv* const env = aData.Env();
+    const auto& bundle = java::GeckoBundle::Ref::From(aData);
+    jni::ObjectArray::LocalRef keys = bundle->Keys();
+    jni::ObjectArray::LocalRef values = bundle->Values();
+    const size_t len = keys->Length();
+    JS::RootedObject obj(aCx, JS_NewPlainObject(aCx));
+
+    NS_ENSURE_TRUE(CheckJS(aCx, !!obj), NS_ERROR_FAILURE);
+    NS_ENSURE_TRUE(values->Length() == len, NS_ERROR_FAILURE);
+
+    for (size_t i = 0; i < len; i++) {
+        jni::String::LocalRef key = keys->GetElement(i);
+        const size_t keyLen = env->GetStringLength(key.Get());
+        const jchar* const keyChars = env->GetStringChars(key.Get(), nullptr);
+        if (NS_WARN_IF(!keyChars)) {
+            env->ExceptionClear();
+            return NS_ERROR_FAILURE;
+        }
+
+        auto releaseKeyChars = MakeScopeExit([env, &key, keyChars] {
+            env->ReleaseStringChars(key.Get(), keyChars);
+            env->ExceptionClear();
+        });
+
+        JS::RootedValue value(aCx);
+        nsresult rv = UnboxValue(aCx, values->GetElement(i), &value);
+        if (rv == NS_ERROR_INVALID_ARG && !JS_IsExceptionPending(aCx)) {
+            JS_ReportErrorUTF8(
+                    aCx, u8"Invalid event data property %s",
+                    NS_ConvertUTF16toUTF8(nsString(reinterpret_cast<
+                            const char16_t*>(keyChars), keyLen)).get());
+        }
+        NS_ENSURE_SUCCESS(rv, rv);
+
+        NS_ENSURE_TRUE(CheckJS(aCx, JS_SetUCProperty(aCx, obj, reinterpret_cast<
+                               const char16_t*>(keyChars), keyLen, value)),
+                       NS_ERROR_FAILURE);
+    }
+
+    aOut.setObject(*obj);
+    return NS_OK;
+}
+
+template<typename Type, typename JNIType, typename ArrayType,
+         JNIType* (JNIEnv::*GetElements)(ArrayType, jboolean*),
+         void (JNIEnv::*ReleaseElements)(ArrayType, JNIType*, jint),
+         JS::Value (*ToValue)(Type)> nsresult
+UnboxArrayPrimitive(JSContext* aCx, const jni::Object::LocalRef& aData,
+                    JS::MutableHandleValue aOut)
+{
+    JNIEnv* const env = aData.Env();
+    const ArrayType jarray = ArrayType(aData.Get());
+    JNIType* const array = (env->*GetElements)(jarray, nullptr);
+    JS::AutoValueVector elements(aCx);
+
+    if (NS_WARN_IF(!array)) {
+        env->ExceptionClear();
+        return NS_ERROR_FAILURE;
+    }
+
+    auto releaseArray = MakeScopeExit([env, jarray, array] {
+        (env->*ReleaseElements)(jarray, array, JNI_ABORT);
+        env->ExceptionClear();
+    });
+
+    const size_t len = env->GetArrayLength(jarray);
+    elements.initCapacity(len);
+
+    for (size_t i = 0; i < len; i++) {
+        NS_ENSURE_TRUE(elements.append((*ToValue)(Type(array[i]))),
+                       NS_ERROR_FAILURE);
+    }
+
+    JS::RootedObject obj(aCx, JS_NewArrayObject(
+            aCx, JS::HandleValueArray(elements)));
+    NS_ENSURE_TRUE(CheckJS(aCx, !!obj), NS_ERROR_FAILURE);
+
+    aOut.setObject(*obj);
+    return NS_OK;
+}
+
+struct StringArray : jni::ObjectBase<StringArray>
+{
+    static const char name[];
+};
+
+struct GeckoBundleArray : jni::ObjectBase<GeckoBundleArray>
+{
+    static const char name[];
+};
+
+const char StringArray::name[] = "[Ljava/lang/String;";
+const char GeckoBundleArray::name[] = "[Lorg/mozilla/gecko/util/GeckoBundle;";
+
+template<nsresult (*Unbox)(JSContext*, const jni::Object::LocalRef&,
+                           JS::MutableHandleValue)> nsresult
+UnboxArrayObject(JSContext* aCx, const jni::Object::LocalRef& aData,
+                 JS::MutableHandleValue aOut)
+{
+    jni::ObjectArray::LocalRef array(
+            aData.Env(), jni::ObjectArray::Ref::From(aData));
+    const size_t len = array->Length();
+    JS::RootedObject obj(aCx, JS_NewArrayObject(aCx, len));
+    NS_ENSURE_TRUE(CheckJS(aCx, !!obj), NS_ERROR_FAILURE);
+
+    for (size_t i = 0; i < len; i++) {
+        jni::Object::LocalRef element = array->GetElement(i);
+        JS::RootedValue value(aCx);
+        nsresult rv = (*Unbox)(aCx, element, &value);
+        NS_ENSURE_SUCCESS(rv, rv);
+
+        NS_ENSURE_TRUE(CheckJS(aCx, JS_SetElement(aCx, obj, i, value)),
+                       NS_ERROR_FAILURE);
+    }
+
+    aOut.setObject(*obj);
+    return NS_OK;
+}
+
+nsresult
+UnboxValue(JSContext* aCx, const jni::Object::LocalRef& aData,
+           JS::MutableHandleValue aOut)
+{
+    if (!aData) {
+        aOut.setNull();
+    } else if (aData.IsInstanceOf<jni::Boolean>()) {
+        aOut.setBoolean(java::GeckoBundle::UnboxBoolean(aData));
+    } else if (aData.IsInstanceOf<jni::Integer>()) {
+        aOut.setInt32(java::GeckoBundle::UnboxInteger(aData));
+    } else if (aData.IsInstanceOf<jni::Double>()) {
+        aOut.setNumber(java::GeckoBundle::UnboxDouble(aData));
+    } else if (aData.IsInstanceOf<jni::String>()) {
+        return UnboxString(aCx, aData, aOut);
+    } else if (aData.IsInstanceOf<java::GeckoBundle>()) {
+        return UnboxBundle(aCx, aData, aOut);
+
+    } else if (aData.IsInstanceOf<jni::BooleanArray>()) {
+        return UnboxArrayPrimitive<bool, jboolean, jbooleanArray,
+                &JNIEnv::GetBooleanArrayElements,
+                &JNIEnv::ReleaseBooleanArrayElements,
+                &JS::BooleanValue>(aCx, aData, aOut);
+
+    } else if (aData.IsInstanceOf<jni::IntArray>()) {
+        return UnboxArrayPrimitive<int32_t, jint, jintArray,
+                &JNIEnv::GetIntArrayElements,
+                &JNIEnv::ReleaseIntArrayElements,
+                &JS::Int32Value>(aCx, aData, aOut);
+
+    } else if (aData.IsInstanceOf<jni::DoubleArray>()) {
+        return UnboxArrayPrimitive<double, jdouble, jdoubleArray,
+                &JNIEnv::GetDoubleArrayElements,
+                &JNIEnv::ReleaseDoubleArrayElements,
+                &JS::DoubleValue>(aCx, aData, aOut);
+
+    } else if (aData.IsInstanceOf<StringArray>()) {
+        return UnboxArrayObject<&UnboxString>(aCx, aData, aOut);
+    } else if (aData.IsInstanceOf<GeckoBundleArray>()) {
+        return UnboxArrayObject<&UnboxBundle>(aCx, aData, aOut);
+    } else {
+        NS_WARNING("Invalid type");
+        return NS_ERROR_INVALID_ARG;
+    }
+    return NS_OK;
+}
+
+nsresult
+UnboxData(jni::String::Param aEvent, JSContext* aCx, jni::Object::Param aData,
+          JS::MutableHandleValue aOut, bool aBundleOnly)
+{
+    MOZ_ASSERT(NS_IsMainThread());
+
+    jni::Object::LocalRef jniData(jni::GetGeckoThreadEnv(), aData);
+    nsresult rv = NS_ERROR_INVALID_ARG;
+
+    if (!aBundleOnly) {
+        rv = UnboxValue(aCx, jniData, aOut);
+    } else if (!jniData || jniData.IsInstanceOf<java::GeckoBundle>()) {
+        rv = UnboxBundle(aCx, jniData, aOut);
+    }
+    if (rv != NS_ERROR_INVALID_ARG) {
+        return rv;
+    }
+
+    nsCString event = aEvent->ToCString();
+    if (JS_IsExceptionPending(aCx)) {
+        JS_ReportWarningUTF8(aCx, "Error dispatching %s", event.get());
+    } else {
+        JS_ReportErrorUTF8(aCx, "Invalid event data for %s", event.get());
+    }
+    return NS_ERROR_INVALID_ARG;
+}
+
+class JavaCallbackDelegate final : public nsIAndroidEventCallback
+{
+    java::EventCallback::GlobalRef mCallback;
+
+    virtual ~JavaCallbackDelegate() {}
+
+    NS_IMETHOD Call(JS::HandleValue aData,
+                    void (java::EventCallback::*aCall)(
+                            jni::Object::Param) const)
+    {
+        MOZ_ASSERT(NS_IsMainThread());
+        AutoJSContext cx;
+
+        jni::Object::LocalRef data(jni::GetGeckoThreadEnv());
+        nsresult rv = BoxData(NS_LITERAL_STRING("callback"), cx, aData, data,
+                              /* ObjectOnly */ false);
+        NS_ENSURE_SUCCESS(rv, JS_IsExceptionPending(cx) ? NS_OK : rv);
+
+        dom::AutoNoJSAPI nojsapi;
+        (java::EventCallback(*mCallback).*aCall)(data);
+        return NS_OK;
+    }
+
+public:
+    JavaCallbackDelegate(java::EventCallback::Param aCallback)
+        : mCallback(jni::GetGeckoThreadEnv(), aCallback)
+    {}
+
+    NS_DECL_ISUPPORTS
+
+    NS_IMETHOD OnSuccess(JS::HandleValue aData) override
+    {
+        return Call(aData, &java::EventCallback::SendSuccess);
+    }
+
+    NS_IMETHOD OnError(JS::HandleValue aData) override
+    {
+        return Call(aData, &java::EventCallback::SendError);
+    }
+};
+
+NS_IMPL_ISUPPORTS(JavaCallbackDelegate, nsIAndroidEventCallback)
+
+class NativeCallbackDelegateSupport final :
+    public java::EventDispatcher::NativeCallbackDelegate
+            ::Natives<NativeCallbackDelegateSupport>
+{
+    using CallbackDelegate = java::EventDispatcher::NativeCallbackDelegate;
+    using Base = CallbackDelegate::Natives<NativeCallbackDelegateSupport>;
+
+    const nsCOMPtr<nsIAndroidEventCallback> mCallback;
+    const nsCOMPtr<nsPIDOMWindowOuter> mWindow;
+
+    void Call(jni::Object::Param aData,
+              nsresult (nsIAndroidEventCallback::*aCall)(JS::HandleValue))
+    {
+        MOZ_ASSERT(NS_IsMainThread());
+
+        // Use the same compartment as the wrapped JS object if possible,
+        // otherwise use either the attached window's compartment or a default
+        // compartment.
+        nsCOMPtr<nsIXPConnectWrappedJS> wrappedJS(do_QueryInterface(mCallback));
+
+        dom::AutoJSAPI jsapi;
+        if (!wrappedJS && mWindow) {
+            NS_ENSURE_TRUE_VOID(jsapi.Init(mWindow->GetCurrentInnerWindow()));
+        } else {
+            NS_ENSURE_TRUE_VOID(jsapi.Init(wrappedJS ?
+                    wrappedJS->GetJSObject() : xpc::PrivilegedJunkScope()));
+        }
+
+        JS::RootedValue data(jsapi.cx());
+        nsresult rv = UnboxData(NS_LITERAL_STRING("callback"), jsapi.cx(),
+                                aData, &data, /* BundleOnly */ false);
+        NS_ENSURE_SUCCESS_VOID(rv);
+
+        dom::AutoNoJSAPI nojsapi;
+        rv = (mCallback->*aCall)(data);
+        NS_ENSURE_SUCCESS_VOID(rv);
+    }
+
+public:
+    using Base::AttachNative;
+
+    template<typename Functor>
+    static void OnNativeCall(Functor&& aCall)
+    {
+        if (NS_IsMainThread()) {
+            // Invoke callbacks synchronously if we're already on Gecko thread.
+            return aCall();
+        }
+        nsAppShell::PostEvent(Move(aCall));
+    }
+
+    static void Finalize(const CallbackDelegate::LocalRef& aInstance)
+    {
+        DisposeNative(aInstance);
+    }
+
+    NativeCallbackDelegateSupport(nsIAndroidEventCallback* callback,
+                                  nsPIDOMWindowOuter* domWindow)
+        : mCallback(callback)
+        , mWindow(domWindow)
+    {}
+
+    void SendSuccess(jni::Object::Param aData)
+    {
+        Call(aData, &nsIAndroidEventCallback::OnSuccess);
+    }
+
+    void SendError(jni::Object::Param aData)
+    {
+        Call(aData, &nsIAndroidEventCallback::OnError);
+    }
+};
+
+} // namespace detail
+
+using namespace detail;
+
+NS_IMPL_ISUPPORTS(EventDispatcher, nsIAndroidEventDispatcher)
+
+nsresult
+EventDispatcher::DispatchOnGecko(ListenersList* list, const nsAString& aEvent,
+                                 JS::HandleValue aData,
+                                 nsIAndroidEventCallback* aCallback)
+{
+    MOZ_ASSERT(NS_IsMainThread());
+    dom::AutoNoJSAPI nojsapi;
+
+    list->lockCount++;
+
+    auto iteratingScope = MakeScopeExit([list] {
+        list->lockCount--;
+        if (list->lockCount || !list->unregistering) {
+            return;
+        }
+
+        list->unregistering = false;
+        for (ssize_t i = list->listeners.Count() - 1; i >= 0; i--) {
+            if (list->listeners[i]) {
+                continue;
+            }
+            list->listeners.RemoveObjectAt(i);
+        }
+    });
+
+    const size_t count = list->listeners.Count();
+    for (size_t i = 0; i < count; i++) {
+        if (!list->listeners[i]) {
+            // Unregistered.
+            continue;
+        }
+        const nsresult rv = list->listeners[i]->OnEvent(
+                aEvent, aData, aCallback);
+        NS_ENSURE_SUCCESS(rv, rv);
+    }
+    return NS_OK;
+}
+
+NS_IMETHODIMP
+EventDispatcher::Dispatch(const nsAString& aEvent, JS::HandleValue aData,
+                          nsIAndroidEventCallback* aCallback, JSContext* aCx)
+{
+    MOZ_ASSERT(NS_IsMainThread());
+
+    // Don't need to lock here because we're on the main thread, and we can't
+    // race against Register/UnregisterListener.
+
+    ListenersList* list = mListenersMap.Get(aEvent);
+    if (list) {
+        return DispatchOnGecko(list, aEvent, aData, aCallback);
+    }
+
+    if (!mDispatcher) {
+        return NS_OK;
+    }
+
+    jni::Object::LocalRef data(jni::GetGeckoThreadEnv());
+    nsresult rv = BoxData(aEvent, aCx, aData, data, /* ObjectOnly */ true);
+    NS_ENSURE_SUCCESS(rv, JS_IsExceptionPending(aCx) ? NS_OK : rv);
+
+    dom::AutoNoJSAPI nojsapi;
+
+    java::EventDispatcher::NativeCallbackDelegate::LocalRef
+            callback(data.Env());
+    if (aCallback) {
+        callback = java::EventDispatcher::NativeCallbackDelegate::New();
+        NativeCallbackDelegateSupport::AttachNative(
+                callback,
+                MakeUnique<NativeCallbackDelegateSupport>(
+                        aCallback, mDOMWindow));
+    }
+
+    mDispatcher->DispatchToThreads(aEvent, /* js */ nullptr, data, callback);
+    return NS_OK;
+}
+
+nsresult
+EventDispatcher::IterateEvents(JSContext* aCx, JS::HandleValue aEvents,
+                               IterateEventsCallback aCallback,
+                               nsIAndroidEventListener* aListener)
+{
+    MOZ_ASSERT(NS_IsMainThread());
+
+    MutexAutoLock lock(mLock);
+
+    auto processEvent = [this, aCx, aCallback, aListener]
+            (JS::HandleValue event) -> nsresult {
+        nsAutoJSString str;
+        NS_ENSURE_TRUE(CheckJS(aCx, str.init(aCx, event.toString())),
+                       NS_ERROR_OUT_OF_MEMORY);
+        return (this->*aCallback)(str, aListener);
+    };
+
+    if (aEvents.isString()) {
+        return processEvent(aEvents);
+    }
+
+    bool isArray = false;
+    NS_ENSURE_TRUE(aEvents.isObject(), NS_ERROR_INVALID_ARG);
+    NS_ENSURE_TRUE(CheckJS(aCx, JS_IsArrayObject(aCx, aEvents, &isArray)),
+                   NS_ERROR_INVALID_ARG);
+    NS_ENSURE_TRUE(isArray, NS_ERROR_INVALID_ARG);
+
+    JS::RootedObject events(aCx, &aEvents.toObject());
+    uint32_t length = 0;
+    NS_ENSURE_TRUE(CheckJS(aCx, JS_GetArrayLength(aCx, events, &length)),
+                   NS_ERROR_INVALID_ARG);
+    NS_ENSURE_TRUE(length, NS_ERROR_INVALID_ARG);
+
+    for (size_t i = 0; i < length; i++) {
+        JS::RootedValue event(aCx);
+        NS_ENSURE_TRUE(CheckJS(aCx, JS_GetElement(aCx, events, i, &event)),
+                       NS_ERROR_INVALID_ARG);
+        NS_ENSURE_TRUE(event.isString(), NS_ERROR_INVALID_ARG);
+
+        const nsresult rv = processEvent(event);
+        NS_ENSURE_SUCCESS(rv, rv);
+    }
+    return NS_OK;
+}
+
+nsresult
+EventDispatcher::RegisterEventLocked(const nsAString& aEvent,
+                                     nsIAndroidEventListener* aListener)
+{
+    ListenersList* list = mListenersMap.Get(aEvent);
+    if (!list) {
+        list = new ListenersList();
+        mListenersMap.Put(aEvent, list);
+    }
+
+#ifdef DEBUG
+    for (ssize_t i = 0; i < list->listeners.Count(); i++) {
+        NS_ENSURE_TRUE(list->listeners[i] != aListener,
+                       NS_ERROR_ALREADY_INITIALIZED);
+    }
+#endif
+
+    list->listeners.AppendObject(aListener);
+    return NS_OK;
+}
+
+NS_IMETHODIMP
+EventDispatcher::RegisterListener(nsIAndroidEventListener* aListener,
+                                  JS::HandleValue aEvents, JSContext *aCx)
+{
+    return IterateEvents(aCx, aEvents, &EventDispatcher::RegisterEventLocked,
+                         aListener);
+}
+
+nsresult
+EventDispatcher::UnregisterEventLocked(const nsAString& aEvent,
+                                       nsIAndroidEventListener* aListener)
+{
+    ListenersList* list = mListenersMap.Get(aEvent);
+#ifdef DEBUG
+    NS_ENSURE_TRUE(list, NS_ERROR_NOT_INITIALIZED);
+#else
+    NS_ENSURE_TRUE(list, NS_OK);
+#endif
+
+    DebugOnly<bool> found = false;
+    for (ssize_t i = list->listeners.Count() - 1; i >= 0; i--) {
+        if (list->listeners[i] != aListener) {
+            continue;
+        }
+        if (list->lockCount) {
+            // Only mark for removal when list is locked.
+            list->listeners.ReplaceObjectAt(nullptr, i);
+            list->unregistering = true;
+        } else {
+            list->listeners.RemoveObjectAt(i);
+        }
+        found = true;
+    }
+#ifdef DEBUG
+    return found ? NS_OK : NS_ERROR_NOT_INITIALIZED;
+#else
+    return NS_OK;
+#endif
+}
+
+NS_IMETHODIMP
+EventDispatcher::UnregisterListener(nsIAndroidEventListener* aListener,
+                                    JS::HandleValue aEvents, JSContext *aCx)
+{
+    return IterateEvents(aCx, aEvents, &EventDispatcher::UnregisterEventLocked,
+                         aListener);
+}
+
+void
+EventDispatcher::Attach(java::EventDispatcher::Param aDispatcher,
+                        nsPIDOMWindowOuter* aDOMWindow)
+{
+    MOZ_ASSERT(NS_IsMainThread());
+    MOZ_ASSERT(aDispatcher);
+
+    if (mDispatcher) {
+        if (mDispatcher == aDispatcher) {
+            // Only need to update the window.
+            mDOMWindow = aDOMWindow;
+            return;
+        }
+        mDispatcher->SetAttachedToGecko(java::EventDispatcher::REATTACHING);
+    }
+
+    java::EventDispatcher::LocalRef dispatcher(aDispatcher);
+
+    NativesBase::AttachNative(dispatcher, this);
+    mDispatcher = dispatcher;
+    mDOMWindow = aDOMWindow;
+
+    dispatcher->SetAttachedToGecko(java::EventDispatcher::ATTACHED);
+}
+
+void
+EventDispatcher::Detach()
+{
+    MOZ_ASSERT(NS_IsMainThread());
+    MOZ_ASSERT(mDispatcher);
+
+    // SetAttachedToGecko will call disposeNative for us. disposeNative will be
+    // called later on the Gecko thread to make sure all pending
+    // dispatchToGecko calls have completed.
+    mDispatcher->SetAttachedToGecko(java::EventDispatcher::DETACHED);
+    mDispatcher = nullptr;
+    mDOMWindow = nullptr;
+}
+
+bool
+EventDispatcher::HasGeckoListener(jni::String::Param aEvent)
+{
+    // Can be called from any thread.
+    MutexAutoLock lock(mLock);
+    return !!mListenersMap.Get(aEvent->ToString());
+}
+
+void
+EventDispatcher::DispatchToGecko(jni::String::Param aEvent,
+                                 jni::Object::Param aData,
+                                 jni::Object::Param aCallback)
+{
+    MOZ_ASSERT(NS_IsMainThread());
+
+    // Don't need to lock here because we're on the main thread, and we can't
+    // race against Register/UnregisterListener.
+
+    nsString event = aEvent->ToString();
+    ListenersList* list = mListenersMap.Get(event);
+    if (!list || list->listeners.IsEmpty()) {
+        return;
+    }
+
+    // Use the same compartment as the attached window if possible, otherwise
+    // use a default compartment.
+    dom::AutoJSAPI jsapi;
+    if (mDOMWindow) {
+        NS_ENSURE_TRUE_VOID(jsapi.Init(mDOMWindow->GetCurrentInnerWindow()));
+    } else {
+        NS_ENSURE_TRUE_VOID(jsapi.Init(xpc::PrivilegedJunkScope()));
+    }
+
+    JS::RootedValue data(jsapi.cx());
+    nsresult rv = UnboxData(aEvent, jsapi.cx(), aData, &data,
+                            /* BundleOnly */ true);
+    NS_ENSURE_SUCCESS_VOID(rv);
+
+    nsCOMPtr<nsIAndroidEventCallback> callback;
+    if (aCallback) {
+        callback = new JavaCallbackDelegate(
+                java::EventCallback::Ref::From(aCallback));
+    }
+
+    DispatchOnGecko(list, event, data, callback);
+}
+
+} // namespace widget
+} // namespace mozilla
new file mode 100644
--- /dev/null
+++ b/widget/android/EventDispatcher.h
@@ -0,0 +1,87 @@
+/* -*- Mode: c++; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: set sw=4 ts=4 expandtab:
+ * 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/. */
+
+#ifndef mozilla_widget_EventDispatcher_h
+#define mozilla_widget_EventDispatcher_h
+
+#include "GeneratedJNINatives.h"
+#include "jsapi.h"
+#include "nsClassHashtable.h"
+#include "nsCOMArray.h"
+#include "nsIAndroidBridge.h"
+#include "nsHashKeys.h"
+#include "nsPIDOMWindow.h"
+
+#include "mozilla/Mutex.h"
+
+namespace mozilla {
+namespace widget {
+
+/**
+ * EventDispatcher is the Gecko counterpart to the Java EventDispatcher class.
+ * Together, they make up a unified event bus. Events dispatched from the Java
+ * side may notify event listeners on the Gecko side, and vice versa.
+ */
+class EventDispatcher final
+    : public nsIAndroidEventDispatcher
+    , public java::EventDispatcher::Natives<EventDispatcher>
+{
+    using NativesBase = java::EventDispatcher::Natives<EventDispatcher>;
+
+public:
+    NS_DECL_ISUPPORTS
+    NS_DECL_NSIANDROIDEVENTDISPATCHER
+
+    EventDispatcher() {}
+
+    void Attach(java::EventDispatcher::Param aDispatcher,
+                nsPIDOMWindowOuter* aDOMWindow);
+    void Detach();
+
+    using NativesBase::DisposeNative;
+
+    bool HasGeckoListener(jni::String::Param aEvent);
+    void DispatchToGecko(jni::String::Param aEvent,
+                         jni::Object::Param aData,
+                         jni::Object::Param aCallback);
+
+private:
+    java::EventDispatcher::GlobalRef mDispatcher;
+    nsCOMPtr<nsPIDOMWindowOuter> mDOMWindow;
+
+    virtual ~EventDispatcher() {}
+
+    struct ListenersList {
+        nsCOMArray<nsIAndroidEventListener> listeners{/* count */ 1};
+        // 0 if the list can be modified
+        uint32_t lockCount{0};
+        // true if this list has a listener that is being unregistered
+        bool unregistering{false};
+    };
+
+    using ListenersMap = nsClassHashtable<nsStringHashKey, ListenersList>;
+
+    Mutex mLock{"mozilla::widget::EventDispatcher"};
+    ListenersMap mListenersMap;
+
+    using IterateEventsCallback = nsresult (EventDispatcher::*)
+            (const nsAString&, nsIAndroidEventListener*);
+
+    nsresult IterateEvents(JSContext* aCx, JS::HandleValue aEvents,
+                           IterateEventsCallback aCallback,
+                           nsIAndroidEventListener* aListener);
+    nsresult RegisterEventLocked(const nsAString&, nsIAndroidEventListener*);
+    nsresult UnregisterEventLocked(const nsAString&, nsIAndroidEventListener*);
+
+    nsresult DispatchOnGecko(ListenersList* list, const nsAString& aEvent,
+                             JS::HandleValue aData,
+                             nsIAndroidEventCallback* aCallback);
+};
+
+} // namespace widget
+} // namespace mozilla
+
+#endif // mozilla_widget_EventDispatcher_h
--- a/widget/android/GeneratedJNINatives.h
+++ b/widget/android/GeneratedJNINatives.h
@@ -47,16 +47,62 @@ const JNINativeMethod AndroidGamepadMana
             ::template Wrap<&Impl::OnButtonChange>),
 
     mozilla::jni::MakeNativeMethod<AndroidGamepadManager::OnGamepadChange_t>(
             mozilla::jni::NativeStub<AndroidGamepadManager::OnGamepadChange_t, Impl>
             ::template Wrap<&Impl::OnGamepadChange>)
 };
 
 template<class Impl>
+class EventDispatcher::Natives : public mozilla::jni::NativeImpl<EventDispatcher, Impl>
+{
+public:
+    static const JNINativeMethod methods[3];
+};
+
+template<class Impl>
+const JNINativeMethod EventDispatcher::Natives<Impl>::methods[] = {
+
+    mozilla::jni::MakeNativeMethod<EventDispatcher::DispatchToGecko_t>(
+            mozilla::jni::NativeStub<EventDispatcher::DispatchToGecko_t, Impl>
+            ::template Wrap<&Impl::DispatchToGecko>),
+
+    mozilla::jni::MakeNativeMethod<EventDispatcher::DisposeNative_t>(
+            mozilla::jni::NativeStub<EventDispatcher::DisposeNative_t, Impl>
+            ::template Wrap<&Impl::DisposeNative>),
+
+    mozilla::jni::MakeNativeMethod<EventDispatcher::HasGeckoListener_t>(
+            mozilla::jni::NativeStub<EventDispatcher::HasGeckoListener_t, Impl>
+            ::template Wrap<&Impl::HasGeckoListener>)
+};
+
+template<class Impl>
+class EventDispatcher::NativeCallbackDelegate::Natives : public mozilla::jni::NativeImpl<NativeCallbackDelegate, Impl>
+{
+public:
+    static const JNINativeMethod methods[3];
+};
+
+template<class Impl>
+const JNINativeMethod EventDispatcher::NativeCallbackDelegate::Natives<Impl>::methods[] = {
+
+    mozilla::jni::MakeNativeMethod<EventDispatcher::NativeCallbackDelegate::Finalize_t>(
+            mozilla::jni::NativeStub<EventDispatcher::NativeCallbackDelegate::Finalize_t, Impl>
+            ::template Wrap<&Impl::Finalize>),
+
+    mozilla::jni::MakeNativeMethod<EventDispatcher::NativeCallbackDelegate::SendError_t>(
+            mozilla::jni::NativeStub<EventDispatcher::NativeCallbackDelegate::SendError_t, Impl>
+            ::template Wrap<&Impl::SendError>),
+
+    mozilla::jni::MakeNativeMethod<EventDispatcher::NativeCallbackDelegate::SendSuccess_t>(
+            mozilla::jni::NativeStub<EventDispatcher::NativeCallbackDelegate::SendSuccess_t, Impl>
+            ::template Wrap<&Impl::SendSuccess>)
+};
+
+template<class Impl>
 class GeckoAppShell::Natives : public mozilla::jni::NativeImpl<GeckoAppShell, Impl>
 {
 public:
     static const JNINativeMethod methods[8];
 };
 
 template<class Impl>
 const JNINativeMethod GeckoAppShell::Natives<Impl>::methods[] = {
--- a/widget/android/GeneratedJNIWrappers.cpp
+++ b/widget/android/GeneratedJNIWrappers.cpp
@@ -47,16 +47,72 @@ auto AndroidGamepadManager::Start() -> v
 constexpr char AndroidGamepadManager::Stop_t::name[];
 constexpr char AndroidGamepadManager::Stop_t::signature[];
 
 auto AndroidGamepadManager::Stop() -> void
 {
     return mozilla::jni::Method<Stop_t>::Call(AndroidGamepadManager::Context(), nullptr);
 }
 
+const char EventDispatcher::name[] =
+        "org/mozilla/gecko/EventDispatcher";
+
+constexpr char EventDispatcher::DispatchToGecko_t::name[];
+constexpr char EventDispatcher::DispatchToGecko_t::signature[];
+
+constexpr char EventDispatcher::DispatchToThreads_t::name[];
+constexpr char EventDispatcher::DispatchToThreads_t::signature[];
+
+auto EventDispatcher::DispatchToThreads(mozilla::jni::String::Param a0, mozilla::jni::Object::Param a1, mozilla::jni::Object::Param a2, mozilla::jni::Object::Param a3) const -> bool
+{
+    return mozilla::jni::Method<DispatchToThreads_t>::Call(EventDispatcher::mCtx, nullptr, a0, a1, a2, a3);
+}
+
+constexpr char EventDispatcher::DisposeNative_t::name[];
+constexpr char EventDispatcher::DisposeNative_t::signature[];
+
+constexpr char EventDispatcher::GetInstance_t::name[];
+constexpr char EventDispatcher::GetInstance_t::signature[];
+
+auto EventDispatcher::GetInstance() -> EventDispatcher::LocalRef
+{
+    return mozilla::jni::Method<GetInstance_t>::Call(EventDispatcher::Context(), nullptr);
+}
+
+constexpr char EventDispatcher::HasGeckoListener_t::name[];
+constexpr char EventDispatcher::HasGeckoListener_t::signature[];
+
+constexpr char EventDispatcher::SetAttachedToGecko_t::name[];
+constexpr char EventDispatcher::SetAttachedToGecko_t::signature[];
+
+auto EventDispatcher::SetAttachedToGecko(int32_t a0) const -> void
+{
+    return mozilla::jni::Method<SetAttachedToGecko_t>::Call(EventDispatcher::mCtx, nullptr, a0);
+}
+
+const char EventDispatcher::NativeCallbackDelegate::name[] =
+        "org/mozilla/gecko/EventDispatcher$NativeCallbackDelegate";
+
+constexpr char EventDispatcher::NativeCallbackDelegate::New_t::name[];
+constexpr char EventDispatcher::NativeCallbackDelegate::New_t::signature[];
+
+auto EventDispatcher::NativeCallbackDelegate::New() -> NativeCallbackDelegate::LocalRef
+{
+    return mozilla::jni::Constructor<New_t>::Call(NativeCallbackDelegate::Context(), nullptr);
+}
+
+constexpr char EventDispatcher::NativeCallbackDelegate::Finalize_t::name[];
+constexpr char EventDispatcher::NativeCallbackDelegate::Finalize_t::signature[];
+
+constexpr char EventDispatcher::NativeCallbackDelegate::SendError_t::name[];
+constexpr char EventDispatcher::NativeCallbackDelegate::SendError_t::signature[];
+
+constexpr char EventDispatcher::NativeCallbackDelegate::SendSuccess_t::name[];
+constexpr char EventDispatcher::NativeCallbackDelegate::SendSuccess_t::signature[];
+
 const char GeckoAppShell::name[] =
         "org/mozilla/gecko/GeckoAppShell";
 
 constexpr char GeckoAppShell::AddFullScreenPluginView_t::name[];
 constexpr char GeckoAppShell::AddFullScreenPluginView_t::signature[];
 
 auto GeckoAppShell::AddFullScreenPluginView(mozilla::jni::Object::Param a0) -> void
 {
@@ -1737,16 +1793,118 @@ auto Clipboard::HasText() -> bool
 constexpr char Clipboard::SetText_t::name[];
 constexpr char Clipboard::SetText_t::signature[];
 
 auto Clipboard::SetText(mozilla::jni::String::Param a0) -> void
 {
     return mozilla::jni::Method<SetText_t>::Call(Clipboard::Context(), nullptr, a0);
 }
 
+const char EventCallback::name[] =
+        "org/mozilla/gecko/util/EventCallback";
+
+constexpr char EventCallback::SendError_t::name[];
+constexpr char EventCallback::SendError_t::signature[];
+
+auto EventCallback::SendError(mozilla::jni::Object::Param a0) const -> void
+{
+    return mozilla::jni::Method<SendError_t>::Call(EventCallback::mCtx, nullptr, a0);
+}
+
+constexpr char EventCallback::SendSuccess_t::name[];
+constexpr char EventCallback::SendSuccess_t::signature[];
+
+auto EventCallback::SendSuccess(mozilla::jni::Object::Param a0) const -> void
+{
+    return mozilla::jni::Method<SendSuccess_t>::Call(EventCallback::mCtx, nullptr, a0);
+}
+
+const char GeckoBundle::name[] =
+        "org/mozilla/gecko/util/GeckoBundle";
+
+constexpr char GeckoBundle::New_t::name[];
+constexpr char GeckoBundle::New_t::signature[];
+
+auto GeckoBundle::New(mozilla::jni::ObjectArray::Param a0, mozilla::jni::ObjectArray::Param a1) -> GeckoBundle::LocalRef
+{
+    return mozilla::jni::Constructor<New_t>::Call(GeckoBundle::Context(), nullptr, a0, a1);
+}
+
+constexpr char GeckoBundle::Box_t::name[];
+constexpr char GeckoBundle::Box_t::signature[];
+
+auto GeckoBundle::Box(double a0) -> mozilla::jni::Object::LocalRef
+{
+    return mozilla::jni::Method<Box_t>::Call(GeckoBundle::Context(), nullptr, a0);
+}
+
+constexpr char GeckoBundle::Box2_t::name[];
+constexpr char GeckoBundle::Box2_t::signature[];
+
+auto GeckoBundle::Box(int32_t a0) -> mozilla::jni::Object::LocalRef
+{
+    return mozilla::jni::Method<Box2_t>::Call(GeckoBundle::Context(), nullptr, a0);
+}
+
+constexpr char GeckoBundle::Box3_t::name[];
+constexpr char GeckoBundle::Box3_t::signature[];
+
+auto GeckoBundle::Box(bool a0) -> mozilla::jni::Object::LocalRef
+{
+    return mozilla::jni::Method<Box3_t>::Call(GeckoBundle::Context(), nullptr, a0);
+}
+
+constexpr char GeckoBundle::Keys_t::name[];
+constexpr char GeckoBundle::Keys_t::signature[];
+
+auto GeckoBundle::Keys() const -> mozilla::jni::ObjectArray::LocalRef
+{
+    return mozilla::jni::Method<Keys_t>::Call(GeckoBundle::mCtx, nullptr);
+}
+
+constexpr char GeckoBundle::UnboxBoolean_t::name[];
+constexpr char GeckoBundle::UnboxBoolean_t::signature[];
+
+auto GeckoBundle::UnboxBoolean(mozilla::jni::Object::Param a0) -> bool
+{
+    return mozilla::jni::Method<UnboxBoolean_t>::Call(GeckoBundle::Context(), nullptr, a0);
+}
+
+constexpr char GeckoBundle::UnboxDouble_t::name[];
+constexpr char GeckoBundle::UnboxDouble_t::signature[];
+
+auto GeckoBundle::UnboxDouble(mozilla::jni::Object::Param a0) -> double
+{
+    return mozilla::jni::Method<UnboxDouble_t>::Call(GeckoBundle::Context(), nullptr, a0);
+}
+
+constexpr char GeckoBundle::UnboxInteger_t::name[];
+constexpr char GeckoBundle::UnboxInteger_t::signature[];
+
+auto GeckoBundle::UnboxInteger(mozilla::jni::Object::Param a0) -> int32_t
+{
+    return mozilla::jni::Method<UnboxInteger_t>::Call(GeckoBundle::Context(), nullptr, a0);
+}
+
+constexpr char GeckoBundle::Values_t::name[];
+constexpr char GeckoBundle::Values_t::signature[];
+
+auto GeckoBundle::Values() const -> mozilla::jni::ObjectArray::LocalRef
+{
+    return mozilla::jni::Method<Values_t>::Call(GeckoBundle::mCtx, nullptr);
+}
+
+constexpr char GeckoBundle::EMPTY_BOOLEAN_ARRAY_t::name[];
+constexpr char GeckoBundle::EMPTY_BOOLEAN_ARRAY_t::signature[];
+
+auto GeckoBundle::EMPTY_BOOLEAN_ARRAY() -> mozilla::jni::BooleanArray::LocalRef
+{
+    return mozilla::jni::Field<EMPTY_BOOLEAN_ARRAY_t>::Get(GeckoBundle::Context(), nullptr);
+}
+
 const char HardwareCodecCapabilityUtils::name[] =
         "org/mozilla/gecko/util/HardwareCodecCapabilityUtils";
 
 constexpr char HardwareCodecCapabilityUtils::HasHWVP9_t::name[];
 constexpr char HardwareCodecCapabilityUtils::HasHWVP9_t::signature[];
 
 auto HardwareCodecCapabilityUtils::HasHWVP9() -> bool
 {
--- a/widget/android/GeneratedJNIWrappers.h
+++ b/widget/android/GeneratedJNIWrappers.h
@@ -169,16 +169,239 @@ public:
     static auto Stop() -> void;
 
     static const mozilla::jni::CallingThread callingThread =
             mozilla::jni::CallingThread::ANY;
 
     template<class Impl> class Natives;
 };
 
+class EventDispatcher : public mozilla::jni::ObjectBase<EventDispatcher>
+{
+public:
+    static const char name[];
+
+    explicit EventDispatcher(const Context& ctx) : ObjectBase<EventDispatcher>(ctx) {}
+
+    class NativeCallbackDelegate;
+
+    struct DispatchToGecko_t {
+        typedef EventDispatcher Owner;
+        typedef void ReturnType;
+        typedef void SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::String::Param,
+                mozilla::jni::Object::Param,
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "dispatchToGecko";
+        static constexpr char signature[] =
+                "(Ljava/lang/String;Lorg/mozilla/gecko/util/GeckoBundle;Lorg/mozilla/gecko/util/EventCallback;)V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::ANY;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::GECKO;
+    };
+
+    struct DispatchToThreads_t {
+        typedef EventDispatcher Owner;
+        typedef bool ReturnType;
+        typedef bool SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::String::Param,
+                mozilla::jni::Object::Param,
+                mozilla::jni::Object::Param,
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "dispatchToThreads";
+        static constexpr char signature[] =
+                "(Ljava/lang/String;Lorg/mozilla/gecko/util/NativeJSObject;Lorg/mozilla/gecko/util/GeckoBundle;Lorg/mozilla/gecko/util/EventCallback;)Z";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    auto DispatchToThreads(mozilla::jni::String::Param, mozilla::jni::Object::Param, mozilla::jni::Object::Param, mozilla::jni::Object::Param) const -> bool;
+
+    struct DisposeNative_t {
+        typedef EventDispatcher Owner;
+        typedef void ReturnType;
+        typedef void SetterType;
+        typedef mozilla::jni::Args<> Args;
+        static constexpr char name[] = "disposeNative";
+        static constexpr char signature[] =
+                "()V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::ANY;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::GECKO;
+    };
+
+    struct GetInstance_t {
+        typedef EventDispatcher Owner;
+        typedef EventDispatcher::LocalRef ReturnType;
+        typedef EventDispatcher::Param SetterType;
+        typedef mozilla::jni::Args<> Args;
+        static constexpr char name[] = "getInstance";
+        static constexpr char signature[] =
+                "()Lorg/mozilla/gecko/EventDispatcher;";
+        static const bool isStatic = true;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto GetInstance() -> EventDispatcher::LocalRef;
+
+    struct HasGeckoListener_t {
+        typedef EventDispatcher Owner;
+        typedef bool ReturnType;
+        typedef bool SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::String::Param> Args;
+        static constexpr char name[] = "hasGeckoListener";
+        static constexpr char signature[] =
+                "(Ljava/lang/String;)Z";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::ANY;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    struct SetAttachedToGecko_t {
+        typedef EventDispatcher Owner;
+        typedef void ReturnType;
+        typedef void SetterType;
+        typedef mozilla::jni::Args<
+                int32_t> Args;
+        static constexpr char name[] = "setAttachedToGecko";
+        static constexpr char signature[] =
+                "(I)V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    auto SetAttachedToGecko(int32_t) const -> void;
+
+    static const int32_t ATTACHED = 1;
+
+    static const int32_t DETACHED = 0;
+
+    static const int32_t REATTACHING = 2;
+
+    static const mozilla::jni::CallingThread callingThread =
+            mozilla::jni::CallingThread::ANY;
+
+    template<class Impl> class Natives;
+};
+
+class EventDispatcher::NativeCallbackDelegate : public mozilla::jni::ObjectBase<NativeCallbackDelegate>
+{
+public:
+    static const char name[];
+
+    explicit NativeCallbackDelegate(const Context& ctx) : ObjectBase<NativeCallbackDelegate>(ctx) {}
+
+    struct New_t {
+        typedef NativeCallbackDelegate Owner;
+        typedef NativeCallbackDelegate::LocalRef ReturnType;
+        typedef NativeCallbackDelegate::Param SetterType;
+        typedef mozilla::jni::Args<> Args;
+        static constexpr char name[] = "<init>";
+        static constexpr char signature[] =
+                "()V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto New() -> NativeCallbackDelegate::LocalRef;
+
+    struct Finalize_t {
+        typedef NativeCallbackDelegate Owner;
+        typedef void ReturnType;
+        typedef void SetterType;
+        typedef mozilla::jni::Args<> Args;
+        static constexpr char name[] = "finalize";
+        static constexpr char signature[] =
+                "()V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::ANY;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::GECKO;
+    };
+
+    struct SendError_t {
+        typedef NativeCallbackDelegate Owner;
+        typedef void ReturnType;
+        typedef void SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "sendError";
+        static constexpr char signature[] =
+                "(Ljava/lang/Object;)V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::ANY;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::PROXY;
+    };
+
+    struct SendSuccess_t {
+        typedef NativeCallbackDelegate Owner;
+        typedef void ReturnType;
+        typedef void SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "sendSuccess";
+        static constexpr char signature[] =
+                "(Ljava/lang/Object;)V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::ANY;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::PROXY;
+    };
+
+    static const mozilla::jni::CallingThread callingThread =
+            mozilla::jni::CallingThread::ANY;
+
+    template<class Impl> class Natives;
+};
+
 class GeckoAppShell : public mozilla::jni::ObjectBase<GeckoAppShell>
 {
 public:
     static const char name[];
 
     explicit GeckoAppShell(const Context& ctx) : ObjectBase<GeckoAppShell>(ctx) {}
 
     class CameraCallback;
@@ -2787,40 +3010,42 @@ public:
     struct Open_t {
         typedef Window Owner;
         typedef void ReturnType;
         typedef void SetterType;
         typedef mozilla::jni::Args<
                 Window::Param,
                 GeckoView::Param,
                 mozilla::jni::Object::Param,
+                mozilla::jni::Object::Param,
                 mozilla::jni::String::Param,
                 int32_t> Args;
         static constexpr char name[] = "open";
         static constexpr char signature[] =
-                "(Lorg/mozilla/gecko/GeckoView$Window;Lorg/mozilla/gecko/GeckoView;Ljava/lang/Object;Ljava/lang/String;I)V";
+                "(Lorg/mozilla/gecko/GeckoView$Window;Lorg/mozilla/gecko/GeckoView;Ljava/lang/Object;Lorg/mozilla/gecko/EventDispatcher;Ljava/lang/String;I)V";
         static const bool isStatic = true;
         static const mozilla::jni::ExceptionMode exceptionMode =
                 mozilla::jni::ExceptionMode::ABORT;
         static const mozilla::jni::CallingThread callingThread =
                 mozilla::jni::CallingThread::ANY;
         static const mozilla::jni::DispatchTarget dispatchTarget =
                 mozilla::jni::DispatchTarget::PROXY;
     };
 
     struct Reattach_t {
         typedef Window Owner;
         typedef void ReturnType;
         typedef void SetterType;
         typedef mozilla::jni::Args<
                 GeckoView::Param,
+                mozilla::jni::Object::Param,
                 mozilla::jni::Object::Param> Args;
         static constexpr char name[] = "reattach";
         static constexpr char signature[] =
-                "(Lorg/mozilla/gecko/GeckoView;Ljava/lang/Object;)V";
+                "(Lorg/mozilla/gecko/GeckoView;Ljava/lang/Object;Lorg/mozilla/gecko/EventDispatcher;)V";
         static const bool isStatic = false;
         static const mozilla::jni::ExceptionMode exceptionMode =
                 mozilla::jni::ExceptionMode::ABORT;
         static const mozilla::jni::CallingThread callingThread =
                 mozilla::jni::CallingThread::ANY;
         static const mozilla::jni::DispatchTarget dispatchTarget =
                 mozilla::jni::DispatchTarget::PROXY;
     };
@@ -4802,16 +5027,278 @@ public:
 
     static auto SetText(mozilla::jni::String::Param) -> void;
 
     static const mozilla::jni::CallingThread callingThread =
             mozilla::jni::CallingThread::GECKO;
 
 };
 
+class EventCallback : public mozilla::jni::ObjectBase<EventCallback>
+{
+public:
+    static const char name[];
+
+    explicit EventCallback(const Context& ctx) : ObjectBase<EventCallback>(ctx) {}
+
+    struct SendError_t {
+        typedef EventCallback Owner;
+        typedef void ReturnType;
+        typedef void SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "sendError";
+        static constexpr char signature[] =
+                "(Ljava/lang/Object;)V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    auto SendError(mozilla::jni::Object::Param) const -> void;
+
+    struct SendSuccess_t {
+        typedef EventCallback Owner;
+        typedef void ReturnType;
+        typedef void SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "sendSuccess";
+        static constexpr char signature[] =
+                "(Ljava/lang/Object;)V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    auto SendSuccess(mozilla::jni::Object::Param) const -> void;
+
+    static const mozilla::jni::CallingThread callingThread =
+            mozilla::jni::CallingThread::GECKO;
+
+};
+
+class GeckoBundle : public mozilla::jni::ObjectBase<GeckoBundle>
+{
+public:
+    static const char name[];
+
+    explicit GeckoBundle(const Context& ctx) : ObjectBase<GeckoBundle>(ctx) {}
+
+    struct New_t {
+        typedef GeckoBundle Owner;
+        typedef GeckoBundle::LocalRef ReturnType;
+        typedef GeckoBundle::Param SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::ObjectArray::Param,
+                mozilla::jni::ObjectArray::Param> Args;
+        static constexpr char name[] = "<init>";
+        static constexpr char signature[] =
+                "([Ljava/lang/String;[Ljava/lang/Object;)V";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto New(mozilla::jni::ObjectArray::Param, mozilla::jni::ObjectArray::Param) -> GeckoBundle::LocalRef;
+
+    struct Box_t {
+        typedef GeckoBundle Owner;
+        typedef mozilla::jni::Object::LocalRef ReturnType;
+        typedef mozilla::jni::Object::Param SetterType;
+        typedef mozilla::jni::Args<
+                double> Args;
+        static constexpr char name[] = "box";
+        static constexpr char signature[] =
+                "(D)Ljava/lang/Object;";
+        static const bool isStatic = true;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto Box(double) -> mozilla::jni::Object::LocalRef;
+
+    struct Box2_t {
+        typedef GeckoBundle Owner;
+        typedef mozilla::jni::Object::LocalRef ReturnType;
+        typedef mozilla::jni::Object::Param SetterType;
+        typedef mozilla::jni::Args<
+                int32_t> Args;
+        static constexpr char name[] = "box";
+        static constexpr char signature[] =
+                "(I)Ljava/lang/Object;";
+        static const bool isStatic = true;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto Box(int32_t) -> mozilla::jni::Object::LocalRef;
+
+    struct Box3_t {
+        typedef GeckoBundle Owner;
+        typedef mozilla::jni::Object::LocalRef ReturnType;
+        typedef mozilla::jni::Object::Param SetterType;
+        typedef mozilla::jni::Args<
+                bool> Args;
+        static constexpr char name[] = "box";
+        static constexpr char signature[] =
+                "(Z)Ljava/lang/Object;";
+        static const bool isStatic = true;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto Box(bool) -> mozilla::jni::Object::LocalRef;
+
+    struct Keys_t {
+        typedef GeckoBundle Owner;
+        typedef mozilla::jni::ObjectArray::LocalRef ReturnType;
+        typedef mozilla::jni::ObjectArray::Param SetterType;
+        typedef mozilla::jni::Args<> Args;
+        static constexpr char name[] = "keys";
+        static constexpr char signature[] =
+                "()[Ljava/lang/String;";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    auto Keys() const -> mozilla::jni::ObjectArray::LocalRef;
+
+    struct UnboxBoolean_t {
+        typedef GeckoBundle Owner;
+        typedef bool ReturnType;
+        typedef bool SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "unboxBoolean";
+        static constexpr char signature[] =
+                "(Ljava/lang/Boolean;)Z";
+        static const bool isStatic = true;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto UnboxBoolean(mozilla::jni::Object::Param) -> bool;
+
+    struct UnboxDouble_t {
+        typedef GeckoBundle Owner;
+        typedef double ReturnType;
+        typedef double SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "unboxDouble";
+        static constexpr char signature[] =
+                "(Ljava/lang/Double;)D";
+        static const bool isStatic = true;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto UnboxDouble(mozilla::jni::Object::Param) -> double;
+
+    struct UnboxInteger_t {
+        typedef GeckoBundle Owner;
+        typedef int32_t ReturnType;
+        typedef int32_t SetterType;
+        typedef mozilla::jni::Args<
+                mozilla::jni::Object::Param> Args;
+        static constexpr char name[] = "unboxInteger";
+        static constexpr char signature[] =
+                "(Ljava/lang/Integer;)I";
+        static const bool isStatic = true;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto UnboxInteger(mozilla::jni::Object::Param) -> int32_t;
+
+    struct Values_t {
+        typedef GeckoBundle Owner;
+        typedef mozilla::jni::ObjectArray::LocalRef ReturnType;
+        typedef mozilla::jni::ObjectArray::Param SetterType;
+        typedef mozilla::jni::Args<> Args;
+        static constexpr char name[] = "values";
+        static constexpr char signature[] =
+                "()[Ljava/lang/Object;";
+        static const bool isStatic = false;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    auto Values() const -> mozilla::jni::ObjectArray::LocalRef;
+
+    struct EMPTY_BOOLEAN_ARRAY_t {
+        typedef GeckoBundle Owner;
+        typedef mozilla::jni::BooleanArray::LocalRef ReturnType;
+        typedef mozilla::jni::BooleanArray::Param SetterType;
+        typedef mozilla::jni::Args<> Args;
+        static constexpr char name[] = "EMPTY_BOOLEAN_ARRAY";
+        static constexpr char signature[] =
+                "[Z";
+        static const bool isStatic = true;
+        static const mozilla::jni::ExceptionMode exceptionMode =
+                mozilla::jni::ExceptionMode::ABORT;
+        static const mozilla::jni::CallingThread callingThread =
+                mozilla::jni::CallingThread::GECKO;
+        static const mozilla::jni::DispatchTarget dispatchTarget =
+                mozilla::jni::DispatchTarget::CURRENT;
+    };
+
+    static auto EMPTY_BOOLEAN_ARRAY() -> mozilla::jni::BooleanArray::LocalRef;
+
+    static const mozilla::jni::CallingThread callingThread =
+            mozilla::jni::CallingThread::GECKO;
+
+};
+
 class HardwareCodecCapabilityUtils : public mozilla::jni::ObjectBase<HardwareCodecCapabilityUtils>
 {
 public:
     static const char name[];
 
     explicit HardwareCodecCapabilityUtils(const Context& ctx) : ObjectBase<HardwareCodecCapabilityUtils>(ctx) {}
 
     struct HasHWVP9_t {
--- a/widget/android/jni/Accessors.h
+++ b/widget/android/jni/Accessors.h
@@ -108,17 +108,17 @@ public:
 
         jvalue jargs[] = {
             Value(TypeAdapter<Args>::FromNative(env, args)).val ...
         };
 
         auto result = TypeAdapter<ReturnType>::ToNative(env,
                 Traits::isStatic ?
                 (env->*TypeAdapter<ReturnType>::StaticCall)(
-                        ctx.RawClassRef(), sID, jargs) :
+                        ctx.ClassRef(), sID, jargs) :
                 (env->*TypeAdapter<ReturnType>::Call)(
                         ctx.Get(), sID, jargs));
 
         EndAccess(ctx, rv);
         return result;
     }
 };
 
@@ -142,17 +142,17 @@ public:
         JNIEnv* const env = ctx.Env();
         Base::BeginAccess(ctx);
 
         jvalue jargs[] = {
             Value(TypeAdapter<Args>::FromNative(env, args)).val ...
         };
 
         if (Traits::isStatic) {
-            env->CallStaticVoidMethodA(ctx.RawClassRef(), Base::sID, jargs);
+            env->CallStaticVoidMethodA(ctx.ClassRef(), Base::sID, jargs);
         } else {
             env->CallVoidMethodA(ctx.Get(), Base::sID, jargs);
         }
 
         Base::EndAccess(ctx, rv);
     }
 };
 
@@ -172,17 +172,17 @@ public:
         JNIEnv* const env = ctx.Env();
         Base::BeginAccess(ctx);
 
         jvalue jargs[] = {
             Value(TypeAdapter<Args>::FromNative(env, args)).val ...
         };
 
         auto result = TypeAdapter<ReturnType>::ToNative(
-                env, env->NewObjectA(ctx.RawClassRef(), Base::sID, jargs));
+                env, env->NewObjectA(ctx.ClassRef(), Base::sID, jargs));
 
         Base::EndAccess(ctx, rv);
         return result;
     }
 };
 
 
 // Field<> is used to access a JNI field given a traits class.
@@ -227,33 +227,33 @@ public:
     {
         JNIEnv* const env = ctx.Env();
         BeginAccess(ctx);
 
         auto result = TypeAdapter<GetterType>::ToNative(
                 env, Traits::isStatic ?
 
                 (env->*TypeAdapter<GetterType>::StaticGet)
-                        (ctx.RawClassRef(), sID) :
+                        (ctx.ClassRef(), sID) :
 
                 (env->*TypeAdapter<GetterType>::Get)
                         (ctx.Get(), sID));
 
         EndAccess(ctx, rv);
         return result;
     }
 
     static void Set(const Context& ctx, nsresult* rv, SetterType val)
     {
         JNIEnv* const env = ctx.Env();
         BeginAccess(ctx);
 
         if (Traits::isStatic) {
             (env->*TypeAdapter<SetterType>::StaticSet)(
-                    ctx.RawClassRef(), sID,
+                    ctx.ClassRef(), sID,
                     TypeAdapter<SetterType>::FromNative(env, val));
         } else {
             (env->*TypeAdapter<SetterType>::Set)(
                     ctx.Get(), sID,
                     TypeAdapter<SetterType>::FromNative(env, val));
         }
 
         EndAccess(ctx, rv);
--- a/widget/android/jni/Natives.h
+++ b/widget/android/jni/Natives.h
@@ -1,15 +1,16 @@
 #ifndef mozilla_jni_Natives_h__
 #define mozilla_jni_Natives_h__
 
 #include <jni.h>
 
 #include "mozilla/IndexSequence.h"
 #include "mozilla/Move.h"
+#include "mozilla/RefPtr.h"
 #include "mozilla/Tuple.h"
 #include "mozilla/TypeTraits.h"
 #include "mozilla/UniquePtr.h"
 #include "mozilla/WeakPtr.h"
 #include "mozilla/Unused.h"
 #include "mozilla/jni/Accessors.h"
 #include "mozilla/jni/Refs.h"
 #include "mozilla/jni/Types.h"
@@ -21,78 +22,129 @@ namespace jni {
 /**
  * C++ classes implementing instance (non-static) native methods can choose
  * from one of two ownership models, when associating a C++ object with a Java
  * instance.
  *
  * * If the C++ class inherits from mozilla::SupportsWeakPtr, weak pointers
  *   will be used. The Java instance will store and own the pointer to a
  *   WeakPtr object. The C++ class itself is otherwise not owned or directly
- *   referenced. To attach a Java instance to a C++ instance, pass in a pointer
- *   to the C++ class (i.e. MyClass*).
+ *   referenced. Note that mozilla::SupportsWeakPtr only supports being used on
+ *   a single thread. To attach a Java instance to a C++ instance, pass in a
+ *   mozilla::SupportsWeakPtr pointer to the C++ class (i.e. MyClass*).
  *
  *   class MyClass : public SupportsWeakPtr<MyClass>
  *                 , public MyJavaClass::Natives<MyClass>
  *   {
  *       // ...
  *
  *   public:
  *       MOZ_DECLARE_WEAKREFERENCE_TYPENAME(MyClass)
- *       using MyJavaClass::Natives<MyClass>::Dispose;
+ *       using MyJavaClass::Natives<MyClass>::DisposeNative;
  *
  *       void AttachTo(const MyJavaClass::LocalRef& instance)
  *       {
- *           MyJavaClass::Natives<MyClass>::AttachInstance(instance, this);
+ *           MyJavaClass::Natives<MyClass>::AttachInstance(
+ *                   instance, static_cast<SupportsWeakPtr<MyClass>*>(this));
  *
  *           // "instance" does NOT own "this", so the C++ object
  *           // lifetime is separate from the Java object lifetime.
  *       }
  *   };
  *
- * * If the C++ class doesn't inherit from mozilla::SupportsWeakPtr, the Java
- *   instance will store and own a pointer to the C++ object itself. This
- *   pointer must not be stored or deleted elsewhere. To attach a Java instance
- *   to a C++ instance, pass in a reference to a UniquePtr of the C++ class
- *   (i.e. UniquePtr<MyClass>).
+ * * If the C++ class contains public members AddRef() and Release(), the Java
+ *   instance will store and own the pointer to a RefPtr object, which holds a
+ *   strong reference on the C++ instance. Normal ref-counting considerations
+ *   apply in this case; for example, disposing may cause the C++ instance to
+ *   be deleted and the destructor to be run on the current thread, which may
+ *   not be desirable. To attach a Java instance to a C++ instance, pass in a
+ *   pointer to the C++ class (i.e. MyClass*).
+ *
+ *   class MyClass : public RefCounted<MyClass>
+ *                 , public MyJavaClass::Natives<MyClass>
+ *   {
+ *       // ...
+ *
+ *   public:
+ *       using MyJavaClass::Natives<MyClass>::DisposeNative;
+ *
+ *       void AttachTo(const MyJavaClass::LocalRef& instance)
+ *       {
+ *           MyJavaClass::Natives<MyClass>::AttachInstance(instance, this);
+ *
+ *           // "instance" owns "this" through the RefPtr, so the C++ object
+ *           // may be destroyed as soon as instance.disposeNative() is called.
+ *       }
+ *   };
+ *
+ * * In other cases, the Java instance will store and own a pointer to the C++
+ *   object itself. This pointer must not be stored or deleted elsewhere. To
+ *   attach a Java instance to a C++ instance, pass in a reference to a
+ *   UniquePtr of the C++ class (i.e. UniquePtr<MyClass>).
  *
  *   class MyClass : public MyJavaClass::Natives<MyClass>
  *   {
  *       // ...
  *
  *   public:
- *       using MyJavaClass::Natives<MyClass>::Dispose;
+ *       using MyJavaClass::Natives<MyClass>::DisposeNative;
  *
  *       static void AttachTo(const MyJavaClass::LocalRef& instance)
  *       {
  *           MyJavaClass::Natives<MyClass>::AttachInstance(
  *                   instance, mozilla::MakeUnique<MyClass>());
  *
  *           // "instance" owns the newly created C++ object, so the C++
- *           // object is destroyed as soon as instance.dispose() is called.
+ *           // object is destroyed as soon as instance.disposeNative() is
+ *           // called.
  *       }
  *   };
  */
 
 namespace detail {
 
+enum NativePtrType
+{
+    OWNING,
+    WEAK,
+    REFPTR
+};
+
+template<class Impl>
+class NativePtrPicker
+{
+    template<class I> static typename EnableIf<
+            IsBaseOf<SupportsWeakPtr<I>, I>::value,
+            char(&)[NativePtrType::WEAK]>::Type Test(char);
+
+    template<class I, typename = decltype(&I::AddRef, &I::Release)>
+            static char (&Test(int))[NativePtrType::REFPTR];
+
+    template<class> static char (&Test(...))[NativePtrType::OWNING];
+
+public:
+    static const int value = sizeof(Test<Impl>('\0')) / sizeof(char);
+};
+
 inline uintptr_t CheckNativeHandle(JNIEnv* env, uintptr_t handle)
 {
     if (!handle) {
         if (!env->ExceptionCheck()) {
             ThrowException(env, "java/lang/NullPointerException",
                            "Null native pointer");
         }
         return 0;
     }
     return handle;
 }
 
-template<class Impl, bool UseWeakPtr = mozilla::IsBaseOf<
-                         SupportsWeakPtr<Impl>, Impl>::value /* = false */>
-struct NativePtr
+template<class Impl, int Type = NativePtrPicker<Impl>::value> struct NativePtr;
+
+template<class Impl>
+struct NativePtr<Impl, /* Type = */ NativePtrType::OWNING>
 {
     static Impl* Get(JNIEnv* env, jobject instance)
     {
         return reinterpret_cast<Impl*>(CheckNativeHandle(
                 env, GetNativeHandle(env, instance)));
     }
 
     template<class LocalRef>
@@ -120,30 +172,30 @@ struct NativePtr
         if (ptr) {
             SetNativeHandle(instance.Env(), instance.Get(), 0);
             MOZ_CATCH_JNI_EXCEPTION(instance.Env());
         }
     }
 };
 
 template<class Impl>
-struct NativePtr<Impl, /* UseWeakPtr = */ true>
+struct NativePtr<Impl, /* Type = */ NativePtrType::WEAK>
 {
     static Impl* Get(JNIEnv* env, jobject instance)
     {
         const auto ptr = reinterpret_cast<WeakPtr<Impl>*>(
                 CheckNativeHandle(env, GetNativeHandle(env, instance)));
         if (!ptr) {
             return nullptr;
         }
 
         Impl* const impl = *ptr;
         if (!impl) {
             ThrowException(env, "java/lang/NullPointerException",
-                           "Native object already released");
+                           "Native weak object already released");
         }
         return impl;
     }
 
     template<class LocalRef>
     static Impl* Get(const LocalRef& instance)
     {
         return Get(instance.Env(), instance.Get());
@@ -168,16 +220,61 @@ struct NativePtr<Impl, /* UseWeakPtr = *
         if (ptr) {
             SetNativeHandle(instance.Env(), instance.Get(), 0);
             MOZ_CATCH_JNI_EXCEPTION(instance.Env());
             delete ptr;
         }
     }
 };
 
+template<class Impl>
+struct NativePtr<Impl, /* Type = */ NativePtrType::REFPTR>
+{
+    static Impl* Get(JNIEnv* env, jobject instance)
+    {
+        const auto ptr = reinterpret_cast<RefPtr<Impl>*>(
+                CheckNativeHandle(env, GetNativeHandle(env, instance)));
+        if (!ptr) {
+            return nullptr;
+        }
+
+        MOZ_ASSERT(*ptr);
+        return *ptr;
+    }
+
+    template<class LocalRef>
+    static Impl* Get(const LocalRef& instance)
+    {
+        return Get(instance.Env(), instance.Get());
+    }
+
+    template<class LocalRef>
+    static void Set(const LocalRef& instance, Impl* ptr)
+    {
+        Clear(instance);
+        SetNativeHandle(instance.Env(), instance.Get(),
+                        reinterpret_cast<uintptr_t>(new RefPtr<Impl>(ptr)));
+        MOZ_CATCH_JNI_EXCEPTION(instance.Env());
+    }
+
+    template<class LocalRef>
+    static void Clear(const LocalRef& instance)
+    {
+        const auto ptr = reinterpret_cast<RefPtr<Impl>*>(
+                GetNativeHandle(instance.Env(), instance.Get()));
+        MOZ_CATCH_JNI_EXCEPTION(instance.Env());
+
+        if (ptr) {
+            SetNativeHandle(instance.Env(), instance.Get(), 0);
+            MOZ_CATCH_JNI_EXCEPTION(instance.Env());
+            delete ptr;
+        }
+    }
+};
+
 } // namespace detail
 
 using namespace detail;
 
 /**
  * For JNI native methods that are dispatched to a proxy, i.e. using
  * @WrapForJNI(dispatchTo = "proxy"), the implementing C++ class must provide a
  * OnNativeCall member. Subsequently, every native call is automatically
@@ -558,17 +655,17 @@ public:
             using LocalRef = typename Owner::LocalRef;
             Dispatcher<Impl, /* HasThisArg */ false, const LocalRef&>::
                     template Run<Traits, /* IsStatic */ true>(
                     /* ThisArg */ nullptr, DisposeNative, env, instance);
             return;
         }
 
         auto self = Owner::LocalRef::Adopt(env, instance);
-        (Impl::DisposeNative)(self);
+        DisposeNative(self);
         self.Forget();
     }
 
     // Non-void static method
     template<ReturnTypeForNonVoidStatic (*Method) (Args...)>
     static MOZ_JNICALL ReturnJNIType
     Wrap(JNIEnv* env, jclass, typename TypeAdapter<Args>::JNIType... args)
     {
@@ -663,29 +760,36 @@ public:
     }
 
 protected:
 
     // Associate a C++ instance with a Java instance.
     static void AttachNative(const typename Cls::LocalRef& instance,
                              SupportsWeakPtr<Impl>* ptr)
     {
-        static_assert(mozilla::IsBaseOf<SupportsWeakPtr<Impl>, Impl>::value,
-                      "Attach with UniquePtr&& when not using WeakPtr");
+        static_assert(NativePtrPicker<Impl>::value == NativePtrType::WEAK,
+                      "Use another AttachNative for non-WeakPtr usage");
         return NativePtr<Impl>::Set(instance, static_cast<Impl*>(ptr));
     }
 
     static void AttachNative(const typename Cls::LocalRef& instance,
                              UniquePtr<Impl>&& ptr)
     {
-        static_assert(!mozilla::IsBaseOf<SupportsWeakPtr<Impl>, Impl>::value,
-                      "Attach with SupportsWeakPtr* when using WeakPtr");
+        static_assert(NativePtrPicker<Impl>::value == NativePtrType::OWNING,
+                      "Use another AttachNative for WeakPtr or RefPtr usage");
         return NativePtr<Impl>::Set(instance, mozilla::Move(ptr));
     }
 
+    static void AttachNative(const typename Cls::LocalRef& instance, Impl* ptr)
+    {
+        static_assert(NativePtrPicker<Impl>::value == NativePtrType::REFPTR,
+                      "Use another AttachNative for non-RefPtr usage");
+        return NativePtr<Impl>::Set(instance, ptr);
+    }
+
     // Get the C++ instance associated with a Java instance.
     // There is always a pending exception if the return value is nullptr.
     static Impl* GetNative(const typename Cls::LocalRef& instance) {
         return NativePtr<Impl>::Get(instance);
     }
 
     static void DisposeNative(const typename Cls::LocalRef& instance) {
         NativePtr<Impl>::Clear(instance);
--- a/widget/android/jni/Refs.h
+++ b/widget/android/jni/Refs.h
@@ -94,16 +94,23 @@ public:
     MOZ_IMPLICIT Ref(decltype(nullptr)) : mInstance(nullptr) {}
 
     // Get the raw JNI reference.
     JNIType Get() const
     {
         return mInstance;
     }
 
+    template<class T>
+    bool IsInstanceOf() const
+    {
+        return FindEnv()->IsInstanceOf(
+                mInstance, typename T::Context().ClassRef());
+    }
+
     bool operator==(const Ref& other) const
     {
         // Treat two references of the same object as being the same.
         return mInstance == other.mInstance || JNI_FALSE !=
                 FindEnv()->IsSameObject(mInstance, other.mInstance);
     }
 
     bool operator!=(const Ref& other) const
@@ -121,16 +128,21 @@ public:
         return !!mInstance;
     }
 
     CopyableCtx operator->() const
     {
         return CopyableCtx(FindEnv(), mInstance);
     }
 
+    CopyableCtx operator*() const
+    {
+        return operator->();
+    }
+
     // Any ref can be cast to an object ref.
     operator Ref<Object, jobject>() const
     {
         return Ref<Object, jobject>(mInstance);
     }
 
     // Null checking (e.g. !!ref) using the safe-bool idiom.
     operator bool_type() const
@@ -157,21 +169,16 @@ class Context : public Ref<Cls, Type>
     using Ref = jni::Ref<Cls, Type>;
 
     static jclass sClassRef; // global reference
 
 protected:
     JNIEnv* const mEnv;
 
 public:
-    static jclass RawClassRef()
-    {
-        return sClassRef;
-    }
-
     Context()
         : Ref(nullptr)
         , mEnv(Ref::FindEnv())
     {}
 
     Context(JNIEnv* env, Type instance)
         : Ref(instance)
         , mEnv(env)
@@ -187,16 +194,23 @@ public:
         return sClassRef;
     }
 
     JNIEnv* Env() const
     {
         return mEnv;
     }
 
+    template<class T>
+    bool IsInstanceOf() const
+    {
+        return mEnv->IsInstanceOf(
+                Ref::mInstance, typename T::Context(mEnv, nullptr).ClassRef());
+    }
+
     bool operator==(const Ref& other) const
     {
         // Treat two references of the same object as being the same.
         return Ref::mInstance == other.mInstance || JNI_FALSE !=
                 mEnv->IsSameObject(Ref::mInstance, other.mInstance);
     }
 
     bool operator!=(const Ref& other) const
@@ -214,16 +228,21 @@ public:
         return !!Ref::mInstance;
     }
 
     Cls operator->() const
     {
         MOZ_ASSERT(Ref::mInstance, "Null jobject");
         return Cls(*this);
     }
+
+    const Context<Cls, Type>& operator*() const
+    {
+        return *this;
+    }
 };
 
 
 template<class Cls, typename Type = jobject>
 class ObjectBase
 {
 protected:
     const jni::Context<Cls, Type>& mCtx;
@@ -262,22 +281,40 @@ template<typename T>
 class TypedObject : public ObjectBase<TypedObject<T>, T>
 {
 public:
     explicit TypedObject(const Context<TypedObject<T>, T>& ctx)
         : ObjectBase<TypedObject<T>, T>(ctx)
     {}
 };
 
+// Binding for a boxed primitive object.
+template<typename T>
+class BoxedObject : public ObjectBase<BoxedObject<T>, jobject>
+{
+public:
+    explicit BoxedObject(const Context<BoxedObject<T>, jobject>& ctx)
+        : ObjectBase<BoxedObject<T>, jobject>(ctx)
+    {}
+};
 
 // Define bindings for built-in types.
 using String = TypedObject<jstring>;
 using Class = TypedObject<jclass>;
 using Throwable = TypedObject<jthrowable>;
 
+using Boolean = BoxedObject<jboolean>;
+using Byte = BoxedObject<jbyte>;
+using Character = BoxedObject<jchar>;
+using Short = BoxedObject<jshort>;
+using Integer = BoxedObject<jint>;
+using Long = BoxedObject<jlong>;
+using Float = BoxedObject<jfloat>;
+using Double = BoxedObject<jdouble>;
+
 using BooleanArray = TypedObject<jbooleanArray>;
 using ByteArray = TypedObject<jbyteArray>;
 using CharArray = TypedObject<jcharArray>;
 using ShortArray = TypedObject<jshortArray>;
 using IntArray = TypedObject<jintArray>;
 using LongArray = TypedObject<jlongArray>;
 using FloatArray = TypedObject<jfloatArray>;
 using DoubleArray = TypedObject<jdoubleArray>;
@@ -418,41 +455,41 @@ public:
     // Returns the same JNI type (jobject, jstring, etc.) as the underlying Ref.
     typename Ref::JNIType Forget()
     {
         const auto obj = Ctx::Get();
         Ctx::mInstance = nullptr;
         return obj;
     }
 
-    LocalRef<Cls>& operator=(LocalRef<Cls> ref)
+    LocalRef<Cls>& operator=(LocalRef<Cls> ref) &
     {
         return swap(ref);
     }
 
-    LocalRef<Cls>& operator=(const Ref& ref)
+    LocalRef<Cls>& operator=(const Ref& ref) &
     {
         LocalRef<Cls> newRef(Ctx::mEnv, ref);
         return swap(newRef);
     }
 
-    LocalRef<Cls>& operator=(LocalRef<GenericObject>&& ref)
+    LocalRef<Cls>& operator=(LocalRef<GenericObject>&& ref) &
     {
         LocalRef<Cls> newRef(mozilla::Move(ref));
         return swap(newRef);
     }
 
     template<class C>
-    LocalRef<Cls>& operator=(GenericLocalRef<C>&& ref)
+    LocalRef<Cls>& operator=(GenericLocalRef<C>&& ref) &
     {
         LocalRef<Cls> newRef(mozilla::Move(ref));
         return swap(newRef);
     }
 
-    LocalRef<Cls>& operator=(decltype(nullptr))
+    LocalRef<Cls>& operator=(decltype(nullptr)) &
     {
         LocalRef<Cls> newRef(Ctx::mEnv, nullptr);
         return swap(newRef);
     }
 };
 
 
 template<class Cls>
@@ -527,34 +564,34 @@ public:
     void Clear(JNIEnv* env)
     {
         if (Ref::mInstance) {
             env->DeleteGlobalRef(Ref::mInstance);
             Ref::mInstance = nullptr;
         }
     }
 
-    GlobalRef<Cls>& operator=(GlobalRef<Cls> ref)
+    GlobalRef<Cls>& operator=(GlobalRef<Cls> ref) &
     {
         return swap(ref);
     }
 
-    GlobalRef<Cls>& operator=(const Ref& ref)
+    GlobalRef<Cls>& operator=(const Ref& ref) &
     {
         GlobalRef<Cls> newRef(ref);
         return swap(newRef);
     }
 
-    GlobalRef<Cls>& operator=(const LocalRef<Cls>& ref)
+    GlobalRef<Cls>& operator=(const LocalRef<Cls>& ref) &
     {
         GlobalRef<Cls> newRef(ref);
         return swap(newRef);
     }
 
-    GlobalRef<Cls>& operator=(decltype(nullptr))
+    GlobalRef<Cls>& operator=(decltype(nullptr)) &
     {
         GlobalRef<Cls> newRef(nullptr);
         return swap(newRef);
     }
 };
 
 
 template<class Cls>
@@ -828,16 +865,28 @@ public:
 
 template<>
 class TypedObject<jobjectArray>
     : public ObjectBase<TypedObject<jobjectArray>, jobjectArray>
 {
     using Base = ObjectBase<TypedObject<jobjectArray>, jobjectArray>;
 
 public:
+    template<class Cls = Object>
+    static Base::LocalRef New(size_t length,
+                              typename Cls::Param initialElement = nullptr) {
+        JNIEnv* const env = GetEnvForThread();
+        jobjectArray array = env->NewObjectArray(
+                jsize(length),
+                typename Cls::Context(env, nullptr).ClassRef(),
+                initialElement.Get());
+        MOZ_CATCH_JNI_EXCEPTION(env);
+        return Base::LocalRef::Adopt(env, array);
+    }
+
     explicit TypedObject(const Context& ctx) : Base(ctx) {}
 
     size_t Length() const
     {
         const size_t ret = Base::Env()->GetArrayLength(Base::Instance());
         MOZ_CATCH_JNI_EXCEPTION(Base::Env());
         return ret;
     }
--- a/widget/android/jni/Utils.cpp
+++ b/widget/android/jni/Utils.cpp
@@ -47,16 +47,24 @@ DEFINE_PRIMITIVE_TYPE_ADAPTER(double,   
 #undef DEFINE_PRIMITIVE_TYPE_ADAPTER
 
 } // namespace detail
 
 template<> const char ObjectBase<Object, jobject>::name[] = "java/lang/Object";
 template<> const char ObjectBase<TypedObject<jstring>, jstring>::name[] = "java/lang/String";
 template<> const char ObjectBase<TypedObject<jclass>, jclass>::name[] = "java/lang/Class";
 template<> const char ObjectBase<TypedObject<jthrowable>, jthrowable>::name[] = "java/lang/Throwable";
+template<> const char ObjectBase<BoxedObject<jboolean>, jobject>::name[] = "java/lang/Boolean";
+template<> const char ObjectBase<BoxedObject<jbyte>, jobject>::name[] = "java/lang/Byte";
+template<> const char ObjectBase<BoxedObject<jchar>, jobject>::name[] = "java/lang/Character";
+template<> const char ObjectBase<BoxedObject<jshort>, jobject>::name[] = "java/lang/Short";
+template<> const char ObjectBase<BoxedObject<jint>, jobject>::name[] = "java/lang/Integer";
+template<> const char ObjectBase<BoxedObject<jlong>, jobject>::name[] = "java/lang/Long";
+template<> const char ObjectBase<BoxedObject<jfloat>, jobject>::name[] = "java/lang/Float";
+template<> const char ObjectBase<BoxedObject<jdouble>, jobject>::name[] = "java/lang/Double";
 template<> const char ObjectBase<TypedObject<jbooleanArray>, jbooleanArray>::name[] = "[Z";
 template<> const char ObjectBase<TypedObject<jbyteArray>, jbyteArray>::name[] = "[B";
 template<> const char ObjectBase<TypedObject<jcharArray>, jcharArray>::name[] = "[C";
 template<> const char ObjectBase<TypedObject<jshortArray>, jshortArray>::name[] = "[S";
 template<> const char ObjectBase<TypedObject<jintArray>, jintArray>::name[] = "[I";
 template<> const char ObjectBase<TypedObject<jlongArray>, jlongArray>::name[] = "[J";
 template<> const char ObjectBase<TypedObject<jfloatArray>, jfloatArray>::name[] = "[F";
 template<> const char ObjectBase<TypedObject<jdoubleArray>, jdoubleArray>::name[] = "[D";
--- a/widget/android/moz.build
+++ b/widget/android/moz.build
@@ -32,16 +32,17 @@ UNIFIED_SOURCES += [
     'AndroidAlerts.cpp',
     'AndroidBridge.cpp',
     'AndroidCompositorWidget.cpp',
     'AndroidContentController.cpp',
     'AndroidJavaWrappers.cpp',
     'AndroidJNI.cpp',
     'AndroidJNIWrapper.cpp',
     'ANRReporter.cpp',
+    'EventDispatcher.cpp',
     'GeneratedJNIWrappers.cpp',
     'GfxInfo.cpp',
     'NativeJSContainer.cpp',
     'nsAndroidProtocolHandler.cpp',
     'nsAppShell.cpp',
     'nsClipboard.cpp',
     'nsDeviceContextAndroid.cpp',
     'nsIdleServiceAndroid.cpp',
--- a/widget/android/nsIAndroidBridge.idl
+++ b/widget/android/nsIAndroidBridge.idl
@@ -27,16 +27,51 @@ interface nsIUITelemetryObserver : nsISu
 
 [scriptable, uuid(0370450f-2e9c-4d16-b333-8ca6ce31a5ff)]
 interface nsIAndroidBrowserApp : nsISupports {
   readonly attribute nsIBrowserTab selectedTab;
   nsIBrowserTab getBrowserTab(in int32_t tabId);
   nsIUITelemetryObserver getUITelemetryObserver();
 };
 
+[scriptable, uuid(e64c39b8-b8ec-477d-aef5-89d517ff9219)]
+interface nsIAndroidEventCallback : nsISupports
+{
+  void onSuccess([optional] in jsval data);
+  void onError([optional] in jsval data);
+};
+
+[scriptable, uuid(73569a75-78eb-4c7f-82b9-2d4f5ccf44c3)]
+interface nsIAndroidEventListener : nsISupports
+{
+  void onEvent(in AString event,
+               [optional] in jsval data,
+               [optional] in nsIAndroidEventCallback callback);
+};
+
+[scriptable, uuid(e98bf792-4145-411e-b298-8219d9b03817)]
+interface nsIAndroidEventDispatcher : nsISupports
+{
+  [implicit_jscontext]
+  void dispatch(in AString event,
+                [optional] in jsval data,
+                [optional] in nsIAndroidEventCallback callback);
+  [implicit_jscontext]
+  void registerListener(in nsIAndroidEventListener listener,
+                        in jsval events);
+  [implicit_jscontext]
+  void unregisterListener(in nsIAndroidEventListener listener,
+                          in jsval events);
+};
+
+[scriptable, uuid(60a78a94-6117-432f-9d49-304913a931c5)]
+interface nsIAndroidView : nsIAndroidEventDispatcher
+{
+};
+
 [scriptable, uuid(1beb70d3-70f3-4742-98cc-a3d301b26c0c)]
-interface nsIAndroidBridge : nsISupports
+interface nsIAndroidBridge : nsIAndroidEventDispatcher
 {
   [implicit_jscontext] void handleGeckoMessage(in jsval message);
   attribute nsIAndroidBrowserApp browserApp;
   void contentDocumentChanged(in mozIDOMWindowProxy window);
   boolean isContentDocumentDisplayed(in mozIDOMWindowProxy window);
 };
--- a/widget/android/nsWindow.cpp
+++ b/widget/android/nsWindow.cpp
@@ -284,16 +284,17 @@ class nsWindow::GeckoViewSupport final
     , public GeckoEditable::Natives<GeckoViewSupport>
     , public SupportsWeakPtr<GeckoViewSupport>
 {
     nsWindow& window;
 
 public:
     typedef GeckoView::Window::Natives<GeckoViewSupport> Base;
     typedef GeckoEditable::Natives<GeckoViewSupport> EditableBase;
+    typedef SupportsWeakPtr<GeckoViewSupport> SupportsWeakPtr;
 
     MOZ_DECLARE_WEAKREFERENCE_TYPENAME(GeckoViewSupport);
 
     template<typename Functor>
     static void OnNativeCall(Functor&& aCall)
     {
         if (aCall.IsTarget(&Open) && NS_IsMainThread()) {
             // Gecko state probably just switched to PROFILE_READY, and the
@@ -320,18 +321,19 @@ public:
         , mEditable(GeckoEditable::New(aView))
         , mIMERanges(new TextRangeArray())
         , mIMEMaskEventsCount(1) // Mask IME events since there's no focus yet
         , mIMEUpdatingContext(false)
         , mIMESelectionChanged(false)
         , mIMETextChangedDuringFlush(false)
         , mIMEMonitorCursor(false)
     {
-        Base::AttachNative(aInstance, this);
-        EditableBase::AttachNative(mEditable, this);
+        Base::AttachNative(aInstance, static_cast<SupportsWeakPtr*>(this));
+        EditableBase::AttachNative(
+                mEditable, static_cast<SupportsWeakPtr*>(this));
     }
 
     ~GeckoViewSupport();
 
     using Base::DisposeNative;
     using EditableBase::DisposeNative;
 
     /**
@@ -340,25 +342,27 @@ public:
 private:
     nsCOMPtr<nsPIDOMWindowOuter> mDOMWindow;
 
 public:
     // Create and attach a window.
     static void Open(const jni::Class::LocalRef& aCls,
                      GeckoView::Window::Param aWindow,
                      GeckoView::Param aView, jni::Object::Param aCompositor,
+                     jni::Object::Param aDispatcher,
                      jni::String::Param aChromeURI,
                      int32_t screenId);
 
     // Close and destroy the nsWindow.
     void Close();
 
     // Reattach this nsWindow to a new GeckoView.
     void Reattach(const GeckoView::Window::LocalRef& inst,
-                  GeckoView::Param aView, jni::Object::Param aCompositor);
+                  GeckoView::Param aView, jni::Object::Param aCompositor,
+                  jni::Object::Param aDispatcher);
 
     void LoadUri(jni::String::Param aUri, int32_t aFlags);
 
     /**
      * GeckoEditable methods
      */
 private:
     /*
@@ -925,16 +929,20 @@ public:
     {
         mNPZC->OnSelectionDragState(aState);
     }
 };
 
 template<> const char
 nsWindow::NativePtr<nsWindow::NPZCSupport>::sName[] = "NPZCSupport";
 
+NS_IMPL_ISUPPORTS(nsWindow::AndroidView,
+                  nsIAndroidEventDispatcher,
+                  nsIAndroidView)
+
 /**
  * Compositor has some unique requirements for its native calls, so make it
  * separate from GeckoViewSupport.
  */
 class nsWindow::LayerViewSupport final
     : public LayerView::Compositor::Natives<LayerViewSupport>
 {
     using LockedWindowPtr = WindowPtr<LayerViewSupport>::Locked;
@@ -1313,16 +1321,17 @@ nsWindow::GeckoViewSupport::~GeckoViewSu
     }
 }
 
 /* static */ void
 nsWindow::GeckoViewSupport::Open(const jni::Class::LocalRef& aCls,
                                  GeckoView::Window::Param aWindow,
                                  GeckoView::Param aView,
                                  jni::Object::Param aCompositor,
+                                 jni::Object::Param aDispatcher,
                                  jni::String::Param aChromeURI,
                                  int32_t aScreenId)
 {
     MOZ_ASSERT(NS_IsMainThread());
 
     PROFILER_LABEL("nsWindow", "GeckoViewSupport::Open",
                    js::ProfileEntry::Category::OTHER);
 
@@ -1334,19 +1343,23 @@ nsWindow::GeckoViewSupport::Open(const j
         url = aChromeURI->ToCString();
     } else {
         url = Preferences::GetCString("toolkit.defaultChromeURI");
         if (!url) {
             url = NS_LITERAL_CSTRING("chrome://browser/content/browser.xul");
         }
     }
 
+    RefPtr<AndroidView> androidView = new AndroidView();
+    androidView->mEventDispatcher->Attach(
+            java::EventDispatcher::Ref::From(aDispatcher), nullptr);
+
     nsCOMPtr<mozIDOMWindowProxy> domWindow;
     ww->OpenWindow(nullptr, url, nullptr, "chrome,dialog=0,resizable,scrollbars=yes",
-                   nullptr, getter_AddRefs(domWindow));
+                   androidView, getter_AddRefs(domWindow));
     MOZ_RELEASE_ASSERT(domWindow);
 
     nsCOMPtr<nsPIDOMWindowOuter> pdomWindow =
             nsPIDOMWindowOuter::From(domWindow);
     nsCOMPtr<nsIWidget> widget = WidgetUtils::DOMWindowToWidget(pdomWindow);
     MOZ_ASSERT(widget);
 
     const auto window = static_cast<nsWindow*>(widget.get());
@@ -1358,42 +1371,52 @@ nsWindow::GeckoViewSupport::Open(const j
 
     window->mGeckoViewSupport->mDOMWindow = pdomWindow;
 
     // Attach the Compositor to the new window.
     auto compositor = LayerView::Compositor::LocalRef(
             aCls.Env(), LayerView::Compositor::Ref::From(aCompositor));
     window->mLayerViewSupport.Attach(compositor, window, compositor);
 
+    // Attach again using the new window.
+    androidView->mEventDispatcher->Attach(
+            java::EventDispatcher::Ref::From(aDispatcher), pdomWindow);
+    window->mAndroidView = androidView;
+
     if (window->mWidgetListener) {
         nsCOMPtr<nsIXULWindow> xulWindow(
                 window->mWidgetListener->GetXULWindow());
         if (xulWindow) {
             // Our window is not intrinsically sized, so tell nsXULWindow to
             // not set a size for us.
             xulWindow->SetIntrinsicallySized(false);
         }
     }
 }
 
 void
 nsWindow::GeckoViewSupport::Close()
 {
+    if (window.mAndroidView) {
+        window.mAndroidView->mEventDispatcher->Detach();
+    }
+
     if (!mDOMWindow) {
         return;
     }
 
     mDOMWindow->ForceClose();
     mDOMWindow = nullptr;
 }
 
 void
 nsWindow::GeckoViewSupport::Reattach(const GeckoView::Window::LocalRef& inst,
                                      GeckoView::Param aView,
-                                     jni::Object::Param aCompositor)
+                                     jni::Object::Param aCompositor,
+                                     jni::Object::Param aDispatcher)
 {
     // Associate our previous GeckoEditable with the new GeckoView.
     mEditable->OnViewChange(aView);
 
     // mNPZCSupport might have already been detached through the Java side calling
     // NativePanZoomController.destroy().
     if (window.mNPZCSupport) {
         window.mNPZCSupport.Detach();
@@ -1401,16 +1424,20 @@ nsWindow::GeckoViewSupport::Reattach(con
 
     MOZ_ASSERT(window.mLayerViewSupport);
     window.mLayerViewSupport.Detach();
 
     auto compositor = LayerView::Compositor::LocalRef(
             inst.Env(), LayerView::Compositor::Ref::From(aCompositor));
     window.mLayerViewSupport.Attach(compositor, &window, compositor);
     compositor->Reattach();
+
+    MOZ_ASSERT(window.mAndroidView);
+    window.mAndroidView->mEventDispatcher->Attach(
+            java::EventDispatcher::Ref::From(aDispatcher), mDOMWindow);
 }
 
 void
 nsWindow::GeckoViewSupport::LoadUri(jni::String::Param aUri, int32_t aFlags)
 {
     if (!mDOMWindow) {
         return;
     }
--- a/widget/android/nsWindow.h
+++ b/widget/android/nsWindow.h
@@ -7,16 +7,17 @@
 #ifndef NSWINDOW_H_
 #define NSWINDOW_H_
 
 #include "nsBaseWidget.h"
 #include "gfxPoint.h"
 #include "nsIIdleServiceInternal.h"
 #include "nsTArray.h"
 #include "AndroidJavaWrappers.h"
+#include "EventDispatcher.h"
 #include "GeneratedJNIWrappers.h"
 #include "mozilla/EventForwards.h"
 #include "mozilla/Mutex.h"
 #include "mozilla/StaticPtr.h"
 #include "mozilla/TextRange.h"
 #include "mozilla/UniquePtr.h"
 
 struct ANPEvent;
@@ -91,16 +92,34 @@ private:
 
         Impl* operator->() const { return operator Impl*(); }
 
         template<class Instance, typename... Args>
         void Attach(Instance aInstance, nsWindow* aWindow, Args&&... aArgs);
         void Detach();
     };
 
+    class AndroidView final : public nsIAndroidView
+    {
+        virtual ~AndroidView() {}
+
+    public:
+        const RefPtr<mozilla::widget::EventDispatcher> mEventDispatcher{
+            new mozilla::widget::EventDispatcher()};
+
+        AndroidView() {}
+
+        NS_DECL_ISUPPORTS
+        NS_DECL_NSIANDROIDVIEW
+
+        NS_FORWARD_NSIANDROIDEVENTDISPATCHER(mEventDispatcher->)
+    };
+
+    RefPtr<AndroidView> mAndroidView;
+
     class LayerViewSupport;
     // Object that implements native LayerView calls.
     // Owned by the Java LayerView instance.
     NativePtr<LayerViewSupport> mLayerViewSupport;
 
     class NPZCSupport;
     // Object that implements native NativePanZoomController calls.
     // Owned by the Java NativePanZoomController instance.