Bug 1337467 - Convert observers to bundle events; r=rbarker r=sebastian
authorJim Chen <nchen@mozilla.com>
Tue, 07 Mar 2017 12:34:04 -0500
changeset 346373 8098ab33c1b6d18f929dcc5abe5b3e92c9f835bf
parent 346372 6351f15cf90b4f2a5d340deca535442e880f3004
child 346374 6758f6e0b836e38e21e2ca4a346f9b205d8b72f1
push id31465
push userkwierso@gmail.com
push dateWed, 08 Mar 2017 00:40:52 +0000
treeherdermozilla-central@58753259bfeb [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrbarker, sebastian
bugs1337467
milestone55.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1337467 - Convert observers to bundle events; r=rbarker r=sebastian Bug 1337467 - 1. Convert "Window:Resize" observer to event; r=rbarker Bug 1337467 - 2. Convert "ScrollTo:FocusedInput" observer to event; r=rbarker Bug 1337467 - 3. Convert "Update:CheckResult" observer to event; r=sebastian Also remove notifyCheckUpdateResult from GeckoInterface. Bug 1337467 - 4. Convert "GeckoView:ImportScript" observer to event; r=sebastian Bug 1337467 - 5. Convert accessibility observers to events; r=sebastian Bug 1337467 - 6. Convert media/casting observers to events; r=sebastian Bug 1337467 - 7. Convert "Sanitize:ClearData" observer to event; r=sebastian Bug 1337467 - 8. Convert "Notification:Event" observer to event; r=sebastian Bug 1337467 - 9. Convert BrowserApp observers to events; r=sebastian Bug 1337467 - 10. Convert Tab observers to events; r=sebastian Bug 1337467 - 11. Convert "Passwords:Init" and "FormHistory:Init" observers to events; r=sebastian Bug 1337467 - 12. Convert Reader observers to events; r=sebastian Bug 1337467 - 13. Convert Distribution observers to events; r=sebastian Bug 1337467 - 14. Convert "Fonts:Reload" observer to event; r=sebastian Bug 1337467 - 15. Convert RecentTabsAdapter observers to events; r=sebastian Bug 1337467 - 16. Convert "Session:Prefetch" observer to event; r=sebastian Bug 1337467 - 17. Convert "Browser:Quit" and "FullScreen:Exit" observers to events; r=sebastian Bug 1337467 - 18. Convert SessionStore observers to events; r=sebastian The "Session:NotifyLocationChange" observer is sent by browser.js and requires passing a browser reference, so it's left as an observer. Bug 1337467 - 19. Remove unused "Tab:Screenshot:Cancel" notifyObserver call; r=me Bug 1337467 - 20. Convert "Session:Navigate" observer to event; r=sebastian Bug 1337467 - 21. Convert "Locale:*" observers to events; r=sebastian Bug 1337467 - Add log for unhandled events; r=me Add back the log indicating no listener for an event, which can be useful when reading logcat. r=me for trivial change. Bug 1337467 - Don't return error from EventDispatcher when OnEvent fails; r=me When a listener's OnEvent method returns an error, continue to dispatch to other listeners and don't return an error from the dispatch function. This avoids unexpected errors when dispatching events. r=me for trivial patch.
accessible/jsat/AccessFu.jsm
dom/presentation/provider/AndroidCastDeviceProvider.js
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java
mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java
mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java
mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java
mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java
mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java
mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java
mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java
mobile/android/base/java/org/mozilla/gecko/Tab.java
mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java
mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java
mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java
mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java
mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java
mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java
mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java
mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java
mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java
mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java
mobile/android/chrome/content/CastingApps.js
mobile/android/chrome/content/EmbedRT.js
mobile/android/chrome/content/FeedHandler.js
mobile/android/chrome/content/Feedback.js
mobile/android/chrome/content/Reader.js
mobile/android/chrome/content/WebcompatReporter.js
mobile/android/chrome/content/about.js
mobile/android/chrome/content/browser.js
mobile/android/components/SessionStore.js
mobile/android/geckoview/src/main/java/org/mozilla/gecko/BaseGeckoInterface.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAccessibility.java
mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.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/modules/Notifications.jsm
mobile/android/tests/browser/chrome/test_session_zombification.html
mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testAndroidCastDeviceProvider.java
mobile/android/tests/browser/robocop/testAndroidCastDeviceProvider.js
widget/android/EventDispatcher.cpp
--- a/accessible/jsat/AccessFu.jsm
+++ b/accessible/jsat/AccessFu.jsm
@@ -10,16 +10,20 @@
 
 const {utils: Cu, interfaces: Ci} = Components;
 
 this.EXPORTED_SYMBOLS = ['AccessFu']; // jshint ignore:line
 
 Cu.import('resource://gre/modules/Services.jsm');
 Cu.import('resource://gre/modules/accessibility/Utils.jsm');
 
+if (Utils.MozBuildApp === 'mobile/android') {
+  Cu.import('resource://gre/modules/Messaging.jsm');
+}
+
 const ACCESSFU_DISABLE = 0; // jshint ignore:line
 const ACCESSFU_ENABLE = 1;
 const ACCESSFU_AUTO = 2;
 
 const SCREENREADER_SETTING = 'accessibility.screenreader';
 const QUICKNAV_MODES_PREF = 'accessibility.accessfu.quicknav_modes';
 const QUICKNAV_INDEX_PREF = 'accessibility.accessfu.quicknav_index';
 
@@ -27,21 +31,19 @@ this.AccessFu = { // jshint ignore:line
   /**
    * Initialize chrome-layer accessibility functionality.
    * If accessibility is enabled on the platform, then a special accessibility
    * mode is started.
    */
   attach: function attach(aWindow) {
     Utils.init(aWindow);
 
-    try {
-      Services.androidBridge.dispatch('Accessibility:Ready');
-      Services.obs.addObserver(this, 'Accessibility:Settings', false);
-    } catch (x) {
-      // Not on Android
+    if (Utils.MozBuildApp === 'mobile/android') {
+      EventDispatcher.instance.dispatch('Accessibility:Ready');
+      EventDispatcher.instance.registerListener(this, 'Accessibility:Settings');
     }
 
     this._activatePref = new PrefCache(
       'accessibility.accessfu.activate', this._enableOrDisable.bind(this));
 
     this._enableOrDisable();
   },
 
@@ -49,17 +51,17 @@ this.AccessFu = { // jshint ignore:line
    * Shut down chrome-layer accessibility functionality from the outside.
    */
   detach: function detach() {
     // Avoid disabling twice.
     if (this._enabled) {
       this._disable();
     }
     if (Utils.MozBuildApp === 'mobile/android') {
-      Services.obs.removeObserver(this, 'Accessibility:Settings');
+      EventDispatcher.instance.unregisterListener(this, 'Accessibility:Settings');
     }
     delete this._activatePref;
     Utils.uninit();
   },
 
   /**
    * A lazy getter for event handler that binds the scope to AccessFu object.
    */
@@ -115,26 +117,31 @@ this.AccessFu = { // jshint ignore:line
     this._notifyOutputPref =
       new PrefCache('accessibility.accessfu.notify_output');
 
 
     this.Input.start();
     Output.start();
     PointerAdapter.start();
 
+    if (Utils.MozBuildApp === 'mobile/android') {
+      EventDispatcher.instance.registerListener(this, [
+        'Accessibility:ActivateObject',
+        'Accessibility:Focus',
+        'Accessibility:LongPress',
+        'Accessibility:MoveByGranularity',
+        'Accessibility:NextObject',
+        'Accessibility:PreviousObject',
+        'Accessibility:ScrollBackward',
+        'Accessibility:ScrollForward',
+      ]);
+    }
+
     Services.obs.addObserver(this, 'remote-browser-shown', false);
     Services.obs.addObserver(this, 'inprocess-browser-shown', false);
-    Services.obs.addObserver(this, 'Accessibility:NextObject', false);
-    Services.obs.addObserver(this, 'Accessibility:PreviousObject', false);
-    Services.obs.addObserver(this, 'Accessibility:Focus', false);
-    Services.obs.addObserver(this, 'Accessibility:ActivateObject', false);
-    Services.obs.addObserver(this, 'Accessibility:LongPress', false);
-    Services.obs.addObserver(this, 'Accessibility:ScrollForward', false);
-    Services.obs.addObserver(this, 'Accessibility:ScrollBackward', false);
-    Services.obs.addObserver(this, 'Accessibility:MoveByGranularity', false);
     Utils.win.addEventListener('TabOpen', this);
     Utils.win.addEventListener('TabClose', this);
     Utils.win.addEventListener('TabSelect', this);
 
     if (this.readyCallback) {
       this.readyCallback();
       delete this.readyCallback;
     }
@@ -164,24 +171,29 @@ this.AccessFu = { // jshint ignore:line
     PointerAdapter.stop();
 
     Utils.win.removeEventListener('TabOpen', this);
     Utils.win.removeEventListener('TabClose', this);
     Utils.win.removeEventListener('TabSelect', this);
 
     Services.obs.removeObserver(this, 'remote-browser-shown');
     Services.obs.removeObserver(this, 'inprocess-browser-shown');
-    Services.obs.removeObserver(this, 'Accessibility:NextObject');
-    Services.obs.removeObserver(this, 'Accessibility:PreviousObject');
-    Services.obs.removeObserver(this, 'Accessibility:Focus');
-    Services.obs.removeObserver(this, 'Accessibility:ActivateObject');
-    Services.obs.removeObserver(this, 'Accessibility:LongPress');
-    Services.obs.removeObserver(this, 'Accessibility:ScrollForward');
-    Services.obs.removeObserver(this, 'Accessibility:ScrollBackward');
-    Services.obs.removeObserver(this, 'Accessibility:MoveByGranularity');
+
+    if (Utils.MozBuildApp === 'mobile/android') {
+      EventDispatcher.instance.unregisterListener(this, [
+        'Accessibility:ActivateObject',
+        'Accessibility:Focus',
+        'Accessibility:LongPress',
+        'Accessibility:MoveByGranularity',
+        'Accessibility:NextObject',
+        'Accessibility:PreviousObject',
+        'Accessibility:ScrollBackward',
+        'Accessibility:ScrollForward',
+      ]);
+    }
 
     delete this._quicknavModesPref;
     delete this._notifyOutputPref;
 
     if (this.doneCallback) {
       this.doneCallback();
       delete this.doneCallback;
     }
@@ -283,53 +295,57 @@ this.AccessFu = { // jshint ignore:line
 
   _handleMessageManager: function _handleMessageManager(aMessageManager) {
     if (this._enabled) {
       this._addMessageListeners(aMessageManager);
     }
     this._loadFrameScript(aMessageManager);
   },
 
-  observe: function observe(aSubject, aTopic, aData) {
-    switch (aTopic) {
+  onEvent: function (event, data, callback) {
+    switch (event) {
       case 'Accessibility:Settings':
-        this._systemPref = JSON.parse(aData).enabled;
+        this._systemPref = data.enabled;
         this._enableOrDisable();
         break;
       case 'Accessibility:NextObject':
-      case 'Accessibility:PreviousObject':
-      {
-        let rule = aData ?
-          aData.substr(0, 1).toUpperCase() + aData.substr(1).toLowerCase() :
+      case 'Accessibility:PreviousObject': {
+        let rule = data ?
+          data.rule.substr(0, 1).toUpperCase() + data.rule.substr(1).toLowerCase() :
           'Simple';
-        let method = aTopic.replace(/Accessibility:(\w+)Object/, 'move$1');
+        let method = event.replace(/Accessibility:(\w+)Object/, 'move$1');
         this.Input.moveCursor(method, rule, 'gesture');
         break;
       }
       case 'Accessibility:ActivateObject':
-        this.Input.activateCurrent(JSON.parse(aData));
+        this.Input.activateCurrent(data);
         break;
       case 'Accessibility:LongPress':
         this.Input.sendContextMenuMessage();
         break;
       case 'Accessibility:ScrollForward':
         this.Input.androidScroll('forward');
         break;
       case 'Accessibility:ScrollBackward':
         this.Input.androidScroll('backward');
         break;
       case 'Accessibility:Focus':
-        this._focused = JSON.parse(aData);
+        this._focused = data.gainFocus;
         if (this._focused) {
           this.autoMove({ forcePresent: true, noOpIfOnScreen: true });
         }
         break;
       case 'Accessibility:MoveByGranularity':
-        this.Input.moveByGranularity(JSON.parse(aData));
+        this.Input.moveByGranularity(data);
         break;
+    }
+  },
+
+  observe: function observe(aSubject, aTopic, aData) {
+    switch (aTopic) {
       case 'remote-browser-shown':
       case 'inprocess-browser-shown':
       {
         // Ignore notifications that aren't from a Browser
         let frameLoader = aSubject.QueryInterface(Ci.nsIFrameLoader);
         if (!frameLoader.ownerIsMozBrowserFrame) {
           return;
         }
--- a/dom/presentation/provider/AndroidCastDeviceProvider.js
+++ b/dom/presentation/provider/AndroidCastDeviceProvider.js
@@ -353,22 +353,21 @@ ChromecastRemoteDisplayDevice.prototype 
 
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIPresentationDevice,
                                          Ci.nsIPresentationLocalDevice,
                                          Ci.nsISupportsWeakReference,
                                          Ci.nsIObserver]),
 };
 
 function AndroidCastDeviceProvider() {
+  this._listener = null;
+  this._deviceList = new Map();
 }
 
 AndroidCastDeviceProvider.prototype = {
-  _listener: null,
-  _deviceList: new Map(),
-
   onSessionRequest: function APDP_onSessionRequest(aDeviceId,
                                                    aUrl,
                                                    aPresentationId,
                                                    aControlChannel) {
     log("AndroidCastDeviceProvider - onSessionRequest"
         + " aDeviceId=" + aDeviceId);
     let device = this._deviceList.get(aDeviceId);
     let receiverDevice = new ChromecastRemoteDisplayDevice(this,
@@ -398,45 +397,48 @@ AndroidCastDeviceProvider.prototype = {
 
   // nsIPresentationDeviceProvider
   set listener(aListener) {
     this._listener = aListener;
 
     // When unload this provider.
     if (!this._listener) {
       // remove observer
-      Services.obs.removeObserver(this, TOPIC_ANDROID_CAST_DEVICE_ADDED);
-      Services.obs.removeObserver(this, TOPIC_ANDROID_CAST_DEVICE_CHANGED);
-      Services.obs.removeObserver(this, TOPIC_ANDROID_CAST_DEVICE_REMOVED);
+      EventDispatcher.instance.unregisterListener(this, [
+        TOPIC_ANDROID_CAST_DEVICE_ADDED,
+        TOPIC_ANDROID_CAST_DEVICE_CHANGED,
+        TOPIC_ANDROID_CAST_DEVICE_REMOVED,
+      ]);
       return;
     }
 
+    // Observer registration
+    EventDispatcher.instance.registerListener(this, [
+      TOPIC_ANDROID_CAST_DEVICE_ADDED,
+      TOPIC_ANDROID_CAST_DEVICE_CHANGED,
+      TOPIC_ANDROID_CAST_DEVICE_REMOVED,
+    ]);
+
     // Sync all device already found by Android.
     EventDispatcher.instance.sendRequest({ type: TOPIC_ANDROID_CAST_DEVICE_SYNCDEVICE });
-    // Observer registration
-    Services.obs.addObserver(this, TOPIC_ANDROID_CAST_DEVICE_ADDED, false);
-    Services.obs.addObserver(this, TOPIC_ANDROID_CAST_DEVICE_CHANGED, false);
-    Services.obs.addObserver(this, TOPIC_ANDROID_CAST_DEVICE_REMOVED, false);
   },
 
   get listener() {
     return this._listener;
   },
 
   forceDiscovery: function APDP_forceDiscovery() {
     // There is no API to do force discovery in Android SDK.
   },
 
-  // nsIObserver
-  observe: function APDP_observe(aSubject, aTopic, aData) {
-    log('observe ' + aTopic + ': ' + aData);
-    switch (aTopic) {
+  onEvent: function APDP_onEvent(event, data, callback) {
+    switch (event) {
       case TOPIC_ANDROID_CAST_DEVICE_ADDED:
       case TOPIC_ANDROID_CAST_DEVICE_CHANGED: {
-        let deviceInfo = JSON.parse(aData);
+        let deviceInfo = data;
         let deviceId   = deviceInfo.uuid;
 
         if (!this._deviceList.has(deviceId)) {
           let device = new ChromecastRemoteDisplayDevice(this,
                                                          deviceInfo.uuid,
                                                          deviceInfo.friendlyName,
                                                          Ci.nsIPresentationService.ROLE_CONTROLLER);
           this._deviceList.set(device.id, device);
@@ -444,17 +446,17 @@ AndroidCastDeviceProvider.prototype = {
         } else {
           let device = this._deviceList.get(deviceId);
           device.update(deviceInfo.friendlyName);
           this._listener.updateDevice(device);
         }
         break;
       }
       case TOPIC_ANDROID_CAST_DEVICE_REMOVED: {
-        let deviceId = aData;
+        let deviceId = data.id;
         if (!this._deviceList.has(deviceId)) {
           break;
         }
 
         let device   = this._deviceList.get(deviceId);
         this._listener.removeDevice(device);
         this._deviceList.delete(deviceId);
         break;
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -11,17 +11,16 @@ import android.app.DownloadManager;
 import android.content.ContentProviderClient;
 import android.os.Environment;
 import android.os.Process;
 import android.support.annotation.NonNull;
 import android.support.annotation.UiThread;
 
 import android.graphics.Rect;
 
-import org.json.JSONArray;
 import org.mozilla.gecko.activitystream.ActivityStream;
 import org.mozilla.gecko.adjust.AdjustBrowserAppDelegate;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.DynamicToolbar.VisibilityTransition;
 import org.mozilla.gecko.Tabs.TabEvents;
 import org.mozilla.gecko.animation.PropertyAnimator;
 import org.mozilla.gecko.animation.ViewHelper;
@@ -163,18 +162,16 @@ import android.view.animation.Interpolat
 import android.widget.Button;
 import android.widget.ListView;
 import android.widget.RelativeLayout;
 import android.widget.ViewFlipper;
 import org.mozilla.gecko.switchboard.AsyncConfigLoader;
 import org.mozilla.gecko.switchboard.SwitchBoard;
 import android.animation.Animator;
 import android.animation.ObjectAnimator;
-import org.json.JSONException;
-import org.json.JSONObject;
 
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.lang.reflect.Method;
 import java.net.URLEncoder;
 import java.util.Arrays;
 import java.util.Collections;
@@ -1376,23 +1373,19 @@ public class BrowserApp extends GeckoApp
             }
             return true;
         }
 
         if (itemId == R.id.subscribe) {
             // This can be selected from either the browser menu or the contextmenu, depending on the size and version (v11+) of the phone.
             Tab tab = Tabs.getInstance().getSelectedTab();
             if (tab != null && tab.hasFeeds()) {
-                JSONObject args = new JSONObject();
-                try {
-                    args.put("tabId", tab.getId());
-                } catch (JSONException e) {
-                    Log.e(LOGTAG, "error building json arguments", e);
-                }
-                GeckoAppShell.notifyObservers("Feeds:Subscribe", args.toString());
+                final GeckoBundle args = new GeckoBundle(1);
+                args.putInt("tabId", tab.getId());
+                EventDispatcher.getInstance().dispatch("Feeds:Subscribe", args);
             }
             return true;
         }
 
         if (itemId == R.id.add_search_engine) {
             // This can be selected from either the browser menu or the contextmenu, depending on the size and version (v11+) of the phone.
             Tab tab = Tabs.getInstance().getSelectedTab();
             if (tab != null && tab.hasOpenSearch()) {
@@ -1912,17 +1905,19 @@ public class BrowserApp extends GeckoApp
                     codeArray[i] = charset.getString("code");
                 }
 
                 final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
                 dialogBuilder.setSingleChoiceItems(titleArray, selected,
                         new AlertDialog.OnClickListener() {
                             @Override
                             public void onClick(final DialogInterface dialog, final int which) {
-                                GeckoAppShell.notifyObservers("CharEncoding:Set", codeArray[which]);
+                                final GeckoBundle data = new GeckoBundle(1);
+                                data.putString("encoding", codeArray[which]);
+                                EventDispatcher.getInstance().dispatch("CharEncoding:Set", data);
                                 dialog.dismiss();
                             }
                         });
                 dialogBuilder.setNegativeButton(R.string.button_cancel,
                         new AlertDialog.OnClickListener() {
                             @Override
                             public void onClick(final DialogInterface dialog, final int which) {
                                 dialog.dismiss();
@@ -1936,18 +1931,17 @@ public class BrowserApp extends GeckoApp
                 GeckoPreferences.setCharEncodingState(visible);
                 if (mMenu != null) {
                     mMenu.findItem(R.id.char_encoding).setVisible(visible);
                 }
                 break;
 
             case "Experiments:GetActive":
                 final List<String> experiments = SwitchBoard.getActiveExperiments(this);
-                final JSONArray json = new JSONArray(experiments);
-                callback.sendSuccess(json.toString());
+                callback.sendSuccess(experiments.toArray(new String[experiments.size()]));
                 break;
 
             case "Experiments:SetOverride":
                 Experiments.setOverride(getContext(), message.getString("name"),
                                         message.getBoolean("isEnabled"));
                 break;
 
             case "Experiments:ClearOverride":
@@ -3157,17 +3151,19 @@ public class BrowserApp extends GeckoApp
             }
         }
 
         final MenuItem item = destination.add(Menu.NONE, info.id, Menu.NONE, info.label);
 
         item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
             @Override
             public boolean onMenuItemClick(MenuItem item) {
-                GeckoAppShell.notifyObservers("Menu:Clicked", Integer.toString(info.id - ADDON_MENU_OFFSET));
+                final GeckoBundle data = new GeckoBundle(1);
+                data.putInt("item", info.id - ADDON_MENU_OFFSET);
+                EventDispatcher.getInstance().dispatch("Menu:Clicked", data);
                 return true;
             }
         });
 
         item.setCheckable(info.checkable);
         item.setChecked(info.checked);
         item.setEnabled(info.enabled);
         item.setVisible(info.visible);
@@ -3669,17 +3665,17 @@ public class BrowserApp extends GeckoApp
         if (itemId == R.id.history_list) {
             final String url = AboutPages.getURLForBuiltinPanelType(PanelType.COMBINED_HISTORY);
             Tabs.getInstance().loadUrl(url);
             return true;
         }
 
         if (itemId == R.id.save_as_pdf) {
             Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, "pdf");
-            GeckoAppShell.notifyObservers("SaveAs:PDF", null);
+            EventDispatcher.getInstance().dispatch("SaveAs:PDF", null);
             return true;
         }
 
         if (itemId == R.id.print) {
             Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, "print");
             PrintHelper.printPDF(this);
             return true;
         }
@@ -3714,37 +3710,33 @@ public class BrowserApp extends GeckoApp
         }
 
         if (itemId == R.id.downloads) {
             Tabs.getInstance().loadUrlInTab(AboutPages.DOWNLOADS);
             return true;
         }
 
         if (itemId == R.id.char_encoding) {
-            GeckoAppShell.notifyObservers("CharEncoding:Get", null);
+            EventDispatcher.getInstance().dispatch("CharEncoding:Get", null);
             return true;
         }
 
         if (itemId == R.id.find_in_page) {
             mFindInPageBar.show();
             return true;
         }
 
         if (itemId == R.id.desktop_mode) {
             Tab selectedTab = Tabs.getInstance().getSelectedTab();
             if (selectedTab == null)
                 return true;
-            JSONObject args = new JSONObject();
-            try {
-                args.put("desktopMode", !item.isChecked());
-                args.put("tabId", selectedTab.getId());
-            } catch (JSONException e) {
-                Log.e(LOGTAG, "error building json arguments", e);
-            }
-            GeckoAppShell.notifyObservers("DesktopMode:Change", args.toString());
+            final GeckoBundle args = new GeckoBundle(2);
+            args.putBoolean("desktopMode", !item.isChecked());
+            args.putInt("tabId", selectedTab.getId());
+            EventDispatcher.getInstance().dispatch("DesktopMode:Change", args);
             return true;
         }
 
         if (itemId == R.id.new_tab) {
             addTab();
             return true;
         }
 
@@ -3947,38 +3939,28 @@ public class BrowserApp extends GeckoApp
             int launchCount = settings.getInt(keyName, 0);
             if (launchCount < FEEDBACK_LAUNCH_COUNT) {
                 // Increment the launch count and store the new value.
                 launchCount++;
                 settings.edit().putInt(keyName, launchCount).apply();
 
                 // If we've reached our magic number, show the feedback page.
                 if (launchCount == FEEDBACK_LAUNCH_COUNT) {
-                    GeckoAppShell.notifyObservers("Feedback:Show", null);
+                    EventDispatcher.getInstance().dispatch("Feedback:Show", null);
                 }
             }
         } finally {
             StrictMode.setThreadPolicy(savedPolicy);
         }
     }
 
     public void openUrls(List<String> urls) {
-        try {
-            JSONArray array = new JSONArray();
-            for (String url : urls) {
-                array.put(url);
-            }
-
-            JSONObject object = new JSONObject();
-            object.put("urls", array);
-
-            GeckoAppShell.notifyObservers("Tabs:OpenMultiple", object.toString());
-        } catch (JSONException e) {
-            Log.e(LOGTAG, "Unable to create JSON for opening multiple URLs");
-        }
+        final GeckoBundle data = new GeckoBundle(1);
+        data.putStringArray("urls", urls.toArray(new String[urls.size()]));
+        EventDispatcher.getInstance().dispatch("Tabs:OpenMultiple", data);
     }
 
     private void showTabQueuePromptIfApplicable(final SafeIntent intent) {
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
                 // We only want to show the prompt if the browser has been opened from an external url
                 if (TabQueueHelper.TAB_QUEUE_ENABLED && mInitialized
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java
@@ -12,16 +12,17 @@ import java.util.Locale;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.GeckoJarReader;
 
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.SharedPreferences;
 import android.content.res.Configuration;
@@ -215,18 +216,19 @@ public class BrowserLocaleManager implem
             return;
         }
 
         // Store the Java-native form.
         prefs.edit().putString("osLocale", osLocaleString).apply();
 
         // The value we send to Gecko should be a language tag, not
         // a Java locale string.
-        final String osLanguageTag = Locales.getLanguageTag(osLocale);
-        GeckoAppShell.notifyObservers("Locale:OS", osLanguageTag);
+        final GeckoBundle data = new GeckoBundle(1);
+        data.putString("languageTag", Locales.getLanguageTag(osLocale));
+        EventDispatcher.getInstance().dispatch("Locale:OS", data);
     }
 
     @Override
     public String getAndApplyPersistedLocale(Context context) {
         initialize(context);
 
         final long t1 = android.os.SystemClock.uptimeMillis();
         final String localeCode = getPersistedLocale(context);
@@ -260,32 +262,34 @@ public class BrowserLocaleManager implem
         // We always persist and notify Gecko, even if nothing seemed to
         // change. This might happen if you're picking a locale that's the same
         // as the current OS locale. The OS locale might change next time we
         // launch, and we need the Gecko pref and persisted locale to have been
         // set by the time that happens.
         persistLocale(context, localeCode);
 
         // Tell Gecko.
-        GeckoAppShell.notifyObservers(EVENT_LOCALE_CHANGED, Locales.getLanguageTag(getCurrentLocale(context)));
+        final GeckoBundle data = new GeckoBundle(1);
+        data.putString("languageTag", Locales.getLanguageTag(getCurrentLocale(context)));
+        EventDispatcher.getInstance().dispatch(EVENT_LOCALE_CHANGED, data);
 
         return resultant;
     }
 
     @Override
     public void resetToSystemLocale(Context context) {
         // Wipe the pref.
         final SharedPreferences settings = getSharedPreferences(context);
         settings.edit().remove(PREF_LOCALE).apply();
 
         // Apply the system locale.
         updateLocale(context, systemLocale);
 
         // Tell Gecko.
-        GeckoAppShell.notifyObservers(EVENT_LOCALE_CHANGED, "");
+        EventDispatcher.getInstance().dispatch(EVENT_LOCALE_CHANGED, null);
     }
 
     /**
      * This is public to allow for an activity to force the
      * current locale to be applied if necessary (e.g., when
      * a new activity launches).
      */
     @Override
--- a/mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java
+++ b/mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java
@@ -1,21 +1,19 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * vim: ts=4 sw=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/. */
 
 package org.mozilla.gecko;
 
-import org.json.JSONObject;
-import org.json.JSONException;
-
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 
 import com.google.android.gms.cast.CastDevice;
 import com.google.android.gms.cast.CastRemoteDisplayLocalService;
 import com.google.android.gms.common.ConnectionResult;
 import com.google.android.gms.common.GooglePlayServicesUtil;
 import com.google.android.gms.common.api.Status;
 
 import android.app.PendingIntent;
@@ -39,29 +37,26 @@ public class ChromeCastDisplay implement
             throw new IllegalStateException("Play services are required for Chromecast support (got status code " + status + ")");
         }
 
         this.context = context;
         this.route = route;
         this.castDevice = CastDevice.getFromBundle(route.getExtras());
     }
 
-    public JSONObject toJSON() {
-        final JSONObject obj = new JSONObject();
-        try {
-            if (castDevice == null) {
-                return null;
-            }
-            obj.put("uuid", route.getId());
-            obj.put("friendlyName", castDevice.getFriendlyName());
-            obj.put("type", "chromecast");
-        } catch (JSONException ex) {
-            Log.d(LOGTAG, "Error building route", ex);
+    @Override // GeckoPresentationDisplay
+    public GeckoBundle toBundle() {
+        if (castDevice == null) {
+            return null;
         }
 
+        final GeckoBundle obj = new GeckoBundle(3);
+        obj.putString("uuid", route.getId());
+        obj.putString("friendlyName", castDevice.getFriendlyName());
+        obj.putString("type", "chromecast");
         return obj;
     }
 
     @Override
     public void start(final EventCallback callback) {
 
         if (CastRemoteDisplayLocalService.getInstance() != null) {
             Log.d(LOGTAG, "CastRemoteDisplayLocalService already existed.");
--- a/mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java
+++ b/mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java
@@ -4,18 +4,16 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
 import java.io.IOException;
 
 import org.mozilla.gecko.util.EventCallback;
 import org.mozilla.gecko.util.GeckoBundle;
-import org.json.JSONObject;
-import org.json.JSONException;
 
 import com.google.android.gms.cast.Cast.MessageReceivedCallback;
 import com.google.android.gms.cast.ApplicationMetadata;
 import com.google.android.gms.cast.Cast;
 import com.google.android.gms.cast.Cast.ApplicationConnectionResult;
 import com.google.android.gms.cast.CastDevice;
 import com.google.android.gms.cast.CastMediaControlIntent;
 import com.google.android.gms.cast.MediaInfo;
@@ -180,37 +178,32 @@ class ChromeCastPlayer implements GeckoM
         this.canMirror = route.supportsControlCategory(CastMediaControlIntent.categoryForCast(MIRROR_RECEIVER_APP_ID));
     }
 
     /**
      *  This dumps everything we can find about the device into JSON. This will hopefully make it
      *  easier to filter out duplicate devices from different sources in JS.
      *  Returns null if the device can't be found.
      */
-    @Override
-    public JSONObject toJSON() {
-        final JSONObject obj = new JSONObject();
-        try {
-            final CastDevice device = CastDevice.getFromBundle(route.getExtras());
-            if (device == null) {
-                return null;
-            }
-
-            obj.put("uuid", route.getId());
-            obj.put("version", device.getDeviceVersion());
-            obj.put("friendlyName", device.getFriendlyName());
-            obj.put("location", device.getIpAddress().toString());
-            obj.put("modelName", device.getModelName());
-            obj.put("mirror", canMirror);
-            // For now we just assume all of these are Google devices
-            obj.put("manufacturer", "Google Inc.");
-        } catch (JSONException ex) {
-            debug("Error building route", ex);
+    @Override // GeckoMediaPlayer
+    public GeckoBundle toBundle() {
+        final CastDevice device = CastDevice.getFromBundle(route.getExtras());
+        if (device == null) {
+            return null;
         }
 
+        final GeckoBundle obj = new GeckoBundle(7);
+        obj.putString("uuid", route.getId());
+        obj.putString("version", device.getDeviceVersion());
+        obj.putString("friendlyName", device.getFriendlyName());
+        obj.putString("location", device.getIpAddress().toString());
+        obj.putString("modelName", device.getModelName());
+        obj.putBoolean("mirror", canMirror);
+        // For now we just assume all of these are Google devices
+        obj.putString("manufacturer", "Google Inc.");
         return obj;
     }
 
     @Override
     public void load(final String title, final String url, final String type, final EventCallback callback) {
         final CastDevice device = CastDevice.getFromBundle(route.getExtras());
         Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(device, new Cast.Listener() {
             @Override
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
@@ -540,17 +540,17 @@ public abstract class GeckoApp
 
         return super.onPreparePanel(featureId, view, menu);
     }
 
     @Override
     public boolean onMenuOpened(int featureId, Menu menu) {
         // exit full-screen mode whenever the menu is opened
         if (mLayerView != null && mLayerView.isFullScreen()) {
-            GeckoAppShell.notifyObservers("FullScreen:Exit", null);
+            EventDispatcher.getInstance().dispatch("FullScreen:Exit", null);
         }
 
         if (featureId == Window.FEATURE_OPTIONS_PANEL) {
             if (mMenu == null) {
                 // getMenuPanel() will force the creation of the menu as well
                 MenuPanel panel = getMenuPanel();
                 onPreparePanel(featureId, panel, mMenu);
             }
@@ -567,53 +567,44 @@ public abstract class GeckoApp
 
     @Override
     public boolean onOptionsItemSelected(MenuItem item) {
         if (item.getItemId() == R.id.quit) {
             // Make sure the Guest Browsing notification goes away when we quit.
             GuestSession.hideNotification(this);
 
             final SharedPreferences prefs = getSharedPreferencesForProfile();
-            final Set<String> clearSet =
-                    PrefUtils.getStringSet(prefs, ClearOnShutdownPref.PREF, new HashSet<String>());
-
-            final JSONObject clearObj = new JSONObject();
-            for (String clear : clearSet) {
-                try {
-                    clearObj.put(clear, true);
-                } catch (JSONException ex) {
-                    Log.e(LOGTAG, "Error adding clear object " + clear, ex);
-                }
-            }
-
-            final JSONObject res = new JSONObject();
-            try {
-                res.put("sanitize", clearObj);
-            } catch (JSONException ex) {
-                Log.e(LOGTAG, "Error adding sanitize object", ex);
+            final Set<String> clearSet = PrefUtils.getStringSet(
+                    prefs, ClearOnShutdownPref.PREF, new HashSet<String>());
+
+            final GeckoBundle clearObj = new GeckoBundle(clearSet.size());
+            for (final String clear : clearSet) {
+                clearObj.putBoolean(clear, true);
             }
 
-            // If the user wants to clear open tabs, or else has opted out of session restore and does want to clear history,
-            // we also want to prevent the current session info from being saved.
-            try {
-                if (clearObj.has("private.data.openTabs")) {
-                    res.put("dontSaveSession", true);
-                } else if (clearObj.has("private.data.history")) {
-
-                    final String sessionRestore = getSessionRestorePreference(getSharedPreferences());
-                    res.put("dontSaveSession", "quit".equals(sessionRestore));
-
-                }
-            } catch (JSONException ex) {
-                Log.e(LOGTAG, "Error adding session restore data", ex);
+            final GeckoBundle res = new GeckoBundle(2);
+            res.putBundle("sanitize", clearObj);
+
+            // If the user wants to clear open tabs, or else has opted out of session
+            // restore and does want to clear history, we also want to prevent the current
+            // session info from being saved.
+            if (clearObj.containsKey("private.data.openTabs")) {
+                res.putBoolean("dontSaveSession", true);
+            } else if (clearObj.containsKey("private.data.history")) {
+
+                final String sessionRestore =
+                        getSessionRestorePreference(getSharedPreferences());
+                res.putBoolean("dontSaveSession", "quit".equals(sessionRestore));
             }
-            GeckoAppShell.notifyObservers("Browser:Quit", res.toString());
-            // We don't call doShutdown() here because this creates a race condition which can
-            // cause the clearing of private data to fail. Instead, we shut down the UI only after
-            // we're done sanitizing.
+
+            EventDispatcher.getInstance().dispatch("Browser:Quit", res);
+
+            // We don't call doShutdown() here because this creates a race condition which
+            // can cause the clearing of private data to fail. Instead, we shut down the
+            // UI only after we're done sanitizing.
             return true;
         }
 
         return super.onOptionsItemSelected(item);
     }
 
     @Override
     public void onOptionsMenuClosed(Menu menu) {
@@ -1342,17 +1333,17 @@ public abstract class GeckoApp
 
             mPrivateBrowsingSession = savedInstanceState.getString(SAVED_STATE_PRIVATE_SESSION);
         }
 
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
                 // If we are doing a restore, read the session data so we can send it to Gecko later.
-                String restoreMessage = null;
+                GeckoBundle restoreMessage = null;
                 if (!mIsRestoringActivity && mShouldRestore) {
                     final boolean isExternalURL = invokedWithExternalURL(getIntentURI(new SafeIntent(getIntent())));
                     try {
                         // restoreSessionTabs() will create simple tab stubs with the
                         // URL and title for each page, but we also need to restore
                         // session history. restoreSessionTabs() will inject the IDs
                         // of the tab stubs into the JSON data (which holds the session
                         // history). This JSON data is then sent to Gecko so session
@@ -1401,17 +1392,17 @@ public abstract class GeckoApp
 
                 synchronized (GeckoApp.this) {
                     mSessionRestoreParsingFinished = true;
                     GeckoApp.this.notifyAll();
                 }
 
                 // If we are doing a restore, send the parsed session data to Gecko.
                 if (!mIsRestoringActivity) {
-                    GeckoAppShell.notifyObservers("Session:Restore", restoreMessage);
+                    EventDispatcher.getInstance().dispatch("Session:Restore", restoreMessage);
                 }
 
                 // Make sure sessionstore.old is either updated or deleted as necessary.
                 getProfile().updateSessionFile(mShouldRestore);
             }
         });
 
         // Perform background initialization.
@@ -1786,66 +1777,68 @@ public abstract class GeckoApp
                 } else {
                     openTabsRunnable.run();
                 }
             }
         });
     }
 
     @WorkerThread
-    private String restoreSessionTabs(final boolean isExternalURL, boolean useBackup) throws SessionRestoreException {
-        try {
-            String sessionString = getProfile().readSessionFile(useBackup);
-            if (sessionString == null) {
-                throw new SessionRestoreException("Could not read from session file");
-            }
-
-            // If we are doing an OOM restore, parse the session data and
-            // stub the restored tabs immediately. This allows the UI to be
-            // updated before Gecko has restored.
-            final JSONArray tabs = new JSONArray();
-            final JSONObject windowObject = new JSONObject();
-            final boolean sessionDataValid;
-
-            LastSessionParser parser = new LastSessionParser(tabs, windowObject, isExternalURL);
-
-            if (mPrivateBrowsingSession == null) {
-                sessionDataValid = parser.parse(sessionString);
-            } else {
-                sessionDataValid = parser.parse(sessionString, mPrivateBrowsingSession);
-            }
-
-            if (tabs.length() > 0) {
+    private GeckoBundle restoreSessionTabs(final boolean isExternalURL, boolean useBackup)
+            throws SessionRestoreException {
+        String sessionString = getProfile().readSessionFile(useBackup);
+        if (sessionString == null) {
+            throw new SessionRestoreException("Could not read from session file");
+        }
+
+        // If we are doing an OOM restore, parse the session data and
+        // stub the restored tabs immediately. This allows the UI to be
+        // updated before Gecko has restored.
+        final JSONArray tabs = new JSONArray();
+        final JSONObject windowObject = new JSONObject();
+        final boolean sessionDataValid;
+
+        LastSessionParser parser = new LastSessionParser(tabs, windowObject, isExternalURL);
+
+        if (mPrivateBrowsingSession == null) {
+            sessionDataValid = parser.parse(sessionString);
+        } else {
+            sessionDataValid = parser.parse(sessionString, mPrivateBrowsingSession);
+        }
+
+        if (tabs.length() > 0) {
+            try {
                 // Update all parent tab IDs ...
                 parser.updateParentId(tabs);
                 windowObject.put("tabs", tabs);
                 // ... and for recently closed tabs as well (if we've got any).
-                JSONArray closedTabs = windowObject.optJSONArray("closedTabs");
+                final JSONArray closedTabs = windowObject.optJSONArray("closedTabs");
                 parser.updateParentId(closedTabs);
                 windowObject.putOpt("closedTabs", closedTabs);
 
-                sessionString = new JSONObject().put("windows", new JSONArray().put(windowObject)).toString();
-            } else {
-                if (parser.allTabsSkipped() || sessionDataValid) {
-                    // If we intentionally skipped all tabs we've read from the session file, we
-                    // set mShouldRestore back to false at this point already, so the calling code
-                    // can infer that the exception wasn't due to a damaged session store file.
-                    // The same applies if the session file was syntactically valid and
-                    // simply didn't contain any tabs.
-                    mShouldRestore = false;
-                }
-                throw new SessionRestoreException("No tabs could be read from session file");
+                sessionString = new JSONObject().put(
+                        "windows", new JSONArray().put(windowObject)).toString();
+            } catch (final JSONException e) {
+                throw new SessionRestoreException(e);
             }
-
-            JSONObject restoreData = new JSONObject();
-            restoreData.put("sessionString", sessionString);
-            return restoreData.toString();
-        } catch (JSONException e) {
-            throw new SessionRestoreException(e);
+        } else {
+            if (parser.allTabsSkipped() || sessionDataValid) {
+                // If we intentionally skipped all tabs we've read from the session file, we
+                // set mShouldRestore back to false at this point already, so the calling code
+                // can infer that the exception wasn't due to a damaged session store file.
+                // The same applies if the session file was syntactically valid and
+                // simply didn't contain any tabs.
+                mShouldRestore = false;
+            }
+            throw new SessionRestoreException("No tabs could be read from session file");
         }
+
+        final GeckoBundle restoreData = new GeckoBundle(1);
+        restoreData.putString("sessionString", sessionString);
+        return restoreData;
     }
 
     @RobocopTarget
     public static EventDispatcher getEventDispatcher() {
         final GeckoApp geckoApp = (GeckoApp) GeckoAppShell.getGeckoInterface();
         return geckoApp.getAppEventDispatcher();
     }
 
@@ -2611,17 +2604,17 @@ public abstract class GeckoApp
 
         if (mFullScreenPluginView != null) {
             GeckoAppShell.onFullScreenPluginHidden(mFullScreenPluginView);
             removeFullScreenPluginView(mFullScreenPluginView);
             return;
         }
 
         if (mLayerView != null && mLayerView.isFullScreen()) {
-            GeckoAppShell.notifyObservers("FullScreen:Exit", null);
+            EventDispatcher.getInstance().dispatch("FullScreen:Exit", null);
             return;
         }
 
         final Tabs tabs = Tabs.getInstance();
         final Tab tab = tabs.getSelectedTab();
         if (tab == null) {
             onDone();
             return;
@@ -2649,18 +2642,19 @@ public abstract class GeckoApp
                 if (tab.doBack()) {
                     return;
                 }
 
                 if (tab.isExternal()) {
                     onDone();
                     Tab nextSelectedTab = Tabs.getInstance().getNextTab(tab);
                     if (nextSelectedTab != null) {
-                        int nextSelectedTabId = nextSelectedTab.getId();
-                        GeckoAppShell.notifyObservers("Tab:KeepZombified", Integer.toString(nextSelectedTabId));
+                        final GeckoBundle data = new GeckoBundle(1);
+                        data.putInt("nextSelectedTabId", nextSelectedTab.getId());
+                        EventDispatcher.getInstance().dispatch("Tab:KeepZombified", data);
                     }
                     tabs.closeTab(tab);
                     return;
                 }
 
                 final int parentId = tab.getParentId();
                 final Tab parent = tabs.getTab(parentId);
                 if (parent != null) {
@@ -2715,21 +2709,16 @@ public abstract class GeckoApp
               mWakeLocks.put(topic, wl);
             }
         } else if (!state.equals("locked-foreground") && wl != null) {
             wl.release();
             mWakeLocks.remove(topic);
         }
     }
 
-    @Override
-    public void notifyCheckUpdateResult(String result) {
-        GeckoAppShell.notifyObservers("Update:CheckResult", result);
-    }
-
     private void geckoConnected() {
         mLayerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
     }
 
     @Override
     public void setAccessibilityEnabled(boolean enabled) {
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java
@@ -1,26 +1,26 @@
 /* -*- 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;
 
-import org.json.JSONObject;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 
 /**
  * Wrapper for MediaRouter types supported by Android, such as Chromecast, Miracast, etc.
  */
 interface GeckoMediaPlayer {
     /**
      * Can return null.
      */
-    JSONObject toJSON();
+    GeckoBundle toBundle();
     void load(String title, String url, String type, EventCallback callback);
     void play(EventCallback callback);
     void pause(EventCallback callback);
     void stop(EventCallback callback);
     void start(EventCallback callback);
     void end(EventCallback callback);
     void mirror(EventCallback callback);
     void message(String message, EventCallback callback);
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java
@@ -8,12 +8,12 @@ import android.content.BroadcastReceiver
 import android.content.Context;
 import android.content.Intent;
 
 public class GeckoMessageReceiver extends BroadcastReceiver {
     @Override
     public void onReceive(Context context, Intent intent) {
         final String action = intent.getAction();
         if (GeckoApp.ACTION_INIT_PW.equals(action)) {
-            GeckoAppShell.notifyObservers("Passwords:Init", null);
+            EventDispatcher.getInstance().dispatch("Passwords:Init", null);
         }
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java
@@ -1,22 +1,22 @@
 /* -*- 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;
 
-import org.json.JSONObject;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
 
 /**
  * Wrapper for MediaRouter types supported by Android to use for
  * Presentation API, such as Chromecast, Miracast, etc.
  */
 interface GeckoPresentationDisplay {
     /**
      * Can return null.
      */
-    JSONObject toJSON();
+    GeckoBundle toBundle();
     void start(EventCallback callback);
     void stop(EventCallback callback);
 }
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java
@@ -1,25 +1,25 @@
 /* -*- 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;
 
 import org.mozilla.gecko.updater.UpdateServiceHelper;
+import org.mozilla.gecko.util.GeckoBundle;
 
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 
 public class GeckoUpdateReceiver extends BroadcastReceiver
 {
     @Override
     public void onReceive(Context context, Intent intent) {
         if (UpdateServiceHelper.ACTION_CHECK_UPDATE_RESULT.equals(intent.getAction())) {
-            String result = intent.getStringExtra("result");
-            if (GeckoAppShell.getGeckoInterface() != null && result != null) {
-                GeckoAppShell.getGeckoInterface().notifyCheckUpdateResult(result);
-            }
+            final GeckoBundle data = new GeckoBundle(1);
+            data.putString("result", intent.getStringExtra("result"));
+            GeckoApp.getEventDispatcher().dispatch("Update:CheckResult", data);
         }
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java
+++ b/mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java
@@ -157,40 +157,43 @@ public class MediaPlayerManager extends 
                 if (display == null) {
                     Log.e(LOGTAG, "Couldn't find a display for this id: " + message.getString("id") + " for message: " + event);
                     return;
                 }
                 display.stop(callback);
             } else if ("AndroidCastDevice:SyncDevice".equals(event)) {
                 for (Map.Entry<String, GeckoPresentationDisplay> entry : displays.entrySet()) {
                     GeckoPresentationDisplay display = entry.getValue();
-                    JSONObject json = display.toJSON();
-                    if (json == null) {
+                    final GeckoBundle data = display.toBundle();
+                    if (data == null) {
                         break;
                     }
-                    GeckoAppShell.notifyObservers("AndroidCastDevice:Added", json.toString());
+                    EventDispatcher.getInstance().dispatch("AndroidCastDevice:Added", data);
                 }
             }
         }
     }
 
     private final MediaRouter.Callback callback =
         new MediaRouter.Callback() {
             @Override
             public void onRouteRemoved(MediaRouter router, RouteInfo route) {
                 debug("onRouteRemoved: route=" + route);
 
                 // Remove from media player list.
                 players.remove(route.getId());
-                GeckoAppShell.notifyObservers("MediaPlayer:Removed", route.getId());
+
+                final GeckoBundle data = new GeckoBundle(1);
+                data.putString("id", route.getId());
+                EventDispatcher.getInstance().dispatch("MediaPlayer:Removed", data);
                 updatePresentation();
 
                 // Remove from presentation display list.
                 if (displays.remove(route.getId()) != null) {
-                    GeckoAppShell.notifyObservers("AndroidCastDevice:Removed", route.getId());
+                    EventDispatcher.getInstance().dispatch("AndroidCastDevice:Removed", data);
                 }
             }
 
             @Override
             public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo route) {
                 updatePresentation();
             }
 
@@ -233,39 +236,39 @@ public class MediaPlayerManager extends 
 
             private void saveAndNotifyOfPlayer(final String eventName,
                                                MediaRouter.RouteInfo route,
                                                final GeckoMediaPlayer player) {
                 if (player == null) {
                     return;
                 }
 
-                final JSONObject json = player.toJSON();
-                if (json == null) {
+                final GeckoBundle data = player.toBundle();
+                if (data == null) {
                     return;
                 }
 
                 players.put(route.getId(), player);
-                GeckoAppShell.notifyObservers(eventName, json.toString());
+                EventDispatcher.getInstance().dispatch(eventName, data);
             }
 
             private void saveAndNotifyOfDisplay(final String eventName,
                                                 MediaRouter.RouteInfo route,
                                                 final GeckoPresentationDisplay display) {
                 if (display == null) {
                     return;
                 }
 
-                final JSONObject json = display.toJSON();
-                if (json == null) {
+                final GeckoBundle data = display.toBundle();
+                if (data == null) {
                     return;
                 }
 
                 displays.put(route.getId(), display);
-                GeckoAppShell.notifyObservers(eventName, json.toString());
+                EventDispatcher.getInstance().dispatch(eventName, data);
             }
         };
 
     private GeckoMediaPlayer getMediaPlayerForRoute(MediaRouter.RouteInfo route) {
         try {
             if (route.supportsControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)) {
                 return new ChromeCastPlayer(getActivity(), route);
             }
--- a/mobile/android/base/java/org/mozilla/gecko/Tab.java
+++ b/mobile/android/base/java/org/mozilla/gecko/Tab.java
@@ -570,34 +570,34 @@ public class Tab {
     public boolean canDoBack() {
         return mCanDoBack;
     }
 
     public boolean doBack() {
         if (!canDoBack())
             return false;
 
-        GeckoAppShell.notifyObservers("Session:Back", "");
+        EventDispatcher.getInstance().dispatch("Session:Back", null);
         return true;
     }
 
     public void doStop() {
-        GeckoAppShell.notifyObservers("Session:Stop", "");
+        EventDispatcher.getInstance().dispatch("Session:Stop", null);
     }
 
     // Our version of nsSHistory::GetCanGoForward
     public boolean canDoForward() {
         return mCanDoForward;
     }
 
     public boolean doForward() {
         if (!canDoForward())
             return false;
 
-        GeckoAppShell.notifyObservers("Session:Forward", "");
+        EventDispatcher.getInstance().dispatch("Session:Forward", null);
         return true;
     }
 
     void handleLocationChange(final GeckoBundle message) {
         final String uri = message.getString("uri");
         final String oldUrl = getURL();
         final boolean sameDocument = message.getBoolean("sameDocument");
         mEnteringReaderMode = ReaderModeUtils.isEnteringReaderMode(oldUrl, uri);
--- a/mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java
@@ -2,17 +2,17 @@
  * 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.db;
 
 import java.lang.IllegalArgumentException;
 import java.util.HashMap;
 
-import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.db.BrowserContract.FormHistory;
 import org.mozilla.gecko.db.BrowserContract.DeletedFormHistory;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.sqlite.SQLiteBridge;
 import org.mozilla.gecko.sync.Utils;
 
 import android.content.ContentValues;
 import android.content.UriMatcher;
@@ -119,17 +119,17 @@ public class FormHistoryProvider extends
 
             default:
                 throw new UnsupportedOperationException("Unknown insert URI " + uri);
         }
     }
 
     @Override
     public void initGecko() {
-        GeckoAppShell.notifyObservers("FormHistory:Init", null);
+        EventDispatcher.getInstance().dispatch("FormHistory:Init", null);
     }
 
     @Override
     public void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db) {
         if (!values.containsKey(FormHistory.GUID)) {
             return;
         }
 
--- a/mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java
+++ b/mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java
@@ -33,21 +33,23 @@ import java.util.zip.ZipFile;
 import javax.net.ssl.SSLException;
 
 import ch.boye.httpclientandroidlib.protocol.HTTP;
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.annotation.JNITarget;
 import org.mozilla.gecko.util.FileUtils;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.app.Activity;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.os.SystemClock;
 import android.support.annotation.WorkerThread;
@@ -218,29 +220,33 @@ public class Distribution {
 
     private static Distribution init(final Distribution distribution) {
         // Read/write preferences and files on the background thread.
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
                 boolean distributionSet = distribution.doInit();
                 if (distributionSet) {
-                    String preferencesJSON = "";
+                    GeckoBundle data = null;
                     try {
                         final File descFile = distribution.getDistributionFile("preferences.json");
                         if (descFile == null) {
                             // This can happen if we have a distribution directory, but no
                             // preferences.json file.
                             throw new IOException("preferences.json not found");
                         }
-                        preferencesJSON = FileUtils.readStringFromFile(descFile);
+
+                        final String preferencesJSON = FileUtils.readStringFromFile(descFile);
+                        data = new GeckoBundle(1);
+                        data.putString("preferences", preferencesJSON);
+
                     } catch (IOException e) {
                         Log.e(LOGTAG, "Error getting distribution descriptor file.", e);
                     }
-                    GeckoAppShell.notifyObservers("Distribution:Set", preferencesJSON);
+                    EventDispatcher.getInstance().dispatch("Distribution:Set", data);
                 }
             }
         });
 
         return distribution;
     }
 
     /**
@@ -338,17 +344,17 @@ public class Distribution {
         // Just in case this isn't empty but doInit has finished.
         runReadyQueue();
 
         // Now process any tasks that already ran while we were in STATE_NONE
         // to tell them of our good news.
         runLateReadyQueue();
 
         // Make sure that changes to search defaults are applied immediately.
-        GeckoAppShell.notifyObservers("Distribution:Changed", "");
+        EventDispatcher.getInstance().dispatch("Distribution:Changed", null);
     }
 
     /**
      * Helper to grab a file in the distribution directory.
      *
      * Returns null if there is no distribution directory or the file
      * doesn't exist. Ensures init first.
      */
--- a/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java
+++ b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java
@@ -3,20 +3,18 @@
  * 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.distribution;
 
 import org.mozilla.gecko.AdjustConstants;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.GeckoAppShell;
-
-import org.json.JSONException;
-import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
 
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.support.v4.content.LocalBroadcastManager;
 import android.text.TextUtils;
 import android.util.Log;
 
@@ -87,21 +85,15 @@ public class ReferrerReceiver extends Br
     }
 
 
     private void propagateMozillaCampaign(ReferrerDescriptor referrer) {
         if (referrer.campaign == null) {
             return;
         }
 
-        try {
-            final JSONObject data = new JSONObject();
-            data.put("id", "playstore");
-            data.put("version", referrer.campaign);
-            String payload = data.toString();
-
-            // Try to make sure the prefs are written as a group.
-            GeckoAppShell.notifyObservers("Campaign:Set", payload);
-        } catch (JSONException e) {
-            Log.e(LOGTAG, "Error propagating campaign identifier.", e);
-        }
+        final GeckoBundle data = new GeckoBundle(2);
+        data.putString("id", "playstore");
+        data.putString("version", referrer.campaign);
+        // Try to make sure the prefs are written as a group.
+        EventDispatcher.getInstance().dispatch("Campaign:Set", data);
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
@@ -1,17 +1,17 @@
 /* -*- 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.dlc;
 
 import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.dlc.catalog.DownloadContent;
 import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
 import org.mozilla.gecko.util.HardwareUtils;
 
 import android.app.IntentService;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
@@ -114,17 +114,17 @@ public class DownloadContentService exte
                 action = new StudyAction();
                 break;
 
             case ACTION_DOWNLOAD_CONTENT:
                 action = new DownloadAction(new DownloadAction.Callback() {
                     @Override
                     public void onContentDownloaded(DownloadContent content) {
                         if (content.isFont()) {
-                            GeckoAppShell.notifyObservers("Fonts:Reload", "");
+                            EventDispatcher.getInstance().dispatch("Fonts:Reload", null);
                         }
                     }
                 });
                 break;
 
             case ACTION_VERIFY_CONTENT:
                 action = new VerifyAction();
                 break;
--- a/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
@@ -13,17 +13,16 @@ import java.util.Collections;
 import java.util.EnumSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 
 import android.content.SharedPreferences;
 
 import org.mozilla.gecko.EventDispatcher;
-import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.SuggestClient;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
@@ -492,17 +491,19 @@ public class BrowserSearch extends HomeF
         final boolean searchPath = searchTerm.indexOf('/') > 0;
         final String autocompletion = findAutocompletion(searchTerm, c, searchPath);
 
         if (autocompletion == null || mAutocompleteHandler == null) {
             return;
         }
 
         // Prefetch auto-completed domain since it's a likely target
-        GeckoAppShell.notifyObservers("Session:Prefetch", "http://" + autocompletion);
+        final GeckoBundle data = new GeckoBundle(1);
+        data.putString("url", "http://" + autocompletion);
+        EventDispatcher.getInstance().dispatch("Session:Prefetch", data);
 
         mAutocompleteHandler.onAutocomplete(autocompletion);
         mAutocompleteHandler = null;
     }
 
     /**
      * Returns the substring of a provided URI, starting at the given offset,
      * and extending up to the end of the path segment in which the provided
@@ -592,17 +593,19 @@ public class BrowserSearch extends HomeF
         final int urlIndex = c.getColumnIndexOrThrow(History.URL);
         int searchCount = 0;
 
         do {
             final String url = c.getString(urlIndex);
 
             if (searchCount == 0) {
                 // Prefetch the first item in the list since it's weighted the highest
-                GeckoAppShell.notifyObservers("Session:Prefetch", url);
+                final GeckoBundle data = new GeckoBundle(1);
+                data.putString("url", url);
+                EventDispatcher.getInstance().dispatch("Session:Prefetch", data);
             }
 
             // Does the completion match against the whole URL? This will match
             // about: pages, as well as user input including "http://...".
             if (url.startsWith(searchTerm)) {
                 return uriSubstringUpToMatchedPath(url, 0,
                         (searchLength > HTTPS_PREFIX_LENGTH) ? searchLength : HTTPS_PREFIX_LENGTH);
             }
--- a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
@@ -30,35 +30,33 @@ import android.view.ContextMenu;
 import android.view.LayoutInflater;
 import android.view.MenuInflater;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.Button;
 import android.widget.ImageView;
 import android.widget.TextView;
-import org.json.JSONException;
-import org.json.JSONObject;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoApp;
-import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.RemoteClientsDialogFragment;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.SyncStatusListener;
 import org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel;
 import org.mozilla.gecko.restrictions.Restrictions;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.db.RemoteClient;
 import org.mozilla.gecko.restrictions.Restrictable;
 import org.mozilla.gecko.widget.HistoryDividerItemDecoration;
+import org.mozilla.gecko.util.GeckoBundle;
 
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
 import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.PARENT;
 import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_SYNC;
 import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS;
@@ -454,24 +452,20 @@ public class CombinedHistoryPanel extend
                     });
 
                     dialogBuilder.setPositiveButton(R.string.button_ok, new AlertDialog.OnClickListener() {
                         @Override
                         public void onClick(final DialogInterface dialog, final int which) {
                             dialog.dismiss();
 
                             // Send message to Java to clear history.
-                            final JSONObject json = new JSONObject();
-                            try {
-                                json.put("history", true);
-                            } catch (JSONException e) {
-                                Log.e(LOGTAG, "JSON error", e);
-                            }
+                            final GeckoBundle data = new GeckoBundle(1);
+                            data.putBoolean("history", true);
+                            EventDispatcher.getInstance().dispatch("Sanitize:ClearData", data);
 
-                            GeckoAppShell.notifyObservers("Sanitize:ClearData", json.toString());
                             Telemetry.sendUIEvent(TelemetryContract.Event.SANITIZE, TelemetryContract.Method.BUTTON, "history");
                         }
                     });
 
                     dialogBuilder.show();
                     break;
                 case CHILD_RECENT_TABS:
                     final String telemetryExtra = mRecentTabsAdapter.restoreAllTabs();
--- a/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java
@@ -8,23 +8,19 @@ package org.mozilla.gecko.home;
 import android.content.Context;
 import android.support.v7.widget.RecyclerView;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.TextView;
 
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
 import org.mozilla.gecko.AboutPages;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoApp;
-import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.SessionParser;
 import org.mozilla.gecko.home.CombinedHistoryAdapter.RecentTabsUpdateHandler;
 import org.mozilla.gecko.home.CombinedHistoryPanel.PanelStateUpdateHandler;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
 import org.mozilla.gecko.util.GeckoBundle;
@@ -79,21 +75,21 @@ public class RecentTabsAdapter extends R
         recentlyClosedTabs = new ClosedTab[0];
         lastSessionTabs = new ClosedTab[0];
 
         readPreviousSessionData();
     }
 
     public void startListeningForClosedTabs() {
         EventDispatcher.getInstance().registerUiThreadListener(this, "ClosedTabs:Data");
-        GeckoAppShell.notifyObservers("ClosedTabs:StartNotifications", null);
+        EventDispatcher.getInstance().dispatch("ClosedTabs:StartNotifications", null);
     }
 
     public void stopListeningForClosedTabs() {
-        GeckoAppShell.notifyObservers("ClosedTabs:StopNotifications", null);
+        EventDispatcher.getInstance().dispatch("ClosedTabs:StopNotifications", null);
         EventDispatcher.getInstance().unregisterUiThreadListener(this, "ClosedTabs:Data");
         recentlyClosedTabsReceived = false;
     }
 
     public void startListeningForHistorySanitize() {
         EventDispatcher.getInstance().registerUiThreadListener(this, "Sanitize:Finished");
     }
 
@@ -290,24 +286,19 @@ public class RecentTabsAdapter extends R
 
     private void addTabDataToList(List<String> dataList, ClosedTab[] closedTabs) {
         for (ClosedTab closedTab : closedTabs) {
             dataList.add(closedTab.data);
         }
     }
 
     private static void restoreSessionWithHistory(List<String> dataList) {
-        final JSONObject json = new JSONObject();
-        try {
-            json.put("tabs", new JSONArray(dataList));
-        } catch (JSONException e) {
-            Log.e(LOGTAG, "JSON error", e);
-        }
-
-        GeckoAppShell.notifyObservers("Session:RestoreRecentTabs", json.toString());
+        final GeckoBundle data = new GeckoBundle(1);
+        data.putStringArray("tabs", dataList);
+        EventDispatcher.getInstance().dispatch("Session:RestoreRecentTabs", data);
     }
 
     @Override
     public CombinedHistoryItem onCreateViewHolder(ViewGroup parent, int viewType) {
         final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
         final View view;
 
         final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
--- a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java
+++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java
@@ -6,18 +6,16 @@
 package org.mozilla.gecko.notifications;
 
 import java.io.File;
 import java.io.UnsupportedEncodingException;
 import java.net.URLConnection;
 import java.net.URLDecoder;
 import java.util.List;
 
-import org.json.JSONException;
-import org.json.JSONObject;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.mozglue.SafeIntent;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
@@ -122,37 +120,32 @@ public final class NotificationHelper im
         }
     }
 
     public boolean isHelperIntent(Intent i) {
         return i.getBooleanExtra(HELPER_NOTIFICATION, false);
     }
 
     public static void getArgsAndSendNotificationIntent(SafeIntent intent) {
-        final JSONObject args = new JSONObject();
+        final GeckoBundle args = new GeckoBundle(5);
         final Uri data = intent.getData();
 
         final String notificationType = data.getQueryParameter(EVENT_TYPE_ATTR);
 
-        try {
-            args.put(ID_ATTR, data.getQueryParameter(ID_ATTR));
-            args.put(EVENT_TYPE_ATTR, notificationType);
-            args.put(HANDLER_ATTR, data.getQueryParameter(HANDLER_ATTR));
-            args.put(COOKIE_ATTR, intent.getStringExtra(COOKIE_ATTR));
+        args.putString(ID_ATTR, data.getQueryParameter(ID_ATTR));
+        args.putString(EVENT_TYPE_ATTR, notificationType);
+        args.putString(HANDLER_ATTR, data.getQueryParameter(HANDLER_ATTR));
+        args.putString(COOKIE_ATTR, intent.getStringExtra(COOKIE_ATTR));
 
-            if (BUTTON_EVENT.equals(notificationType)) {
-                final String actionName = data.getQueryParameter(ACTION_ID_ATTR);
-                args.put(ACTION_ID_ATTR, actionName);
-            }
+        if (BUTTON_EVENT.equals(notificationType)) {
+            final String actionName = data.getQueryParameter(ACTION_ID_ATTR);
+            args.putString(ACTION_ID_ATTR, actionName);
+        }
 
-            Log.i(LOGTAG, "Send " + args.toString());
-            GeckoAppShell.notifyObservers("Notification:Event", args.toString());
-        } catch (JSONException e) {
-            Log.e(LOGTAG, "Error building JSON notification arguments.", e);
-        }
+        EventDispatcher.getInstance().dispatch("Notification:Event", args);
     }
 
     public void handleNotificationIntent(SafeIntent i) {
         final Uri data = i.getData();
         final String notificationType = data.getQueryParameter(EVENT_TYPE_ATTR);
         final String id = data.getQueryParameter(ID_ATTR);
         if (id == null || notificationType == null) {
             Log.e(LOGTAG, "handleNotificationEvent: invalid intent parameters");
--- a/mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java
@@ -1,21 +1,20 @@
 /* -*- 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.preferences;
 
-import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.util.GeckoBundle;
 
-import org.json.JSONException;
-import org.json.JSONObject;
 import org.mozilla.gecko.icons.storage.DiskStorage;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Set;
 
 import android.content.Context;
 import android.util.AttributeSet;
@@ -35,33 +34,29 @@ class PrivateDataPreference extends Mult
 
         if (!positiveResult) {
             return;
         }
 
         Telemetry.sendUIEvent(TelemetryContract.Event.SANITIZE, TelemetryContract.Method.DIALOG, "settings");
 
         final Set<String> values = getValues();
-        final JSONObject json = new JSONObject();
+        final GeckoBundle data = new GeckoBundle();
 
         for (String value : values) {
             // Privacy pref checkbox values are stored in Android prefs to
             // remember their check states. The key names are private.data.X,
             // where X is a string from Gecko sanitization. This prefix is
             // removed here so we can send the values to Gecko, which then does
             // the sanitization for each key.
             final String key = value.substring(PREF_KEY_PREFIX.length());
-            try {
-                json.put(key, true);
-            } catch (JSONException e) {
-                Log.e(LOGTAG, "JSON error", e);
-            }
+            data.putBoolean(key, true);
         }
 
         if (values.contains("private.data.offlineApps")) {
             // Remove all icons from storage if removing "Offline website data" was selected.
             DiskStorage.get(getContext()).evictAll();
         }
 
         // clear private data in gecko
-        GeckoAppShell.notifyObservers("Sanitize:ClearData", json.toString());
+        EventDispatcher.getInstance().dispatch("Sanitize:ClearData", data);
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java
+++ b/mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java
@@ -96,27 +96,23 @@ public final class ReadingListHelper imp
                 } catch (InterruptedException | ExecutionException e) {
                     // Ignore
                 }
 
                 return null;
             }
 
             @Override
-            public void onPostExecute(String faviconUrl) {
-                JSONObject args = new JSONObject();
+            public void onPostExecute(final String faviconUrl) {
+                final GeckoBundle args = new GeckoBundle(2);
                 if (faviconUrl != null) {
-                    try {
-                        args.put("url", url);
-                        args.put("faviconUrl", faviconUrl);
-                    } catch (JSONException e) {
-                        Log.w(LOGTAG, "Error building JSON favicon arguments.", e);
-                    }
+                    args.putString("url", url);
+                    args.putString("faviconUrl", faviconUrl);
                 }
-                callback.sendSuccess(args.toString());
+                callback.sendSuccess(args);
             }
         }).execute();
     }
 
     private void handleAddedToCache(final String url, final String path, final int size) {
         final SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context);
 
         rch.put(url, path, size);
@@ -125,29 +121,33 @@ public final class ReadingListHelper imp
     public static void cacheReaderItem(final String url, final int tabID, Context context) {
         if (AboutPages.isAboutReader(url)) {
             throw new IllegalArgumentException("Page url must be original (not about:reader) url");
         }
 
         SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context);
 
         if (!rch.isURLCached(url)) {
-            GeckoAppShell.notifyObservers("Reader:AddToCache", Integer.toString(tabID));
+            final GeckoBundle data = new GeckoBundle(1);
+            data.putInt("tabID", tabID);
+            EventDispatcher.getInstance().dispatch("Reader:AddToCache", data);
         }
     }
 
     public static void removeCachedReaderItem(final String url, Context context) {
         if (AboutPages.isAboutReader(url)) {
             throw new IllegalArgumentException("Page url must be original (not about:reader) url");
         }
 
         SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context);
 
         if (rch.isURLCached(url)) {
-            GeckoAppShell.notifyObservers("Reader:RemoveFromCache", url);
+            final GeckoBundle data = new GeckoBundle(1);
+            data.putString("url", url);
+            EventDispatcher.getInstance().dispatch("Reader:RemoveFromCache", data);
         }
 
         // When removing items from the cache we can probably spare ourselves the async callback
         // that we use when adding cached items. We know the cached item will be gone, hence
         // we no longer need to track it in the SavedReaderViewHelper
         rch.remove(url);
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java
@@ -1,21 +1,22 @@
 /* -*- 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.tabqueue;
 
 import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.content.Context;
 import android.content.Intent;
 import android.content.SharedPreferences;
 import android.content.res.Resources;
@@ -24,17 +25,16 @@ import android.support.v4.app.Notificati
 import android.support.v4.content.ContextCompat;
 import android.text.TextUtils;
 import android.util.Log;
 import android.view.View;
 import android.view.WindowManager;
 
 import org.json.JSONArray;
 import org.json.JSONException;
-import org.json.JSONObject;
 
 import java.util.ArrayList;
 import java.util.List;
 
 public class TabQueueHelper {
     private static final String LOGTAG = "Gecko" + TabQueueHelper.class.getSimpleName();
 
     // Disable Tab Queue for API level 10 (GB) - Bug 1206055
@@ -279,26 +279,26 @@ public class TabQueueHelper {
 
         // exit early if we don't have any tabs queued
         if (getTabQueueLength(context) < 1) {
             return;
         }
 
         JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
 
-        if (jsonArray.length() > 0) {
-            JSONObject data = new JSONObject();
-            try {
-                data.put("urls", jsonArray);
-                data.put("shouldNotifyTabsOpenedToJava", shouldPerformJavaScriptCallback);
-                GeckoAppShell.notifyObservers("Tabs:OpenMultiple", data.toString());
-            } catch (JSONException e) {
-                // Don't exit early as we perform cleanup at the end of this function.
-                Log.e(LOGTAG, "Error sending tab queue data", e);
+        final int len = jsonArray.length();
+        if (len > 0) {
+            final String[] urls = new String[len];
+            for (int i = 0; i < len; i++) {
+                urls[i] = jsonArray.optString(i);
             }
+            final GeckoBundle data = new GeckoBundle(2);
+            data.putStringArray("urls", urls);
+            data.putBoolean("shouldNotifyTabsOpenedToJava", shouldPerformJavaScriptCallback);
+            EventDispatcher.getInstance().dispatch("Tabs:OpenMultiple", data);
         }
 
         try {
             profile.deleteFileFromProfileDir(filename);
         } catch (IllegalArgumentException e) {
             Log.e(LOGTAG, "Error deleting Tab Queue data file.", e);
         }
 
--- a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java
@@ -2,19 +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.tabs;
 
 import java.util.ArrayList;
 import java.util.List;
 
-import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.GeckoBundle;
 
 import android.content.Context;
 import android.content.DialogInterface;
 import android.os.Bundle;
 import android.os.Parcelable;
 import android.support.v4.app.Fragment;
 import android.support.v4.app.FragmentManager;
 import android.support.v4.app.FragmentTransaction;
@@ -80,18 +81,19 @@ public class TabHistoryFragment extends 
         toIndex = bundle.getInt(ARG_INDEX);
         final ArrayAdapter<TabHistoryPage> urlAdapter = new TabHistoryAdapter(getActivity(), historyPageList);
         dialogList.setAdapter(urlAdapter);
         dialogList.setOnItemClickListener(this);
     }
 
     @Override
     public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
-        String index = String.valueOf(toIndex - position);
-        GeckoAppShell.notifyObservers("Session:Navigate", index);
+        final GeckoBundle data = new GeckoBundle(1);
+        data.putInt("index", toIndex - position);
+        EventDispatcher.getInstance().dispatch("Session:Navigate", data);
         dismiss();
     }
 
     @Override
     public void onClick(View v) {
         // Since the fragment view fills the entire screen, any clicks outside of the history
         // ListView will end up here.
         dismiss();
--- a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java
@@ -76,17 +76,16 @@ public abstract class TabsLayout extends
         Tabs.registerOnTabsChangedListener(this);
         refreshTabsData();
     }
 
     @Override
     public void hide() {
         setVisibility(View.GONE);
         Tabs.unregisterOnTabsChangedListener(this);
-        GeckoAppShell.notifyObservers("Tab:Screenshot:Cancel", "");
         tabsAdapter.clear();
     }
 
     @Override
     public boolean shouldExpand() {
         return true;
     }
 
--- a/mobile/android/chrome/content/CastingApps.js
+++ b/mobile/android/chrome/content/CastingApps.js
@@ -25,29 +25,31 @@ var mediaPlayerDevice = {
   target: "media:router",
   factory: function(aService) {
     Cu.import("resource://gre/modules/MediaPlayerApp.jsm");
     return new MediaPlayerApp(aService);
   },
   types: ["video/mp4", "video/webm", "application/x-mpegurl"],
   extensions: ["mp4", "webm", "m3u", "m3u8"],
   init: function() {
-    Services.obs.addObserver(this, "MediaPlayer:Added", false);
-    Services.obs.addObserver(this, "MediaPlayer:Changed", false);
-    Services.obs.addObserver(this, "MediaPlayer:Removed", false);
+    GlobalEventDispatcher.registerListener(this, [
+      "MediaPlayer:Added",
+      "MediaPlayer:Changed",
+      "MediaPlayer:Removed",
+    ]);
   },
-  observe: function(subject, topic, data) {
-    if (topic === "MediaPlayer:Added") {
-      let service = this.toService(JSON.parse(data));
+  onEvent: function(event, data, callback) {
+    if (event === "MediaPlayer:Added") {
+      let service = this.toService(data);
       SimpleServiceDiscovery.addService(service);
-    } else if (topic === "MediaPlayer:Changed") {
-      let service = this.toService(JSON.parse(data));
+    } else if (event === "MediaPlayer:Changed") {
+      let service = this.toService(data);
       SimpleServiceDiscovery.updateService(service);
-    } else if (topic === "MediaPlayer:Removed") {
-      SimpleServiceDiscovery.removeService(data);
+    } else if (event === "MediaPlayer:Removed") {
+      SimpleServiceDiscovery.removeService(data.id);
     }
   },
   toService: function(display) {
     // Convert the native data into something matching what is created in _processService()
     return {
       location: display.location,
       target: "media:router",
       friendlyName: display.friendlyName,
--- a/mobile/android/chrome/content/EmbedRT.js
+++ b/mobile/android/chrome/content/EmbedRT.js
@@ -8,20 +8,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 /*
  * Collection of methods and features specific to using a GeckoView instance.
  * The code is isolated from browser.js for code size and performance reasons.
  */
 var EmbedRT = {
   _scopes: {},
 
-  observe: function(subject, topic, data) {
-    switch(topic) {
+  onEvent: function (event, data, callback) {
+    switch (event) {
       case "GeckoView:ImportScript":
-        this.importScript(data);
+        this.importScript(data.scriptURL);
         break;
     }
   },
 
   /*
    * Loads a script file into a sandbox and calls an optional load function
    */
   importScript: function(scriptURL) {
--- a/mobile/android/chrome/content/FeedHandler.js
+++ b/mobile/android/chrome/content/FeedHandler.js
@@ -52,19 +52,18 @@ var FeedHandler = {
         if (!(type in this._contentTypes))
           this._contentTypes[type] = [];
         this._contentTypes[type].push({ contentType: type, uri: uri, name: title });
       }
       catch(ex) {}
     }
   },
 
-  observe: function fh_observe(aSubject, aTopic, aData) {
-    if (aTopic === "Feeds:Subscribe") {
-      let args = JSON.parse(aData);
+  onEvent: function fh_onEvent(event, args, callback) {
+    if (event === "Feeds:Subscribe") {
       let tab = BrowserApp.getTabForId(args.tabId);
       if (!tab)
         return;
 
       let browser = tab.browser;
       let feeds = browser.feeds;
       if (feeds == null)
         return;
--- a/mobile/android/chrome/content/Feedback.js
+++ b/mobile/android/chrome/content/Feedback.js
@@ -5,18 +5,18 @@
 
 var Feedback = {
 
   get _feedbackURL() {
     delete this._feedbackURL;
     return this._feedbackURL = Services.urlFormatter.formatURLPref("app.feedbackURL");
   },
 
-  observe: function(aMessage, aTopic, aData) {
-    if (aTopic !== "Feedback:Show") {
+  onEvent: function(event, data, callback) {
+    if (event !== "Feedback:Show") {
       return;
     }
 
     // Don't prompt for feedback in distribution builds.
     try {
       Services.prefs.getCharPref("distribution.id");
       return;
     } catch (e) {}
--- a/mobile/android/chrome/content/Reader.js
+++ b/mobile/android/chrome/content/Reader.js
@@ -50,27 +50,27 @@ var Reader = {
   /**
    * If the requested tab has a backPress listener, return its results, else false.
    */
   onBackPress: function(tabId) {
     let listener = this._backPressListeners[tabId];
     return { handled: (listener ? listener() : false) };
   },
 
-  observe: function Reader_observe(aMessage, aTopic, aData) {
-    switch (aTopic) {
+  onEvent: function Reader_onEvent(event, data, callback) {
+    switch (event) {
       case "Reader:RemoveFromCache": {
-        ReaderMode.removeArticleFromCache(aData).catch(e => Cu.reportError("Error removing article from cache: " + e));
+        ReaderMode.removeArticleFromCache(data.url).catch(e => Cu.reportError("Error removing article from cache: " + e));
         break;
       }
 
       case "Reader:AddToCache": {
-        let tab = BrowserApp.getTabForId(aData);
+        let tab = BrowserApp.getTabForId(data.tabID);
         if (!tab) {
-          throw new Error("No tab for tabID = " + aData + " when trying to save reader view article");
+          throw new Error("No tab for tabID = " + data.tabID + " when trying to save reader view article");
         }
 
         // If the article is coming from reader mode, we must have fetched it already.
         this._getArticleData(tab.browser).then((article) => {
           ReaderMode.storeArticleInCache(article);
         }).catch(e => Cu.reportError("Error storing article in cache: " + e));
         break;
       }
@@ -115,17 +115,17 @@ var Reader = {
         break;
       }
 
       case "Reader:FaviconRequest": {
         GlobalEventDispatcher.sendRequestForResult({
           type: "Reader:FaviconRequest",
           url: message.data.url
         }).then(data => {
-          message.target.messageManager.sendAsyncMessage("Reader:FaviconReturn", JSON.parse(data));
+          message.target.messageManager.sendAsyncMessage("Reader:FaviconReturn", data);
         });
         break;
       }
 
       case "Reader:SystemUIVisibility":
         this._showSystemUI(message.data.visible);
         break;
 
--- a/mobile/android/chrome/content/WebcompatReporter.js
+++ b/mobile/android/chrome/content/WebcompatReporter.js
@@ -10,29 +10,39 @@ Cu.import("resource://gre/modules/XPCOMU
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Snackbars", "resource://gre/modules/Snackbars.jsm");
 
 var WebcompatReporter = {
   menuItem: null,
   menuItemEnabled: null,
   init: function() {
-    Services.obs.addObserver(this, "DesktopMode:Change", false);
+    GlobalEventDispatcher.registerListener(this, "DesktopMode:Change");
     Services.obs.addObserver(this, "chrome-document-global-created", false);
     Services.obs.addObserver(this, "content-document-global-created", false);
 
     let visible = true;
     if ("@mozilla.org/parental-controls-service;1" in Cc) {
       let pc = Cc["@mozilla.org/parental-controls-service;1"].createInstance(Ci.nsIParentalControlsService);
       visible = !pc.parentalControlsEnabled;
     }
 
     this.addMenuItem(visible);
   },
 
+  onEvent: function(event, data, callback) {
+    if (event === "DesktopMode:Change") {
+      let tab = BrowserApp.getTabForId(data.tabId);
+      let currentURI = tab.browser.currentURI.spec;
+      if (data.desktopMode && this.isReportableUrl(currentURI)) {
+        this.reportDesktopModePrompt(tab);
+      }
+    }
+  },
+
   observe: function(subject, topic, data) {
     if (topic == "content-document-global-created" || topic == "chrome-document-global-created") {
       let win = subject;
       let currentURI = win.document.documentURI;
 
       // Ignore non top-level documents
       if (currentURI !== win.top.location.href) {
         return;
@@ -40,23 +50,16 @@ var WebcompatReporter = {
 
       if (!this.menuItemEnabled && this.isReportableUrl(currentURI)) {
         NativeWindow.menu.update(this.menuItem, {enabled: true});
         this.menuItemEnabled = true;
       } else if (this.menuItemEnabled && !this.isReportableUrl(currentURI)) {
         NativeWindow.menu.update(this.menuItem, {enabled: false});
         this.menuItemEnabled = false;
       }
-    } else if (topic === "DesktopMode:Change") {
-      let args = JSON.parse(data);
-      let tab = BrowserApp.getTabForId(args.tabId);
-      let currentURI = tab.browser.currentURI.spec;
-      if (args.desktopMode && this.isReportableUrl(currentURI)) {
-        this.reportDesktopModePrompt(tab);
-      }
     }
   },
 
   addMenuItem: function(visible) {
     this.menuItem = NativeWindow.menu.add({
       name: this.strings.GetStringFromName("webcompat.menu.name"),
       callback: () => {
         Promise.resolve(BrowserApp.selectedTab).then(this.getScreenshot)
--- a/mobile/android/chrome/content/about.js
+++ b/mobile/android/chrome/content/about.js
@@ -54,36 +54,29 @@ function init() {
     links.forEach(function(link) {
       let url = formatter.formatURLPref(link.pref);
       let element = document.getElementById(link.id);
       element.setAttribute("href", url);
     });
   } catch (ex) {}
 
 #ifdef MOZ_UPDATER
-  let Updater = {
-    update: null,
-
-    init: function() {
-      Services.obs.addObserver(this, "Update:CheckResult", false);
-    },
-
-    observe: function(aSubject, aTopic, aData) {
-      if (aTopic == "Update:CheckResult") {
-        showUpdateMessage(aData);
-      }
-    },
-  };
-
-  Updater.init();
-
   function checkForUpdates() {
     showCheckingMessage();
 
     let window = Services.wm.getMostRecentWindow("navigator:browser");
+
+    window.WindowEventDispatcher.registerListener(
+        function listener(event, data, callback) {
+      if (event === "Update:CheckResult") {
+        EventDispatcher.instance.unregisterListener(listener, event);
+        showUpdateMessage(data.result);
+      }
+    }, "Update:CheckResult");
+
     window.WindowEventDispatcher.sendRequest({ type: "Update:Check" });
   }
 
   function downloadUpdate() {
     let window = Services.wm.getMostRecentWindow("navigator:browser");
     window.WindowEventDispatcher.sendRequest({ type: "Update:Download" });
   }
 
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -147,20 +147,16 @@ lazilyLoadedBrowserScripts.forEach(funct
     Services.scriptloader.loadSubScript(script, sandbox);
     return sandbox[name];
   });
 });
 
 var lazilyLoadedObserverScripts = [
   ["MemoryObserver", ["memory-pressure", "Memory:Dump"], "chrome://browser/content/MemoryObserver.js"],
   ["ConsoleAPI", ["console-api-log-event"], "chrome://browser/content/ConsoleAPI.js"],
-  ["FeedHandler", ["Feeds:Subscribe"], "chrome://browser/content/FeedHandler.js"],
-  ["Feedback", ["Feedback:Show"], "chrome://browser/content/Feedback.js"],
-  ["EmbedRT", ["GeckoView:ImportScript"], "chrome://browser/content/EmbedRT.js"],
-  ["Reader", ["Reader:AddToCache", "Reader:RemoveFromCache"], "chrome://browser/content/Reader.js"],
 ];
 
 if (AppConstants.MOZ_WEBRTC) {
   lazilyLoadedObserverScripts.push(
     ["WebrtcUI", ["getUserMedia:request",
                   "PeerConnection:request",
                   "recording-device-events",
                   "VideoCapture:Paused",
@@ -226,29 +222,41 @@ lazilyLoadedObserverScripts.forEach(func
   });
 });
 
 // Lazily-loaded JS subscripts and modules that use global/window EventDispatcher.
 [
   ["ActionBarHandler", WindowEventDispatcher,
    ["TextSelection:Get", "TextSelection:Action", "TextSelection:End"],
    "chrome://browser/content/ActionBarHandler.js"],
+  ["EmbedRT", WindowEventDispatcher,
+   ["GeckoView:ImportScript"],
+   "chrome://browser/content/EmbedRT.js"],
+  ["Feedback", GlobalEventDispatcher,
+   ["Feedback:Show"],
+   "chrome://browser/content/Feedback.js"],
+  ["FeedHandler", GlobalEventDispatcher,
+   ["Feeds:Subscribe"],
+   "chrome://browser/content/FeedHandler.js"],
   ["FindHelper", GlobalEventDispatcher,
    ["FindInPage:Opened", "FindInPage:Closed"],
    "chrome://browser/content/FindHelper.js"],
   ["Home", GlobalEventDispatcher,
    ["HomeBanner:Get", "HomePanels:Get", "HomePanels:Authenticate",
     "HomePanels:RefreshView", "HomePanels:Installed", "HomePanels:Uninstalled"],
    "resource://gre/modules/Home.jsm"],
   ["PermissionsHelper", GlobalEventDispatcher,
    ["Permissions:Check", "Permissions:Get", "Permissions:Clear"],
    "chrome://browser/content/PermissionsHelper.js"],
   ["PrintHelper", GlobalEventDispatcher,
    ["Print:PDF"],
    "chrome://browser/content/PrintHelper.js"],
+  ["Reader", GlobalEventDispatcher,
+   ["Reader:AddToCache", "Reader:RemoveFromCache"],
+   "chrome://browser/content/Reader.js"],
 ].forEach(module => {
   let [name, dispatcher, events, script] = module;
   XPCOMUtils.defineLazyGetter(window, name, function() {
     let sandbox = {};
     if (script.endsWith(".jsm")) {
       Cu.import(script, sandbox);
     } else {
       Services.scriptloader.loadSubScript(script, sandbox);
@@ -378,38 +386,43 @@ var BrowserApp = {
 
     Services.androidBridge.browserApp = this;
 
     GlobalEventDispatcher.registerListener(this, [
       "Tab:Load",
       "Tab:Selected",
       "Tab:Closed",
       "Browser:LoadManifest",
+      "Browser:Quit",
+      "Fonts:Reload",
+      "FormHistory:Init",
+      "FullScreen:Exit",
+      "Locale:OS",
+      "Locale:Changed",
+      "Passwords:Init",
+      "Sanitize:ClearData",
+      "SaveAs:PDF",
+      "ScrollTo:FocusedInput",
+      "Session:Back",
+      "Session:Forward",
       "Session:GetHistory",
+      "Session:Navigate",
       "Session:Reload",
+      "Session:Stop",
     ]);
 
-    Services.obs.addObserver(this, "Locale:OS", false);
-    Services.obs.addObserver(this, "Locale:Changed", false);
-    Services.obs.addObserver(this, "Session:Back", false);
-    Services.obs.addObserver(this, "Session:Forward", false);
-    Services.obs.addObserver(this, "Session:Navigate", false);
-    Services.obs.addObserver(this, "Session:Stop", false);
-    Services.obs.addObserver(this, "SaveAs:PDF", false);
-    Services.obs.addObserver(this, "Browser:Quit", false);
-    Services.obs.addObserver(this, "ScrollTo:FocusedInput", false);
-    Services.obs.addObserver(this, "Sanitize:ClearData", false);
-    Services.obs.addObserver(this, "FullScreen:Exit", false);
-    Services.obs.addObserver(this, "Passwords:Init", false);
-    Services.obs.addObserver(this, "FormHistory:Init", false);
+    // Provide compatibility for add-ons like QuitNow that send "Browser:Quit"
+    // as an observer notification.
+    Services.obs.addObserver((subject, topic, data) =>
+        this.quit(data ? JSON.parse(data) : undefined), "Browser:Quit", false);
+
     Services.obs.addObserver(this, "android-get-pref", false);
     Services.obs.addObserver(this, "android-set-pref", false);
     Services.obs.addObserver(this, "gather-telemetry", false);
     Services.obs.addObserver(this, "keyword-search", false);
-    Services.obs.addObserver(this, "Fonts:Reload", false);
     Services.obs.addObserver(this, "Vibration:Request", false);
 
     window.addEventListener("fullscreen", function() {
       WindowEventDispatcher.sendRequest({
         type: window.fullScreen ? "ToggleChrome:Hide" : "ToggleChrome:Show"
       });
     });
 
@@ -968,18 +981,18 @@ var BrowserApp = {
             },
           }
         });
     });
   },
 
   onAppUpdated: function() {
     // initialize the form history and passwords databases on upgrades
-    Services.obs.notifyObservers(null, "FormHistory:Init", "");
-    Services.obs.notifyObservers(null, "Passwords:Init", "");
+    GlobalEventDispatcher.dispatch("FormHistory:Init", null);
+    GlobalEventDispatcher.dispatch("Passwords:Init", null);
 
     if (this._startupStatus === "upgrade") {
       this._migrateUI();
     }
   },
 
   _migrateUI: function() {
     const UI_VERSION = 3;
@@ -1627,21 +1640,147 @@ var BrowserApp = {
     let browser = this.selectedBrowser;
 
     switch (event) {
       case "Browser:LoadManifest": {
         installManifest(browser, data.manifestUrl, data.iconSize);
         break;
       }
 
+      case "Browser:Quit":
+        this.quit(data);
+        break;
+
+      case "Fonts:Reload":
+        FontEnumerator.updateFontList();
+        break;
+
+      case "FormHistory:Init": {
+        // Force creation/upgrade of formhistory.sqlite
+        FormHistory.count({});
+        GlobalEventDispatcher.unregisterListener(this, event);
+        break;
+      }
+
+      case "FullScreen:Exit":
+        browser.contentDocument.exitFullscreen();
+        break;
+
+      case "Locale:OS": {
+        // We know the system locale. We use this for generating Accept-Language headers.
+        let languageTag = data.languageTag;
+        console.log("Locale:OS: " + languageTag);
+        let currentOSLocale = this.getOSLocalePref();
+        if (currentOSLocale == languageTag) {
+          break;
+        }
+
+        console.log("New OS locale.");
+
+        // Ensure that this choice is immediately persisted, because
+        // Gecko won't be told again if it forgets.
+        Services.prefs.setCharPref("intl.locale.os", languageTag);
+        Services.prefs.savePrefFile(null);
+
+        let appLocale = this.getUALocalePref();
+
+        this.computeAcceptLanguages(languageTag, appLocale);
+        break;
+      }
+
+      case "Locale:Changed": {
+        if (data) {
+          // The value provided to Locale:Changed should be a BCP47 language tag
+          // understood by Gecko -- for example, "es-ES" or "de".
+          console.log("Locale:Changed: " + data.languageTag);
+
+          // We always write a localized pref, even though sometimes the value is a char pref.
+          // (E.g., on desktop single-locale builds.)
+          this.setLocalizedPref("general.useragent.locale", data.languageTag);
+        } else {
+          // Resetting.
+          console.log("Switching to system locale.");
+          Services.prefs.clearUserPref("general.useragent.locale");
+        }
+
+        Services.prefs.setBoolPref("intl.locale.matchOS", !data);
+
+        // Ensure that this choice is immediately persisted, because
+        // Gecko won't be told again if it forgets.
+        Services.prefs.savePrefFile(null);
+
+        // Blow away the string cache so that future lookups get the
+        // correct locale.
+        Strings.flush();
+
+        // Make sure we use the right Accept-Language header.
+        let osLocale;
+        try {
+          // This should never not be set at this point, but better safe than sorry.
+          osLocale = Services.prefs.getCharPref("intl.locale.os");
+        } catch (e) {
+        }
+
+        this.computeAcceptLanguages(osLocale, data && data.languageTag);
+        break;
+      }
+
+      case "Passwords:Init": {
+        let storage = Cc["@mozilla.org/login-manager/storage/mozStorage;1"].
+                      getService(Ci.nsILoginManagerStorage);
+        storage.initialize();
+        GlobalEventDispatcher.unregisterListener(this, event);
+        break;
+      }
+
+      case "Sanitize:ClearData":
+        this.sanitize(data);
+        break;
+
+      case "SaveAs:PDF":
+        this.saveAsPDF(browser);
+        break;
+
+      case "ScrollTo:FocusedInput": {
+        // these messages come from a change in the viewable area and not user interaction
+        // we allow scrolling to the selected input, but not zooming the page
+        this.scrollToFocusedInput(browser, false);
+        break;
+      }
+
       case "Session:GetHistory": {
         callback.onSuccess(this.getHistory(data));
         break;
       }
 
+      case "Session:Back":
+        browser.goBack();
+        break;
+
+      case "Session:Forward":
+        browser.goForward();
+        break;
+
+      case "Session:Navigate": {
+        let index = data.index;
+        let webNav = BrowserApp.selectedTab.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation);
+        let historySize = webNav.sessionHistory.count;
+
+        if (index < 0) {
+          index = 0;
+          Log.e("Browser", "Negative index truncated to zero");
+        } else if (index >= historySize) {
+          Log.e("Browser", "Incorrect index " + index + " truncated to " + historySize - 1);
+          index = historySize - 1;
+        }
+
+        browser.gotoIndex(index);
+        break;
+      }
+
       case "Session:Reload": {
         let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
 
         // Check to see if this is a message to enable/disable mixed content blocking.
         if (data) {
           if (data.bypassCache) {
             flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE |
                      Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY;
@@ -1684,16 +1823,20 @@ var BrowserApp = {
           let sh = webNav.sessionHistory;
           if (sh)
             webNav = sh.QueryInterface(Ci.nsIWebNavigation);
         } catch (e) {}
         webNav.reload(flags);
         break;
       }
 
+      case "Session:Stop":
+        browser.stop();
+        break;
+
       case "Tab:Load": {
         let url = data.url;
         let flags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP
                   | Ci.nsIWebNavigation.LOAD_FLAGS_FIXUP_SCHEME_TYPOS;
 
         // Pass LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL to prevent any loads from
         // inheriting the currently loaded document's principal.
         if (data.userEntered) {
@@ -1749,44 +1892,16 @@ var BrowserApp = {
       }
     }
   },
 
   observe: function(aSubject, aTopic, aData) {
     let browser = this.selectedBrowser;
 
     switch (aTopic) {
-      case "Session:Back":
-        browser.goBack();
-        break;
-
-      case "Session:Forward":
-        browser.goForward();
-        break;
-
-      case "Session:Navigate":
-          let index = JSON.parse(aData);
-          let webNav = BrowserApp.selectedTab.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation);
-          let historySize = webNav.sessionHistory.count;
-
-          if (index < 0) {
-            index = 0;
-            Log.e("Browser", "Negative index truncated to zero");
-          } else if (index >= historySize) {
-            Log.e("Browser", "Incorrect index " + index + " truncated to " + historySize - 1);
-            index = historySize - 1;
-          }
-
-          browser.gotoIndex(index);
-          break;
-
-      case "Session:Stop":
-        browser.stop();
-        break;
-
       case "keyword-search":
         // This event refers to a search via the URL bar, not a bookmarks
         // keyword search. Note that this code assumes that the user can only
         // perform a keyword search on the selected tab.
         this.selectedTab.isSearch = true;
 
         // Don't store queries in private browsing mode.
         let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(this.selectedTab.browser);
@@ -1796,55 +1911,16 @@ var BrowserApp = {
         GlobalEventDispatcher.sendRequest({
           type: "Search:Keyword",
           identifier: engine.identifier,
           name: engine.name,
           query: query
         });
         break;
 
-      case "Browser:Quit":
-        // Add-ons like QuitNow and CleanQuit provide aData as an empty-string ("").
-        // Pass undefined to invoke the methods default parms.
-        this.quit(aData ? JSON.parse(aData) : undefined);
-        break;
-
-      case "SaveAs:PDF":
-        this.saveAsPDF(browser);
-        break;
-
-      case "ScrollTo:FocusedInput":
-        // these messages come from a change in the viewable area and not user interaction
-        // we allow scrolling to the selected input, but not zooming the page
-        this.scrollToFocusedInput(browser, false);
-        break;
-
-      case "Sanitize:ClearData":
-        this.sanitize(JSON.parse(aData));
-        break;
-
-      case "FullScreen:Exit":
-        browser.contentDocument.exitFullscreen();
-        break;
-
-      case "Passwords:Init": {
-        let storage = Cc["@mozilla.org/login-manager/storage/mozStorage;1"].
-                      getService(Ci.nsILoginManagerStorage);
-        storage.initialize();
-        Services.obs.removeObserver(this, "Passwords:Init");
-        break;
-      }
-
-      case "FormHistory:Init": {
-        // Force creation/upgrade of formhistory.sqlite
-        FormHistory.count({});
-        Services.obs.removeObserver(this, "FormHistory:Init");
-        break;
-      }
-
       case "android-get-pref": {
         // These pref names are not "real" pref names. They are used in the
         // setting menu, and these are passed when initializing the setting
         // menu. aSubject is a nsIWritableVariant to hold the pref value.
         aSubject.QueryInterface(Ci.nsIWritableVariant);
 
         switch (aData) {
           // The plugin pref is actually two separate prefs, so
@@ -1938,76 +2014,16 @@ var BrowserApp = {
         }
         break;
       }
 
       case "gather-telemetry":
         GlobalEventDispatcher.sendRequest({ type: "Telemetry:Gather" });
         break;
 
-      case "Locale:OS":
-        // We know the system locale. We use this for generating Accept-Language headers.
-        console.log("Locale:OS: " + aData);
-        let currentOSLocale = this.getOSLocalePref();
-        if (currentOSLocale == aData) {
-          break;
-        }
-
-        console.log("New OS locale.");
-
-        // Ensure that this choice is immediately persisted, because
-        // Gecko won't be told again if it forgets.
-        Services.prefs.setCharPref("intl.locale.os", aData);
-        Services.prefs.savePrefFile(null);
-
-        let appLocale = this.getUALocalePref();
-
-        this.computeAcceptLanguages(aData, appLocale);
-        break;
-
-      case "Locale:Changed":
-        if (aData) {
-          // The value provided to Locale:Changed should be a BCP47 language tag
-          // understood by Gecko -- for example, "es-ES" or "de".
-          console.log("Locale:Changed: " + aData);
-
-          // We always write a localized pref, even though sometimes the value is a char pref.
-          // (E.g., on desktop single-locale builds.)
-          this.setLocalizedPref("general.useragent.locale", aData);
-        } else {
-          // Resetting.
-          console.log("Switching to system locale.");
-          Services.prefs.clearUserPref("general.useragent.locale");
-        }
-
-        Services.prefs.setBoolPref("intl.locale.matchOS", !aData);
-
-        // Ensure that this choice is immediately persisted, because
-        // Gecko won't be told again if it forgets.
-        Services.prefs.savePrefFile(null);
-
-        // Blow away the string cache so that future lookups get the
-        // correct locale.
-        Strings.flush();
-
-        // Make sure we use the right Accept-Language header.
-        let osLocale;
-        try {
-          // This should never not be set at this point, but better safe than sorry.
-          osLocale = Services.prefs.getCharPref("intl.locale.os");
-        } catch (e) {
-        }
-
-        this.computeAcceptLanguages(osLocale, aData);
-        break;
-
-      case "Fonts:Reload":
-        FontEnumerator.updateFontList();
-        break;
-
       case "Vibration:Request":
         if (aSubject instanceof Navigator) {
           let navigator = aSubject;
           let buttons = [
             {
               label: Strings.browser.GetStringFromName("vibrationRequest.denyButton"),
               callback: function() {
                 navigator.setVibrationPermission(false);
@@ -2184,18 +2200,18 @@ async function installManifest(browser, 
     Cu.reportError("Failed to install: " + err.message);
   }
 }
 
 var NativeWindow = {
   init: function() {
     GlobalEventDispatcher.registerListener(this, [
       "Doorhanger:Reply",
+      "Menu:Clicked",
     ]);
-    Services.obs.addObserver(this, "Menu:Clicked", false);
     this.contextmenus.init();
   },
 
   loadDex: function(zipFile, implClass) {
     GlobalEventDispatcher.sendRequest({
       type: "Dex:Load",
       zipfile: zipFile,
       impl: implClass || "Main"
@@ -2333,23 +2349,20 @@ var NativeWindow = {
 
         let prompt = this.doorhanger._callbacks[reply_id].prompt;
         for (let id in this.doorhanger._callbacks) {
           if (this.doorhanger._callbacks[id].prompt == prompt) {
             delete this.doorhanger._callbacks[id];
           }
         }
       }
-    }
-  },
-
-  observe: function(aSubject, aTopic, aData) {
-    if (aTopic == "Menu:Clicked") {
-      if (this.menu._callbacks[aData])
-        this.menu._callbacks[aData]();
+    } else if (aTopic == "Menu:Clicked") {
+      if (this.menu._callbacks[data.item]) {
+        this.menu._callbacks[data.item]();
+      }
     }
   },
 
   contextmenus: {
     items: {}, //  a list of context menu items that we may show
     DEFAULT_HTML5_ORDER: -1, // Sort order for HTML5 context menu items
 
     init: function() {
@@ -3209,17 +3222,17 @@ var LightWeightThemeWebInstaller = {
 };
 
 var DesktopUserAgent = {
   DESKTOP_UA: null,
   TCO_DOMAIN: "t.co",
   TCO_REPLACE: / Gecko.*/,
 
   init: function ua_init() {
-    Services.obs.addObserver(this, "DesktopMode:Change", false);
+    GlobalEventDispatcher.registerListener(this, "DesktopMode:Change");
     UserAgentOverrides.addComplexOverride(this.onRequest.bind(this));
 
     // See https://developer.mozilla.org/en/Gecko_user_agent_string_reference
     this.DESKTOP_UA = Cc["@mozilla.org/network/protocol;1?name=http"]
                         .getService(Ci.nsIHttpProtocolHandler).userAgent
                         .replace(/Android \d.+?; [a-zA-Z]+/, "X11; Linux x86_64")
                         .replace(/Gecko\/[0-9\.]+/, "Gecko/20100101");
   },
@@ -3284,25 +3297,24 @@ var DesktopUserAgent = {
         return loadContext.associatedWindow;
       } catch (e) {
         // loadContext.associatedWindow can throw when there's no window
       }
     }
     return null;
   },
 
-  observe: function ua_observe(aSubject, aTopic, aData) {
-    if (aTopic === "DesktopMode:Change") {
-      let args = JSON.parse(aData);
-      let tab = BrowserApp.getTabForId(args.tabId);
+  onEvent: function ua_onEvent(event, data, callback) {
+    if (event === "DesktopMode:Change") {
+      let tab = BrowserApp.getTabForId(data.tabId);
       if (tab) {
-        tab.reloadWithMode(args.desktopMode);
-      }
-    }
-  }
+        tab.reloadWithMode(data.desktopMode);
+      }
+    }
+  },
 };
 
 
 function nsBrowserAccess() {
 }
 
 nsBrowserAccess.prototype = {
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIBrowserDOMWindow]),
@@ -5555,24 +5567,23 @@ var XPInstallObserver = {
 
   hideRestartPrompt: function() {
     NativeWindow.doorhanger.hide("addon-app-restart", BrowserApp.selectedTab.id);
   }
 };
 
 var ViewportHandler = {
   init: function init() {
-    Services.obs.addObserver(this, "Window:Resize", false);
-  },
-
-  observe: function(aSubject, aTopic, aData) {
-    if (aTopic == "Window:Resize" && aData) {
-      let scrollChange = JSON.parse(aData);
+    GlobalEventDispatcher.registerListener(this, "Window:Resize");
+  },
+
+  onEvent: function (event, data, callback) {
+    if (event == "Window:Resize" && data) {
       let windowUtils = window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
-      windowUtils.setNextPaintSyncId(scrollChange.id);
+      windowUtils.setNextPaintSyncId(data.id);
     }
   }
 };
 
 /**
  * Handler for blocked popups, triggered by DOMUpdatePageReport events in browser.xml
  */
 var PopupBlockerObserver = {
@@ -5747,28 +5758,30 @@ var IndexedDB = {
     timeoutId = setTimeout(timeoutNotification, firstTimeoutDuration);
   }
 };
 
 var CharacterEncoding = {
   _charsets: [],
 
   init: function init() {
-    Services.obs.addObserver(this, "CharEncoding:Get", false);
-    Services.obs.addObserver(this, "CharEncoding:Set", false);
+    GlobalEventDispatcher.registerListener(this, [
+      "CharEncoding:Get",
+      "CharEncoding:Set",
+    ]);
     InitLater(() => this.sendState());
   },
 
-  observe: function observe(aSubject, aTopic, aData) {
-    switch (aTopic) {
+  onEvent: function onEvent(event, data, callback) {
+    switch (event) {
       case "CharEncoding:Get":
         this.getEncoding();
         break;
       case "CharEncoding:Set":
-        this.setEncoding(aData);
+        this.setEncoding(data.encoding);
         break;
     }
   },
 
   sendState: function sendState() {
     let showCharEncoding = "false";
     try {
       showCharEncoding = Services.prefs.getComplexValue("browser.menu.showCharacterEncoding", Ci.nsIPrefLocalizedString).data;
@@ -6475,18 +6488,17 @@ var Experiments = {
   MALWARE_DOWNLOAD_PROTECTION: "malware-download-protection",
 
   // Try to load pages from disk cache when network is offline (bug 935190)
   OFFLINE_CACHE: "offline-cache",
 
   init() {
     GlobalEventDispatcher.sendRequestForResult({
       type: "Experiments:GetActive"
-    }).then(experiments => {
-      let names = JSON.parse(experiments);
+    }).then(names => {
       for (let name of names) {
         switch (name) {
           case this.MALWARE_DOWNLOAD_PROTECTION: {
             // Apply experiment preferences on the default branch. This allows
             // us to avoid migrating user prefs when experiments are enabled/disabled,
             // and it also allows users to override these prefs in about:config.
             let defaults = Services.prefs.getDefaultBranch(null);
             defaults.setBoolPref("browser.safebrowsing.downloads.enabled", true);
@@ -6667,70 +6679,76 @@ var ExternalApps = {
 
 var Distribution = {
   // File used to store campaign data
   _file: null,
 
   _preferencesJSON: null,
 
   init: function dc_init() {
-    Services.obs.addObserver(this, "Distribution:Changed", false);
-    Services.obs.addObserver(this, "Distribution:Set", false);
+    GlobalEventDispatcher.registerListener(this, [
+      "Campaign:Set",
+      "Distribution:Changed",
+      "Distribution:Set",
+    ]);
     Services.obs.addObserver(this, "prefservice:after-app-defaults", false);
-    Services.obs.addObserver(this, "Campaign:Set", false);
 
     // Look for file outside the APK:
     // /data/data/org.mozilla.xxx/distribution.json
     this._file = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
     this._file.append("distribution.json");
     this.readJSON(this._file, this.update);
   },
 
-  observe: function dc_observe(aSubject, aTopic, aData) {
-    switch (aTopic) {
+  onEvent: function dc_onEvent(event, data, callback) {
+    switch (event) {
+      case "Campaign:Set": {
+        // Update the prefs for this session
+        try {
+          this.update(data);
+        } catch (ex) {
+          Cu.reportError("Distribution: Could not parse JSON: " + ex);
+          return;
+        }
+
+        // Asynchronously copy the data to the file.
+        let array = new TextEncoder().encode(JSON.stringify(data));
+        OS.File.writeAtomic(this._file.path, array, { tmpPath: this._file.path + ".tmp" });
+        break;
+      }
+
       case "Distribution:Changed":
         // Re-init the search service.
         try {
           Services.search._asyncReInit();
         } catch (e) {
           console.log("Unable to reinit search service.");
         }
         // Fall through.
 
       case "Distribution:Set":
-        if (aData) {
+        if (data) {
           try {
-            this._preferencesJSON = JSON.parse(aData);
+            this._preferencesJSON = JSON.parse(data.preferences);
           } catch (e) {
             console.log("Invalid distribution JSON.");
           }
         }
         // Reload the default prefs so we can observe "prefservice:after-app-defaults"
         Services.prefs.QueryInterface(Ci.nsIObserver).observe(null, "reload-default-prefs", null);
         this.installDistroAddons();
         break;
-
+    }
+  },
+
+  observe: function dc_observe(aSubject, aTopic, aData) {
+    switch (aTopic) {
       case "prefservice:after-app-defaults":
         this.getPrefs();
         break;
-
-      case "Campaign:Set": {
-        // Update the prefs for this session
-        try {
-          this.update(JSON.parse(aData));
-        } catch (ex) {
-          Cu.reportError("Distribution: Could not parse JSON: " + ex);
-          return;
-        }
-
-        // Asynchronously copy the data to the file.
-        let array = new TextEncoder().encode(aData);
-        OS.File.writeAtomic(this._file.path, array, { tmpPath: this._file.path + ".tmp" });
-        break;
-      }
     }
   },
 
   update: function dc_update(aData) {
     // Force the distribution preferences on the default branch
     let defaults = Services.prefs.getDefaultBranch(null);
     defaults.setCharPref("distribution.id", aData.id);
     defaults.setCharPref("distribution.version", aData.version);
@@ -6898,57 +6916,62 @@ var Tabs = {
     // On low-memory platforms, always allow tab expiration. On high-mem
     // platforms, allow it to be turned on once we hit a low-mem situation.
     if (BrowserApp.isOnLowMemoryPlatform) {
       this._enableTabExpiration = true;
     } else {
       Services.obs.addObserver(this, "memory-pressure", false);
     }
 
-    // Watch for opportunities to pre-connect to high probability targets.
-    Services.obs.addObserver(this, "Session:Prefetch", false);
+    GlobalEventDispatcher.registerListener(this, [
+      // Watch for opportunities to pre-connect to high probability targets.
+      "Session:Prefetch",
+    ]);
 
     // Track the network connection so we can efficiently use the cache
     // for possible offline rendering.
     Services.obs.addObserver(this, "network:link-status-changed", false);
     let network = Cc["@mozilla.org/network/network-link-service;1"].getService(Ci.nsINetworkLinkService);
     this.useCache = !network.isLinkUp;
 
     BrowserApp.deck.addEventListener("pageshow", this);
     BrowserApp.deck.addEventListener("TabOpen", this);
   },
 
+  onEvent: function(event, data, callback) {
+    switch (event) {
+      case "Session:Prefetch":
+        if (!data.url) {
+          break;
+        }
+        try {
+          let uri = Services.io.newURI(data.url);
+          if (uri && !this._domains.has(uri.host)) {
+            Services.io.QueryInterface(Ci.nsISpeculativeConnect).speculativeConnect2(
+                uri, BrowserApp.selectedBrowser.contentDocument.nodePrincipal, null);
+            this._domains.add(uri.host);
+          }
+        } catch (e) {}
+        break;
+    }
+  },
+
   observe: function(aSubject, aTopic, aData) {
     switch (aTopic) {
       case "memory-pressure":
         if (aData != "heap-minimize") {
           // We received a low-memory related notification. This will enable
           // expirations.
           this._enableTabExpiration = true;
           Services.obs.removeObserver(this, "memory-pressure");
         } else {
           // Use "heap-minimize" as a trigger to expire the most stale tab.
           this.expireLruTab();
         }
         break;
-      case "Session:Prefetch":
-        if (aData) {
-          try {
-            let uri = Services.io.newURI(aData);
-            if (uri && !this._domains.has(uri.host)) {
-              Services.io.QueryInterface(Ci.nsISpeculativeConnect).speculativeConnect2(uri,
-                                                                                       BrowserApp.selectedBrowser
-                                                                                                 .contentDocument
-                                                                                                 .nodePrincipal,
-                                                                                       null);
-              this._domains.add(uri.host);
-            }
-          } catch (e) {}
-        }
-        break;
       case "network:link-status-changed":
         if (["down", "unknown", "up"].indexOf(aData) == -1) {
           return;
         }
         this.useCache = (aData === "down");
         break;
     }
   },
--- a/mobile/android/components/SessionStore.js
+++ b/mobile/android/components/SessionStore.js
@@ -155,39 +155,111 @@ SessionStore.prototype = {
   _forgetClosedTabs: function ss_forgetClosedTabs() {
     for (let [ssid, win] of Object.entries(this._windows)) {
       win.closedTabs = [];
     }
 
     this._lastClosedTabIndex = -1;
   },
 
+  onEvent: function ss_onEvent(event, data, callback) {
+    switch (event) {
+      case "ClosedTabs:StartNotifications":
+        this._notifyClosedTabs = true;
+        log("ClosedTabs:StartNotifications");
+        this._sendClosedTabsToJava(Services.wm.getMostRecentWindow("navigator:browser"));
+        break;
+
+      case "ClosedTabs:StopNotifications":
+        this._notifyClosedTabs = false;
+        log("ClosedTabs:StopNotifications");
+        break;
+
+      case "Session:Restore": {
+        EventDispatcher.instance.unregisterListener(this, "Session:Restore");
+        if (data) {
+          // Be ready to handle any restore failures by making sure we have a valid tab opened
+          let window = Services.wm.getMostRecentWindow("navigator:browser");
+          let restoreCleanup = (function (aSubject, aTopic, aData) {
+              Services.obs.removeObserver(restoreCleanup, "sessionstore-windows-restored");
+
+              if (window.BrowserApp.tabs.length == 0) {
+                window.BrowserApp.addTab("about:home", {
+                  selected: true
+                });
+              }
+              // Normally, _restoreWindow() will have set this to true already,
+              // but we want to make sure it's set even in case of a restore failure.
+              this._startupRestoreFinished = true;
+              log("startupRestoreFinished = true (through notification)");
+          }).bind(this);
+          Services.obs.addObserver(restoreCleanup, "sessionstore-windows-restored", false);
+
+          // Do a restore, triggered by Java
+          this.restoreLastSession(data.sessionString);
+        } else {
+          // Not doing a restore; just send restore message
+          this._startupRestoreFinished = true;
+          log("startupRestoreFinished = true");
+          Services.obs.notifyObservers(null, "sessionstore-windows-restored", "");
+        }
+        break;
+      }
+
+      case "Session:RestoreRecentTabs":
+        this._restoreTabs(data);
+        break;
+
+      case "Tab:KeepZombified": {
+        if (data.nextSelectedTabId >= 0) {
+          this._keepAsZombieTabId = data.nextSelectedTabId;
+          log("Tab:KeepZombified " + data.nextSelectedTabId);
+        }
+        break;
+      }
+
+      case "Tabs:OpenMultiple": {
+        this._openTabs(data);
+
+        if (data.shouldNotifyTabsOpenedToJava) {
+          let window = Services.wm.getMostRecentWindow("navigator:browser");
+          window.WindowEventDispatcher.sendRequest({
+            type: "Tabs:TabsOpened"
+          });
+        }
+        break;
+      }
+    }
+  },
+
   observe: function ss_observe(aSubject, aTopic, aData) {
     let observerService = Services.obs;
     switch (aTopic) {
       case "app-startup":
+        EventDispatcher.instance.registerListener(this, [
+          "ClosedTabs:StartNotifications",
+          "ClosedTabs:StopNotifications",
+          "Session:Restore",
+          "Session:RestoreRecentTabs",
+          "Tab:KeepZombified",
+          "Tabs:OpenMultiple",
+        ]);
         observerService.addObserver(this, "final-ui-startup", true);
         observerService.addObserver(this, "domwindowopened", true);
         observerService.addObserver(this, "domwindowclosed", true);
         observerService.addObserver(this, "browser:purge-session-history", true);
         observerService.addObserver(this, "browser:purge-session-tabs", true);
         observerService.addObserver(this, "quit-application-requested", true);
         observerService.addObserver(this, "quit-application-proceeding", true);
         observerService.addObserver(this, "quit-application", true);
-        observerService.addObserver(this, "Session:Restore", true);
         observerService.addObserver(this, "Session:NotifyLocationChange", true);
         observerService.addObserver(this, "Content:HistoryChange", true);
-        observerService.addObserver(this, "Tab:KeepZombified", true);
         observerService.addObserver(this, "application-background", true);
         observerService.addObserver(this, "application-foreground", true);
-        observerService.addObserver(this, "ClosedTabs:StartNotifications", true);
-        observerService.addObserver(this, "ClosedTabs:StopNotifications", true);
         observerService.addObserver(this, "last-pb-context-exited", true);
-        observerService.addObserver(this, "Session:RestoreRecentTabs", true);
-        observerService.addObserver(this, "Tabs:OpenMultiple", true);
         break;
       case "final-ui-startup":
         observerService.removeObserver(this, "final-ui-startup");
         this.init();
         break;
       case "domwindowopened": {
         let window = aSubject;
         window.addEventListener("load", () => {
@@ -269,49 +341,16 @@ SessionStore.prototype = {
           // Timer call back for delayed saving
           this._saveTimer = null;
           log("timer-callback, pendingWrite = " + this._pendingWrite);
           if (this._pendingWrite) {
             this.saveState();
           }
         }
         break;
-      case "Session:Restore": {
-        Services.obs.removeObserver(this, "Session:Restore");
-        if (aData) {
-          // Be ready to handle any restore failures by making sure we have a valid tab opened
-          let window = Services.wm.getMostRecentWindow("navigator:browser");
-          let restoreCleanup = {
-            observe: function (aSubject, aTopic, aData) {
-              Services.obs.removeObserver(restoreCleanup, "sessionstore-windows-restored");
-
-              if (window.BrowserApp.tabs.length == 0) {
-                window.BrowserApp.addTab("about:home", {
-                  selected: true
-                });
-              }
-              // Normally, _restoreWindow() will have set this to true already,
-              // but we want to make sure it's set even in case of a restore failure.
-              this._startupRestoreFinished = true;
-              log("startupRestoreFinished = true (through notification)");
-            }.bind(this)
-          };
-          Services.obs.addObserver(restoreCleanup, "sessionstore-windows-restored", false);
-
-          // Do a restore, triggered by Java
-          let data = JSON.parse(aData);
-          this.restoreLastSession(data.sessionString);
-        } else {
-          // Not doing a restore; just send restore message
-          this._startupRestoreFinished = true;
-          log("startupRestoreFinished = true");
-          Services.obs.notifyObservers(null, "sessionstore-windows-restored", "");
-        }
-        break;
-      }
       case "Session:NotifyLocationChange": {
         let browser = aSubject;
 
         if (browser.__SS_restoreReloadPending && this._startupRestoreFinished) {
           delete browser.__SS_restoreReloadPending;
           log("remove restoreReloadPending");
         }
 
@@ -337,36 +376,16 @@ SessionStore.prototype = {
           browser.__SS_historyChange =
             window.setTimeout(() => {
               delete browser.__SS_historyChange;
               this.onTabLoad(window, browser);
             }, 0);
         }
         break;
       }
-      case "Tabs:OpenMultiple": {
-        let data = JSON.parse(aData);
-
-        this._openTabs(data);
-
-        if (data.shouldNotifyTabsOpenedToJava) {
-          let window = Services.wm.getMostRecentWindow("navigator:browser");
-          window.WindowEventDispatcher.sendRequest({
-            type: "Tabs:TabsOpened"
-          });
-        }
-        break;
-      }
-      case "Tab:KeepZombified": {
-        if (aData >= 0) {
-          this._keepAsZombieTabId = aData;
-          log("Tab:KeepZombified " + aData);
-        }
-        break;
-      }
       case "application-background":
         // We receive this notification when Android's onPause callback is
         // executed. After onPause, the application may be terminated at any
         // point without notice; therefore, we must synchronously write out any
         // pending save state to ensure that this data does not get lost.
         log("application-background");
         // Tab events dispatched immediately before the application was backgrounded
         // might actually arrive after this point, therefore save them without delay.
@@ -385,37 +404,23 @@ SessionStore.prototype = {
         // If we skipped restoring a zombified tab before backgrounding,
         // we might have to do it now instead.
         let window = Services.wm.getMostRecentWindow("navigator:browser");
         if (window) { // Might not yet be ready during a cold startup.
           let tab = window.BrowserApp.selectedTab;
           this.restoreZombieTab(tab);
         }
         break;
-      case "ClosedTabs:StartNotifications":
-        this._notifyClosedTabs = true;
-        log("ClosedTabs:StartNotifications");
-        this._sendClosedTabsToJava(Services.wm.getMostRecentWindow("navigator:browser"));
-        break;
-      case "ClosedTabs:StopNotifications":
-        this._notifyClosedTabs = false;
-        log("ClosedTabs:StopNotifications");
-        break;
       case "last-pb-context-exited":
         // Clear private closed tab data when we leave private browsing.
         for (let window of Object.values(this._windows)) {
           window.closedTabs = window.closedTabs.filter(tab => !tab.isPrivate);
         }
         this._lastClosedTabIndex = -1;
         break;
-      case "Session:RestoreRecentTabs": {
-        let data = JSON.parse(aData);
-        this._restoreTabs(data);
-        break;
-      }
     }
   },
 
   handleEvent: function ss_handleEvent(aEvent) {
     let window = aEvent.currentTarget.ownerGlobal;
     switch (aEvent.type) {
       case "TabOpen": {
         let browser = aEvent.target;
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/BaseGeckoInterface.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/BaseGeckoInterface.java
@@ -90,21 +90,16 @@ public class BaseGeckoInterface implemen
     }
 
     // Bug 908791: Implement this
     @Override
     public AbsoluteLayout getPluginContainer() {
         return null;
     }
 
-    @Override
-    public void notifyCheckUpdateResult(String result) {
-        GeckoAppShell.notifyObservers("Update:CheckResult", result);
-    }
-
     // Bug 908792: Implement this
     @Override
     public void invalidateOptionsMenu() {}
 
     @Override
     public void createShortcut(String title, String URI) {
         // By default, do nothing.
     }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
@@ -229,17 +229,19 @@ public final class EventDispatcher exten
                          final EventCallback callback) {
         synchronized (this) {
             if (mAttachedToGecko && hasGeckoListener(type)) {
                 dispatchToGecko(type, message, JavaCallbackDelegate.wrap(callback));
                 return;
             }
         }
 
-        dispatchToThreads(type, message, /* callback */ callback);
+        if (!dispatchToThreads(type, message, /* callback */ callback)) {
+            Log.w(LOGTAG, "No listener for " + type);
+        }
     }
 
     @WrapForJNI(calledFrom = "gecko")
     private boolean dispatchToThreads(final String type,
                                       final GeckoBundle message,
                                       final EventCallback callback) {
         final List<BundleEventListener> geckoListeners;
         synchronized (mGeckoThreadListeners) {
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAccessibility.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAccessibility.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.json.JSONException;
 import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.util.UIAsyncTask;
 
 import android.content.Context;
 import android.graphics.Rect;
 import android.os.Build;
 import android.os.Bundle;
@@ -41,32 +42,27 @@ public class GeckoAccessibility {
     // This is the number Brailleback uses to start indexing routing keys.
     private static final int BRAILLE_CLICK_BASE_INDEX = -275000000;
     private static SelfBrailleClient sSelfBrailleClient;
 
     public static void updateAccessibilitySettings (final Context context) {
         new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
                 @Override
                 public Void doInBackground() {
-                    JSONObject ret = new JSONObject();
                     sEnabled = false;
                     AccessibilityManager accessibilityManager =
                         (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
                     sEnabled = accessibilityManager.isEnabled() && accessibilityManager.isTouchExplorationEnabled();
                     if (Build.VERSION.SDK_INT >= 16 && sEnabled && sSelfBrailleClient == null) {
                         sSelfBrailleClient = new SelfBrailleClient(context, false);
                     }
 
-                    try {
-                        ret.put("enabled", sEnabled);
-                    } catch (Exception ex) {
-                        Log.e(LOGTAG, "Error building JSON arguments for Accessibility:Settings:", ex);
-                    }
-
-                    GeckoAppShell.notifyObservers("Accessibility:Settings", ret.toString());
+                    final GeckoBundle ret = new GeckoBundle(1);
+                    ret.putBoolean("enabled", sEnabled);
+                    EventDispatcher.getInstance().dispatch("Accessibility:Settings", ret);
                     return null;
                 }
 
                 @Override
                 public void onPostExecute(Void args) {
                     final GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface();
                     if (geckoInterface == null) {
                         return;
@@ -259,18 +255,21 @@ public class GeckoAccessibility {
                 public void onTouchExplorationStateChanged(boolean enabled) {
                     updateAccessibilitySettings(context);
                 }
             });
         }
     }
 
     public static void onLayerViewFocusChanged(boolean gainFocus) {
-        if (sEnabled)
-            GeckoAppShell.notifyObservers("Accessibility:Focus", gainFocus ? "true" : "false");
+        if (sEnabled) {
+            final GeckoBundle data = new GeckoBundle(1);
+            data.putBoolean("gainFocus", gainFocus);
+            EventDispatcher.getInstance().dispatch("Accessibility:Focus", data);
+        }
     }
 
     public static class GeckoAccessibilityDelegate extends View.AccessibilityDelegate {
         AccessibilityNodeProvider mAccessibilityNodeProvider;
 
         @Override
         public AccessibilityNodeProvider getAccessibilityNodeProvider(final View host) {
             if (mAccessibilityNodeProvider == null)
@@ -322,87 +321,75 @@ public class GeckoAccessibility {
                         @Override
                         public boolean performAction (int virtualViewId, int action, Bundle arguments) {
                             if (action == AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) {
                                 // The accessibility focus is permanently on the middle node, VIRTUAL_CURSOR_POSITION.
                                 // When we enter the view forward or backward we just ask Gecko to get focus, keeping the current position.
                                 if (virtualViewId == VIRTUAL_CURSOR_POSITION && sHoverEnter != null) {
                                     GeckoAccessibility.sendAccessibilityEvent(sHoverEnter, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
                                 } else {
-                                    GeckoAppShell.notifyObservers("Accessibility:Focus", "true");
+                                    final GeckoBundle data = new GeckoBundle(1);
+                                    data.putBoolean("gainFocus", true);
+                                    EventDispatcher.getInstance().dispatch("Accessibility:Focus", data);
                                 }
                                 return true;
                             } else if (action == AccessibilityNodeInfo.ACTION_CLICK && virtualViewId == VIRTUAL_CURSOR_POSITION) {
-                                GeckoAppShell.notifyObservers("Accessibility:ActivateObject", null);
+                                EventDispatcher.getInstance().dispatch("Accessibility:ActivateObject", null);
                                 return true;
                             } else if (action == AccessibilityNodeInfo.ACTION_LONG_CLICK && virtualViewId == VIRTUAL_CURSOR_POSITION) {
-                                GeckoAppShell.notifyObservers("Accessibility:LongPress", null);
+                                EventDispatcher.getInstance().dispatch("Accessibility:LongPress", null);
                                 return true;
                             } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD && virtualViewId == VIRTUAL_CURSOR_POSITION) {
-                                GeckoAppShell.notifyObservers("Accessibility:ScrollForward", null);
+                                EventDispatcher.getInstance().dispatch("Accessibility:ScrollForward", null);
                                 return true;
                             } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD && virtualViewId == VIRTUAL_CURSOR_POSITION) {
-                                GeckoAppShell.notifyObservers("Accessibility:ScrollBackward", null);
+                                EventDispatcher.getInstance().dispatch("Accessibility:ScrollBackward", null);
                                 return true;
-                            } else if (action == AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT && virtualViewId == VIRTUAL_CURSOR_POSITION) {
-                                String traversalRule = "";
+                            } else if ((action == AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT ||
+                                        action == AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT) && virtualViewId == VIRTUAL_CURSOR_POSITION) {
+                                final GeckoBundle data;
                                 if (arguments != null) {
-                                    traversalRule = arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING);
+                                    data = new GeckoBundle(1);
+                                    data.putString("rule", arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING));
+                                } else {
+                                    data = null;
                                 }
-                                GeckoAppShell.notifyObservers("Accessibility:NextObject", traversalRule);
-                                return true;
-                            } else if (action == AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT && virtualViewId == VIRTUAL_CURSOR_POSITION) {
-                                String traversalRule = "";
-                                if (arguments != null) {
-                                    traversalRule = arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING);
-                                }
-                                GeckoAppShell.notifyObservers("Accessibility:PreviousObject", traversalRule);
+                                EventDispatcher.getInstance().dispatch(action == AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT ?
+                                        "Accessibility:NextObject" : "Accessibility:PreviousObject", data);
                                 return true;
                             } else if (action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY &&
                                        virtualViewId == VIRTUAL_CURSOR_POSITION) {
                                 // XXX: Self brailling gives this action with a bogus argument instead of an actual click action;
                                 // the argument value is the BRAILLE_CLICK_BASE_INDEX - the index of the routing key that was hit.
                                 // Other negative values are used by ChromeVox, but we don't support them.
                                 // FAKE_GRANULARITY_READ_CURRENT = -1
                                 // FAKE_GRANULARITY_READ_TITLE = -2
                                 // FAKE_GRANULARITY_STOP_SPEECH = -3
                                 // FAKE_GRANULARITY_CHANGE_SHIFTER = -4
                                 int granularity = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
                                 if (granularity <= BRAILLE_CLICK_BASE_INDEX) {
                                     int keyIndex = BRAILLE_CLICK_BASE_INDEX - granularity;
-                                    JSONObject activationData = new JSONObject();
-                                    try {
-                                        activationData.put("keyIndex", keyIndex);
-                                    } catch (JSONException e) {
-                                        return true;
-                                    }
-                                    GeckoAppShell.notifyObservers("Accessibility:ActivateObject", activationData.toString());
+                                    final GeckoBundle data = new GeckoBundle(1);
+                                    data.putInt("keyIndex", keyIndex);
+                                    EventDispatcher.getInstance().dispatch("Accessibility:ActivateObject", data);
                                 } else if (granularity > 0) {
-                                    JSONObject movementData = new JSONObject();
-                                    try {
-                                        movementData.put("direction", "Next");
-                                        movementData.put("granularity", granularity);
-                                    } catch (JSONException e) {
-                                        return true;
-                                    }
-                                    GeckoAppShell.notifyObservers("Accessibility:MoveByGranularity", movementData.toString());
+                                    final GeckoBundle data = new GeckoBundle(2);
+                                    data.putString("direction", "Next");
+                                    data.putInt("granularity", granularity);
+                                    EventDispatcher.getInstance().dispatch("Accessibility:MoveByGranularity", data);
                                 }
                                 return true;
                             } else if (action == AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY &&
                                        virtualViewId == VIRTUAL_CURSOR_POSITION) {
-                                JSONObject movementData = new JSONObject();
                                 int granularity = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
-                                try {
-                                    movementData.put("direction", "Previous");
-                                    movementData.put("granularity", granularity);
-                                } catch (JSONException e) {
-                                    return true;
-                                }
+                                final GeckoBundle data = new GeckoBundle(2);
+                                data.putString("direction", "Previous");
+                                data.putInt("granularity", granularity);
                                 if (granularity > 0) {
-                                    GeckoAppShell.notifyObservers("Accessibility:MoveByGranularity", movementData.toString());
+                                    EventDispatcher.getInstance().dispatch("Accessibility:MoveByGranularity", data);
                                 }
                                 return true;
                             }
                             return host.performAccessibilityAction(action, arguments);
                         }
                     };
 
             return mAccessibilityNodeProvider;
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java
@@ -1720,17 +1720,16 @@ public class GeckoAppShell
         public void removePluginView(final View view);
         public void enableOrientationListener();
         public void disableOrientationListener();
         public void addAppStateListener(AppStateListener listener);
         public void removeAppStateListener(AppStateListener listener);
         public void notifyWakeLockChanged(String topic, String state);
         public boolean areTabsShown();
         public AbsoluteLayout getPluginContainer();
-        public void notifyCheckUpdateResult(String result);
         public void invalidateOptionsMenu();
         public boolean isForegrounded();
 
         /**
          * Create a shortcut -- generally a home-screen icon -- linking the given title to the given URI.
          * <p>
          * This method is always invoked on the Gecko thread.
          *
@@ -1987,17 +1986,17 @@ public class GeckoAppShell
         GeckoView v = (GeckoView) getLayerView();
         if (v == null) {
             return;
         }
         boolean imeIsEnabled = v.isIMEEnabled();
         if (imeIsEnabled && !sImeWasEnabledOnLastResize) {
             // The IME just came up after not being up, so let's scroll
             // to the focused input.
-            notifyObservers("ScrollTo:FocusedInput", "");
+            EventDispatcher.getInstance().dispatch("ScrollTo:FocusedInput", null);
         }
         sImeWasEnabledOnLastResize = imeIsEnabled;
     }
 
     @WrapForJNI(calledFrom = "gecko")
     private static double[] getCurrentNetworkInformation() {
         return GeckoNetworkManager.getInstance().getCurrentInformation();
     }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoView.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoView.java
@@ -3,18 +3,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;
 
 import java.util.Set;
 
-import org.json.JSONException;
-import org.json.JSONObject;
 import org.mozilla.gecko.annotation.ReflectionTarget;
 import org.mozilla.gecko.annotation.WrapForJNI;
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.mozglue.JNIObject;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
 import org.mozilla.gecko.util.GeckoBundle;
 
@@ -408,17 +406,19 @@ public class GeckoView extends LayerView
 
     /* package */ boolean isIMEEnabled() {
         return mInputConnectionListener != null &&
                 mInputConnectionListener.isIMEEnabled();
     }
 
     public void importScript(final String url) {
         if (url.startsWith("resource://android/assets/")) {
-            GeckoAppShell.notifyObservers("GeckoView:ImportScript", url);
+            final GeckoBundle data = new GeckoBundle(1);
+            data.putString("scriptURL", url);
+            getEventDispatcher().dispatch("GeckoView:ImportScript", data);
             return;
         }
 
         throw new IllegalArgumentException("Must import script from 'resources://android/assets/' location.");
     }
 
     /**
     * Set the chrome callback handler.
--- 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
@@ -2,31 +2,32 @@
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.gfx;
 
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.gfx.LayerView.DrawListener;
 import org.mozilla.gecko.util.FloatUtils;
+import org.mozilla.gecko.util.GeckoBundle;
 
 import android.content.Context;
 import android.graphics.Color;
 import android.graphics.Matrix;
 import android.graphics.PointF;
 import android.graphics.RectF;
 import android.os.SystemClock;
 import android.util.DisplayMetrics;
 import android.util.Log;
 import android.view.InputDevice;
 import android.view.MotionEvent;
-import org.json.JSONObject;
 
 import java.util.ArrayList;
 import java.util.List;
 
 class GeckoLayerClient implements LayerView.Listener, PanZoomTarget
 {
     private static final String LOGTAG = "GeckoLayerClient";
     private static int sPaintSyncId = 1;
@@ -226,35 +227,32 @@ class GeckoLayerClient implements LayerV
             Log.d(LOGTAG, "Window-size changed to " + mWindowSize);
         }
 
         if (mView != null) {
             mView.notifySizeChanged(mWindowSize.width, mWindowSize.height,
                                     mScreenSize.width, mScreenSize.height);
         }
 
-        String json = "";
-        try {
-            if (scrollChange != null) {
-                int id = ++sPaintSyncId;
-                if (id == 0) {
-                    // never use 0 as that is the default value for "this is not
-                    // a special transaction"
-                    id = ++sPaintSyncId;
-                }
-                JSONObject jsonObj = new JSONObject();
-                jsonObj.put("x", scrollChange.x / mViewportMetrics.zoomFactor);
-                jsonObj.put("y", scrollChange.y / mViewportMetrics.zoomFactor);
-                jsonObj.put("id", id);
-                json = jsonObj.toString();
+        final GeckoBundle data;
+        if (scrollChange != null) {
+            int id = ++sPaintSyncId;
+            if (id == 0) {
+                // never use 0 as that is the default value for "this is not
+                // a special transaction"
+                id = ++sPaintSyncId;
             }
-        } catch (Exception e) {
-            Log.e(LOGTAG, "Unable to convert point to JSON", e);
+            data = new GeckoBundle(3);
+            data.putDouble("x", scrollChange.x / mViewportMetrics.zoomFactor);
+            data.putDouble("y", scrollChange.y / mViewportMetrics.zoomFactor);
+            data.putInt("id", id);
+        } else {
+            data = null;
         }
-        GeckoAppShell.notifyObservers("Window:Resize", json);
+        EventDispatcher.getInstance().dispatch("Window:Resize", data);
     }
 
     /**
      * The different types of Viewport messages handled. All viewport events
      * expect a display-port to be returned, but can handle one not being
      * returned.
      */
     private enum ViewportMessageType {
--- a/mobile/android/modules/Notifications.jsm
+++ b/mobile/android/modules/Notifications.jsm
@@ -1,25 +1,20 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
-Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Messaging.jsm");
 
 this.EXPORTED_SYMBOLS = ["Notifications"];
 
-function log(msg) {
-  // Services.console.logStringMessage(msg);
-}
-
 var _notificationsMap = {};
 var _handlersMap = {};
 
 function Notification(aId, aOptions) {
   this._id = aId;
   this._when = (new Date()).getTime();
   this.fillWithOptions(aOptions);
 }
@@ -197,20 +192,17 @@ var Notifications = {
   },
 
   cancel: function notif_cancel(aId) {
     let notification = _notificationsMap[aId];
     if (notification)
       notification.cancel();
   },
 
-  observe: function notif_observe(aSubject, aTopic, aData) {
-    Services.console.logStringMessage(aTopic + " " + aData);
-
-    let data = JSON.parse(aData);
+  onEvent: function notif_onEvent(event, data, callback) {
     let id = data.id;
     let handlerKey = data.handlerKey;
     let cookie = data.cookie ? JSON.parse(data.cookie) : undefined;
     let notification = _notificationsMap[id];
 
     switch (data.eventType) {
       case "notification-clicked":
         if (notification && notification._onClick)
@@ -250,9 +242,9 @@ var Notifications = {
     if (!aIID.equals(Ci.nsISupports) &&
         !aIID.equals(Ci.nsIObserver) &&
         !aIID.equals(Ci.nsISupportsWeakReference))
       throw Components.results.NS_ERROR_NO_INTERFACE;
     return this;
   }
 };
 
-Services.obs.addObserver(Notifications, "Notification:Event", false);
+EventDispatcher.instance.registerListener(Notifications, "Notification:Event");
--- a/mobile/android/tests/browser/chrome/test_session_zombification.html
+++ b/mobile/android/tests/browser/chrome/test_session_zombification.html
@@ -118,17 +118,17 @@ https://bugzilla.mozilla.org/show_bug.cg
     // Zombify the backgrounded test tab
     tabTest.zombify();
 
     // Check that the test tab is actually zombified
     ok(tabTest.browser.__SS_restore, "Test tab is set for delay loading.");
     is(tabTest.browser.currentURI.spec, "about:blank", "Test tab is zombified.");
 
     // Tell the session store that it shouldn't restore that tab on selecting
-    observerService.notifyObservers(null, "Tab:KeepZombified", tabTest.id);
+    EventDispatcher.instance.dispatch("Tab:KeepZombified", {nextSelectedTabId: tabTest.id});
 
     // Switch back to the test tab and check that it remains zombified
     BrowserApp.selectTab(tabTest);
     yield promiseTabEvent(BrowserApp.deck, "TabSelect");
     is(BrowserApp.selectedTab, tabTest, "Test tab is selected.");
     ok(tabTest.browser.__SS_restore, "Test tab is still set for delay loading.");
 
     // Switch to the other tab and back again
@@ -149,17 +149,17 @@ https://bugzilla.mozilla.org/show_bug.cg
     BrowserApp.selectTab(tabBlank);
     yield promiseTabEvent(BrowserApp.deck, "TabSelect");
     is(BrowserApp.selectedTab, tabBlank, "Test tab is in background.");
     tabTest.zombify();
     ok(tabTest.browser.__SS_restore, "Test tab is set for delay loading.");
     is(tabTest.browser.currentURI.spec, "about:blank", "Test tab is zombified.");
 
     // Tell the session store that it shouldn't restore that tab on selecting
-    observerService.notifyObservers(null, "Tab:KeepZombified", tabTest.id);
+    EventDispatcher.instance.dispatch("Tab:KeepZombified", {nextSelectedTabId: tabTest.id});
 
     // Switch back to the test tab and check that it remains zombified
     BrowserApp.selectTab(tabTest);
     yield promiseTabEvent(BrowserApp.deck, "TabSelect");
     is(BrowserApp.selectedTab, tabTest, "Test tab is selected.");
     ok(tabTest.browser.__SS_restore, "Test tab is still set for delay loading.");
 
     // Fake an "application-foreground" notification
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testAndroidCastDeviceProvider.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testAndroidCastDeviceProvider.java
@@ -24,24 +24,21 @@ public class testAndroidCastDeviceProvid
                               final EventCallback callback) {
       mAsserter.dumpLog("Got event: " + event);
 
       if ("AndroidCastDevice:Start".equals(event)) {
         callback.sendSuccess("Succeed to start presentation.");
         GeckoAppShell.notifyObservers("presentation-view-ready", "chromecast");
 
       } else if ("AndroidCastDevice:SyncDevice".equals(event)) {
-        final JSONObject json = new JSONObject();
-        try {
-            json.put("uuid", "existed-chromecast");
-            json.put("friendlyName", "existed-chromecast");
-            json.put("type", "chromecast");
-        } catch (JSONException ex) {
-        }
-        GeckoAppShell.notifyObservers("AndroidCastDevice:Added", json.toString());
+        final GeckoBundle data = new GeckoBundle(3);
+        data.putString("uuid", "existed-chromecast");
+        data.putString("friendlyName", "existed-chromecast");
+        data.putString("type", "chromecast");
+        EventDispatcher.getInstance().dispatch("AndroidCastDevice:Added", data);
       }
     }
 
     @Override
     public void setUp() throws Exception {
       super.setUp();
       EventDispatcher.getInstance().registerGeckoThreadListener(this, "AndroidCastDevice:Start",
                                                                       "AndroidCastDevice:SyncDevice");
--- a/mobile/android/tests/browser/robocop/testAndroidCastDeviceProvider.js
+++ b/mobile/android/tests/browser/robocop/testAndroidCastDeviceProvider.js
@@ -5,17 +5,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 /* jshint esnext:true, globalstrict:true, moz:true, undef:true, unused:true */
 /* globals Components */
 
 "use strict";
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu  } = Components;
 
-Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Messaging.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 // event name
 const TOPIC_ANDROID_CAST_DEVICE_ADDED   = "AndroidCastDevice:Added";
 const TOPIC_ANDROID_CAST_DEVICE_REMOVED = "AndroidCastDevice:Removed";
 const TOPIC_ANDROID_CAST_DEVICE_START   = "AndroidCastDevice:Start";
 const TOPIC_PRESENTATION_VIEW_READY     = "presentation-view-ready";
 
@@ -157,35 +157,35 @@ function deviceManagement() {
   };
 
   // Sync device from Android.
   Promise.race([listener.isAddDeviceCalled, listener.isUpdateDeviceCalled])
     .then(() => {
       listener.reset();
       ok(listener.count() == 1, "There should be one device in device manager after sync device.");
       // Remove the device.
-      Services.obs.notifyObservers(null, TOPIC_ANDROID_CAST_DEVICE_REMOVED, "existed-chromecast");
+      EventDispatcher.instance.dispatch(TOPIC_ANDROID_CAST_DEVICE_REMOVED, {id: "existed-chromecast"});
       return listener.isRemoveDeviceCalled;
   }).then(() => {
       listener.reset();
       ok(listener.count() == 0, "There should be no any device after the device is removed.");
       // Add the device.
-      Services.obs.notifyObservers(null, TOPIC_ANDROID_CAST_DEVICE_ADDED, JSON.stringify(device));
+      EventDispatcher.instance.dispatch(TOPIC_ANDROID_CAST_DEVICE_ADDED, device);
       return listener.isAddDeviceCalled;
   }).then(() => {
       listener.reset();
       ok(listener.count() == 1, "There should be only one device in device manager.");
       // Add the same device, and it should trigger updateDevice.
-      Services.obs.notifyObservers(null, TOPIC_ANDROID_CAST_DEVICE_ADDED, JSON.stringify(device));
+      EventDispatcher.instance.dispatch(TOPIC_ANDROID_CAST_DEVICE_ADDED, device);
       return listener.isUpdateDeviceCalled;
   }).then(() => {
       listener.reset();
       ok(listener.count() == 1, "There should still only one device in device manager.");
       // Remove the device.
-      Services.obs.notifyObservers(null, TOPIC_ANDROID_CAST_DEVICE_REMOVED, device.uuid);
+      EventDispatcher.instance.dispatch(TOPIC_ANDROID_CAST_DEVICE_REMOVED, {id: device.uuid});
       return listener.isRemoveDeviceCalled;
   }).then(() => {
       listener.reset();
       ok(listener.count() == 0, "There should be no any device after the device is removed.");
       do_test_finished();
       run_next_test();
   });
 }
@@ -219,17 +219,17 @@ function presentationLaunchAndTerminate(
   provider.listener = listener;
 
   let device = {
     uuid: "chromecast",
     friendlyName: "chromecast"
   };
 
   // Add and get the device.
-  Services.obs.notifyObservers(null, TOPIC_ANDROID_CAST_DEVICE_ADDED, JSON.stringify(device));
+  EventDispatcher.instance.dispatch(TOPIC_ANDROID_CAST_DEVICE_ADDED, device);
   let presentationDevice = listener.getDevice(device.uuid).QueryInterface(Ci.nsIPresentationDevice);
   ok(presentationDevice != null, "It should have nsIPresentationDevice interface.");
 
   controllerControlChannel = presentationDevice.establishControlChannel();
   controllerControlChannel.QueryInterface(Ci.nsIPresentationControlChannel);
 
   controllerControlChannelListener = new TestControlChannelListener("controller");
   controllerControlChannel.listener = controllerControlChannelListener;
--- a/widget/android/EventDispatcher.cpp
+++ b/widget/android/EventDispatcher.cpp
@@ -709,17 +709,17 @@ EventDispatcher::DispatchOnGecko(Listene
     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);
+        Unused << NS_WARN_IF(NS_FAILED(rv));
     }
     return NS_OK;
 }
 
 NS_IMETHODIMP
 EventDispatcher::Dispatch(JS::HandleValue aEvent, JS::HandleValue aData,
                           nsIAndroidEventCallback* aCallback, JSContext* aCx)
 {