Bug 1004495 - Revamp notification handlers to work when Gecko is not running. r=bnicholson
authorWes Johnston <wjohnston@mozilla.com>
Fri, 27 Jun 2014 13:25:00 -0700
changeset 215105 6ee3c3ba17b589a7dd9faa33bdeffdf3823d158d
parent 215104 0814bb0f08d097ee4eb0bd7b2e5794357f76c766
child 215106 cb01d0c61cca693a87e357ad28ad2571f40f750a
push id3857
push userraliiev@mozilla.com
push dateTue, 02 Sep 2014 16:39:23 +0000
treeherdermozilla-beta@5638b907b505 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbnicholson
bugs1004495
milestone33.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 1004495 - Revamp notification handlers to work when Gecko is not running. r=bnicholson
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/GeckoApp.java
mobile/android/base/GeckoApplication.java
mobile/android/base/NotificationClient.java
mobile/android/base/NotificationHelper.java
mobile/android/chrome/content/browser.js
mobile/android/chrome/content/downloads.js
mobile/android/modules/Notifications.jsm
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -132,16 +132,23 @@
 
             <meta-data android:name="com.sec.minimode.icon.landscape.normal"
                        android:resource="@drawable/icon" />
 
             <intent-filter>
                 <action android:name="org.mozilla.gecko.ACTION_ALERT_CALLBACK" />
             </intent-filter>
 
+            <!-- Notification API V2 -->
+            <intent-filter>
+                <action android:name="@ANDROID_PACKAGE_NAME@.helperBroadcastAction" />
+                <data android:scheme="moz-notification" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+
             <intent-filter>
                 <action android:name="org.mozilla.gecko.UPDATE"/>
                 <category android:name="android.intent.category.DEFAULT" />
             </intent-filter>
 
             <!-- Default browser intents -->
             <intent-filter>
                 <action android:name="android.intent.action.VIEW" />
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -1317,17 +1317,16 @@ public abstract class GeckoApp
                     public void run() {
                         GeckoApp.this.onLocaleReady(uiLocale);
                     }
                 });
             }
         });
 
         GeckoAppShell.setNotificationClient(makeNotificationClient());
-        NotificationHelper.init(getApplicationContext());
         IntentHelper.init(this);
     }
 
     /**
      * At this point, the resource system and the rest of the browser are
      * aware of the locale.
      *
      * Now we can display strings!
@@ -1622,16 +1621,18 @@ public abstract class GeckoApp
                 Tabs.getInstance().notifyListeners(selectedTab, Tabs.TabEvents.SELECTED);
             geckoConnected();
             GeckoAppShell.setLayerClient(mLayerView.getLayerClientObject());
             GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Viewport:Flush", null));
         }
 
         if (ACTION_ALERT_CALLBACK.equals(action)) {
             processAlertCallback(intent);
+        } else if (NotificationHelper.HELPER_BROADCAST_ACTION.equals(action)) {
+            NotificationHelper.getInstance(getApplicationContext()).handleNotificationIntent(intent);
         }
     }
 
     private String restoreSessionTabs(final boolean isExternalURL) throws SessionRestoreException {
         try {
             String sessionString = getProfile().readSessionFile(false);
             if (sessionString == null) {
                 throw new SessionRestoreException("Could not read from session file");
@@ -1896,32 +1897,34 @@ public abstract class GeckoApp
         } else if (ACTION_HOMESCREEN_SHORTCUT.equals(action)) {
             String uri = getURIFromIntent(intent);
             GeckoAppShell.sendEventToGecko(GeckoEvent.createBookmarkLoadEvent(uri));
         } else if (Intent.ACTION_SEARCH.equals(action)) {
             String uri = getURIFromIntent(intent);
             GeckoAppShell.sendEventToGecko(GeckoEvent.createURILoadEvent(uri));
         } else if (ACTION_ALERT_CALLBACK.equals(action)) {
             processAlertCallback(intent);
+        } else if (NotificationHelper.HELPER_BROADCAST_ACTION.equals(action)) {
+            NotificationHelper.getInstance(getApplicationContext()).handleNotificationIntent(intent);
         } else if (ACTION_LAUNCH_SETTINGS.equals(action)) {
             // Check if launched from data reporting notification.
             Intent settingsIntent = new Intent(GeckoApp.this, GeckoPreferences.class);
             // Copy extras.
             settingsIntent.putExtras(intent);
             startActivity(settingsIntent);
         }
     }
 
     /*
      * Handles getting a uri from and intent in a way that is backwards
      * compatable with our previous implementations
      */
     protected String getURIFromIntent(Intent intent) {
         final String action = intent.getAction();
-        if (ACTION_ALERT_CALLBACK.equals(action))
+        if (ACTION_ALERT_CALLBACK.equals(action) || NotificationHelper.HELPER_BROADCAST_ACTION.equals(action))
             return null;
 
         String uri = intent.getDataString();
         if (uri != null)
             return uri;
 
         if ((action != null && action.startsWith(ACTION_WEBAPP_PREFIX)) || ACTION_HOMESCREEN_SHORTCUT.equals(action)) {
             uri = intent.getStringExtra("args");
--- a/mobile/android/base/GeckoApplication.java
+++ b/mobile/android/base/GeckoApplication.java
@@ -117,16 +117,18 @@ public class GeckoApplication extends Ap
 
     @Override
     public void onCreate() {
         HardwareUtils.init(getApplicationContext());
         Clipboard.init(getApplicationContext());
         FilePicker.init(getApplicationContext());
         GeckoLoader.loadMozGlue();
         HomePanelsManager.getInstance().init(getApplicationContext());
+        // This getInstance call will force initializatino of the NotificationHelper, but does nothing with the result
+        NotificationHelper.getInstance(getApplicationContext()).init();
         super.onCreate();
     }
 
     public boolean isApplicationInBackground() {
         return mInBackground;
     }
 
     public LightweightTheme getLightweightTheme() {
--- a/mobile/android/base/NotificationClient.java
+++ b/mobile/android/base/NotificationClient.java
@@ -15,17 +15,17 @@ import java.util.concurrent.ConcurrentHa
 
 /**
  * Client for posting notifications through a NotificationHandler.
  */
 public abstract class NotificationClient {
     private static final String LOGTAG = "GeckoNotificationClient";
 
     private volatile NotificationHandler mHandler;
-    private boolean mReady;
+    private boolean mReady = false;
     private final LinkedList<Runnable> mTaskQueue = new LinkedList<Runnable>();
     private final ConcurrentHashMap<Integer, UpdateRunnable> mUpdatesMap =
             new ConcurrentHashMap<Integer, UpdateRunnable>();
 
     /**
      * Runnable that is reused between update notifications.
      *
      * Updates happen frequently, so reusing Runnables prevents frequent dynamic allocation.
@@ -137,27 +137,30 @@ public abstract class NotificationClient
     }
 
     /**
      * Removes a notification.
      *
      * @see NotificationHandler#remove(int)
      */
     public synchronized void remove(final int notificationID) {
-        if (!mReady) {
-            return;
-        }
-
         mTaskQueue.add(new Runnable() {
             @Override
             public void run() {
                 mHandler.remove(notificationID);
                 mUpdatesMap.remove(notificationID);
             }
         });
+
+        // If mReady == false, we haven't added any notifications yet. That can happen if Fennec is being
+        // started in response to clicking a notification. Call bind() to ensure the task we posted above is run.
+        if (!mReady) {
+            bind();
+        }
+
         notify();
     }
 
     /**
      * Determines whether a notification is showing progress.
      *
      * @see NotificationHandler#isProgressStyle(int)
      */
--- a/mobile/android/base/NotificationHelper.java
+++ b/mobile/android/base/NotificationHelper.java
@@ -19,24 +19,24 @@ import android.content.IntentFilter;
 import android.content.Context;
 import android.content.Intent;
 import android.graphics.Bitmap;
 import android.net.Uri;
 import android.support.v4.app.NotificationCompat;
 import android.util.Log;
 
 import java.util.Iterator;
-import java.util.Set;
-import java.util.HashSet;
+import java.util.HashMap;
 
 public final class NotificationHelper implements GeckoEventListener {
+    public static final String HELPER_BROADCAST_ACTION = AppConstants.ANDROID_PACKAGE_NAME + ".helperBroadcastAction";
+
     public static final String NOTIFICATION_ID = "NotificationHelper_ID";
-    private static final String LOGTAG = "GeckoNotificationManager";
+    private static final String LOGTAG = "GeckoNotificationHelper";
     private static final String HELPER_NOTIFICATION = "helperNotif";
-    private static final String HELPER_BROADCAST_ACTION = AppConstants.ANDROID_PACKAGE_NAME + ".helperBroadcastAction";
 
     // Attributes mandatory to be used while sending a notification from js.
     private static final String TITLE_ATTR = "title";
     private static final String TEXT_ATTR = "text";
     private static final String ID_ATTR = "id";
     private static final String SMALLICON_ATTR = "smallIcon";
 
     // Attributes that can be used while sending a notification from js.
@@ -49,145 +49,164 @@ public final class NotificationHelper im
     private static final String PRIORITY_ATTR = "priority";
     private static final String LARGE_ICON_ATTR = "largeIcon";
     private static final String EVENT_TYPE_ATTR = "eventType";
     private static final String ACTIONS_ATTR = "actions";
     private static final String ACTION_ID_ATTR = "buttonId";
     private static final String ACTION_TITLE_ATTR = "title";
     private static final String ACTION_ICON_ATTR = "icon";
     private static final String PERSISTENT_ATTR = "persistent";
+    private static final String HANDLER_ATTR = "handlerKey";
+    private static final String COOKIE_ATTR = "cookie";
 
     private static final String NOTIFICATION_SCHEME = "moz-notification";
 
     private static final String BUTTON_EVENT = "notification-button-clicked";
     private static final String CLICK_EVENT = "notification-clicked";
     private static final String CLEARED_EVENT = "notification-cleared";
     private static final String CLOSED_EVENT = "notification-closed";
 
-    private static Context mContext;
-    private static Set<String> mClearableNotifications;
-    private static BroadcastReceiver mReceiver;
-    private static NotificationHelper mInstance;
+    private Context mContext;
+
+    // Holds a list of notifications that should be cleared if the Fennec Activity is shut down.
+    // Will not include ongoing or persistent notifications that are tied to Gecko's lifecycle.
+    private HashMap<String, String> mClearableNotifications;
 
-    private NotificationHelper() {
+    private boolean mInitialized = false;
+    private static NotificationHelper sInstance;
+
+    private NotificationHelper(Context context) {
+        mContext = context;
     }
 
-    public static void init(Context context) {
-        if (mInstance != null) {
-            Log.w(LOGTAG, "NotificationHelper.init() called twice!");
-            return;
-        }
-        mInstance = new NotificationHelper();
-        mContext = context;
-        mClearableNotifications = new HashSet<String>();
-        EventDispatcher.getInstance().registerGeckoThreadListener(mInstance,
+    public void init() {
+        mClearableNotifications = new HashMap<String, String>();
+        EventDispatcher.getInstance().registerGeckoThreadListener(this,
             "Notification:Show",
             "Notification:Hide");
-        registerReceiver(context);
+        mInitialized = true;
+    }
+
+    public static NotificationHelper getInstance(Context context) {
+        // If someone else created this singleton, but didn't initialize it, something has gone wrong.
+        if (sInstance != null && !sInstance.mInitialized) {
+            throw new IllegalStateException("NotificationHelper was created by someone else but not initialized");
+        }
+
+        if (sInstance == null) {
+            sInstance = new NotificationHelper(context.getApplicationContext());
+        }
+        return sInstance;
     }
 
     @Override
     public void handleMessage(String event, JSONObject message) {
         if (event.equals("Notification:Show")) {
             showNotification(message);
         } else if (event.equals("Notification:Hide")) {
             hideNotification(message);
         }
     }
 
     public boolean isHelperIntent(Intent i) {
         return i.getBooleanExtra(HELPER_NOTIFICATION, false);
     }
 
-    private static void registerReceiver(Context context) {
-        IntentFilter filter = new IntentFilter(HELPER_BROADCAST_ACTION);
-        // Scheme is needed, otherwise only broadcast with no data will be catched.
-        filter.addDataScheme(NOTIFICATION_SCHEME);
-        mReceiver = new BroadcastReceiver() {
-            @Override
-            public void onReceive(Context context, Intent intent) {
-                mInstance.handleNotificationIntent(intent);
-            }
-        };
-        context.registerReceiver(mReceiver, filter);
-    }
-
-
-    private void handleNotificationIntent(Intent i) {
+    public void handleNotificationIntent(Intent i) {
         final Uri data = i.getData();
         if (data == null) {
-            Log.w(LOGTAG, "handleNotificationEvent: empty data");
+            Log.e(LOGTAG, "handleNotificationEvent: empty data");
             return;
         }
         final String id = data.getQueryParameter(ID_ATTR);
         final String notificationType = data.getQueryParameter(EVENT_TYPE_ATTR);
         if (id == null || notificationType == null) {
-            Log.w(LOGTAG, "handleNotificationEvent: invalid intent parameters");
+            Log.e(LOGTAG, "handleNotificationEvent: invalid intent parameters");
             return;
         }
 
-        // In case the user swiped out the notification, we empty the id
-        // set.
+        // In case the user swiped out the notification, we empty the id set.
         if (CLEARED_EVENT.equals(notificationType)) {
             mClearableNotifications.remove(id);
+            // If Gecko isn't running, we throw away events where the notification was cancelled.
+            // i.e. Don't bug the user if they're just closing a bunch of notifications.
+            if (!GeckoThread.checkLaunchState(GeckoThread.LaunchState.GeckoRunning)) {
+                return;
+            }
         }
 
-        if (GeckoThread.checkLaunchState(GeckoThread.LaunchState.GeckoRunning)) {
-            JSONObject args = new JSONObject();
-            try {
-                args.put(ID_ATTR, id);
-                args.put(EVENT_TYPE_ATTR, notificationType);
+        JSONObject args = new JSONObject();
+
+        // The handler and cookie parameters are optional
+        final String handler = data.getQueryParameter(HANDLER_ATTR);
+        final String cookie = i.getStringExtra(COOKIE_ATTR);
+
+        try {
+            args.put(ID_ATTR, id);
+            args.put(EVENT_TYPE_ATTR, notificationType);
+            args.put(HANDLER_ATTR, handler);
+            args.put(COOKIE_ATTR, cookie);
 
-                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.put(ACTION_ID_ATTR, actionName);
+            }
 
-                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Notification:Event", args.toString()));
-            } catch (JSONException e) {
-                Log.w(LOGTAG, "Error building JSON notification arguments.", e);
-            }
+            Log.i(LOGTAG, "Send " + args.toString());
+            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Notification:Event", args.toString()));
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "Error building JSON notification arguments.", e);
         }
+
         // If the notification was clicked, we are closing it. This must be executed after
         // sending the event to js side because when the notification is canceled no event can be
         // handled.
         if (CLICK_EVENT.equals(notificationType) && !i.getBooleanExtra(ONGOING_ATTR, false)) {
-            hideNotification(id);
+            hideNotification(id, handler, cookie);
         }
 
     }
 
     private Uri.Builder getNotificationBuilder(JSONObject message, String type) {
         Uri.Builder b = new Uri.Builder();
         b.scheme(NOTIFICATION_SCHEME).appendQueryParameter(EVENT_TYPE_ATTR, type);
 
         try {
             final String id = message.getString(ID_ATTR);
             b.appendQueryParameter(ID_ATTR, id);
         } catch (JSONException ex) {
             Log.i(LOGTAG, "buildNotificationPendingIntent, error parsing", ex);
         }
+
+        try {
+            final String id = message.getString(HANDLER_ATTR);
+            b.appendQueryParameter(HANDLER_ATTR, id);
+        } catch (JSONException ex) {
+            Log.i(LOGTAG, "Notification doesn't have a handler");
+        }
+
         return b;
     }
 
     private Intent buildNotificationIntent(JSONObject message, Uri.Builder builder) {
         Intent notificationIntent = new Intent(HELPER_BROADCAST_ACTION);
         final boolean ongoing = message.optBoolean(ONGOING_ATTR);
         notificationIntent.putExtra(ONGOING_ATTR, ongoing);
 
         final Uri dataUri = builder.build();
         notificationIntent.setData(dataUri);
         notificationIntent.putExtra(HELPER_NOTIFICATION, true);
+        notificationIntent.putExtra(COOKIE_ATTR, message.optString(COOKIE_ATTR));
         return notificationIntent;
     }
 
     private PendingIntent buildNotificationPendingIntent(JSONObject message, String type) {
         Uri.Builder builder = getNotificationBuilder(message, type);
         final Intent notificationIntent = buildNotificationIntent(message, builder);
-        PendingIntent pi = PendingIntent.getBroadcast(mContext, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+        PendingIntent pi = PendingIntent.getActivity(mContext, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
         return pi;
     }
 
     private PendingIntent buildButtonClickPendingIntent(JSONObject message, JSONObject action) {
         Uri.Builder builder = getNotificationBuilder(message, BUTTON_EVENT);
         try {
             // Action name must be in query uri, otherwise buttons pending intents
             // would be collapsed.
@@ -195,17 +214,17 @@ public final class NotificationHelper im
                 builder.appendQueryParameter(ACTION_ID_ATTR, action.getString(ACTION_ID_ATTR));
             } else {
                 Log.i(LOGTAG, "button event with no name");
             }
         } catch (JSONException ex) {
             Log.i(LOGTAG, "buildNotificationPendingIntent, error parsing", ex);
         }
         final Intent notificationIntent = buildNotificationIntent(message, builder);
-        PendingIntent res = PendingIntent.getBroadcast(mContext, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+        PendingIntent res = PendingIntent.getActivity(mContext, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
         return res;
     }
 
     private void showNotification(JSONObject message) {
         NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext);
 
         // These attributes are required
         final String id;
@@ -285,63 +304,76 @@ public final class NotificationHelper im
         PendingIntent deletePendingIntent = buildNotificationPendingIntent(message, CLEARED_EVENT);
         builder.setDeleteIntent(deletePendingIntent);
 
         GeckoAppShell.notificationClient.add(id.hashCode(), builder.build());
 
         boolean persistent = message.optBoolean(PERSISTENT_ATTR);
         // We add only not persistent notifications to the list since we want to purge only
         // them when geckoapp is destroyed.
-        if (!persistent && !mClearableNotifications.contains(id)) {
-            mClearableNotifications.add(id);
+        if (!persistent && !mClearableNotifications.containsKey(id)) {
+            mClearableNotifications.put(id, message.toString());
         }
     }
 
     private void hideNotification(JSONObject message) {
-        String id;
+        final String id;
+        final String handler;
+        final String cookie;
         try {
             id = message.getString("id");
+            handler = message.optString("handlerKey");
+            cookie  = message.optString("cookie");
         } catch (JSONException ex) {
             Log.i(LOGTAG, "Error parsing", ex);
             return;
         }
 
-        hideNotification(id);
+        hideNotification(id, handler, cookie);
     }
 
-    private void sendNotificationWasClosed(String id) {
-        if (!GeckoThread.checkLaunchState(GeckoThread.LaunchState.GeckoRunning)) {
-            return;
-        }
-        JSONObject args = new JSONObject();
+    private void sendNotificationWasClosed(String id, String handlerKey, String cookie) {
+        final JSONObject args = new JSONObject();
         try {
             args.put(ID_ATTR, id);
+            args.put(HANDLER_ATTR, handlerKey);
+            args.put(COOKIE_ATTR, cookie);
             args.put(EVENT_TYPE_ATTR, CLOSED_EVENT);
+            Log.i(LOGTAG, "Send " + args.toString());
             GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Notification:Event", args.toString()));
         } catch (JSONException ex) {
-            Log.w(LOGTAG, "sendNotificationWasClosed: error building JSON notification arguments.", ex);
+            Log.e(LOGTAG, "sendNotificationWasClosed: error building JSON notification arguments.", ex);
         }
     }
 
-    private void closeNotification(String id) {
+    private void closeNotification(String id, String handlerKey, String cookie) {
         GeckoAppShell.notificationClient.remove(id.hashCode());
-        sendNotificationWasClosed(id);
+        sendNotificationWasClosed(id, handlerKey, cookie);
     }
 
-    public void hideNotification(String id) {
+    public void hideNotification(String id, String handlerKey, String cookie) {
         mClearableNotifications.remove(id);
-        closeNotification(id);
+        closeNotification(id, handlerKey, cookie);
     }
 
     private void clearAll() {
-        for (Iterator<String> i = mClearableNotifications.iterator(); i.hasNext();) {
+        for (Iterator<String> i = mClearableNotifications.keySet().iterator(); i.hasNext();) {
             final String id = i.next();
+            final String json = mClearableNotifications.get(id);
             i.remove();
-            closeNotification(id);
+
+            JSONObject obj;
+            try {
+                obj = new JSONObject(json);
+            } catch(JSONException ex) {
+                obj = new JSONObject();
+            }
+
+            closeNotification(id, obj.optString(HANDLER_ATTR), obj.optString(COOKIE_ATTR));
         }
     }
 
     public static void destroy() {
-        if (mInstance != null) {
-            mInstance.clearAll();
+        if (sInstance != null) {
+            sInstance.clearAll();
         }
     }
 }
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -112,16 +112,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 #endif
   ["MemoryObserver", ["memory-pressure", "Memory:Dump"], "chrome://browser/content/MemoryObserver.js"],
   ["ConsoleAPI", ["console-api-log-event"], "chrome://browser/content/ConsoleAPI.js"],
   ["FindHelper", ["FindInPage:Find", "FindInPage:Prev", "FindInPage:Next", "FindInPage:Closed", "Tab:Selected"], "chrome://browser/content/FindHelper.js"],
   ["PermissionsHelper", ["Permissions:Get", "Permissions:Clear"], "chrome://browser/content/PermissionsHelper.js"],
   ["FeedHandler", ["Feeds:Subscribe"], "chrome://browser/content/FeedHandler.js"],
   ["Feedback", ["Feedback:Show"], "chrome://browser/content/Feedback.js"],
   ["SelectionHandler", ["TextSelection:Get"], "chrome://browser/content/SelectionHandler.js"],
+  ["Notifications", ["Notification:Event"], "chrome://browser/content/Notifications.jsm"],
 ].forEach(function (aScript) {
   let [name, notifications, script] = aScript;
   XPCOMUtils.defineLazyGetter(window, name, function() {
     let sandbox = {};
     Services.scriptloader.loadSubScript(script, sandbox);
     return sandbox[name];
   });
   let observer = (s, t, d) => {
--- a/mobile/android/chrome/content/downloads.js
+++ b/mobile/android/chrome/content/downloads.js
@@ -21,16 +21,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 var Downloads = {
   _initialized: false,
   _dlmgr: null,
   _progressAlert: null,
   _privateDownloads: [],
   _showingPrompt: false,
   _downloadsIdMap: {},
+  _notificationKey: "downloads",
 
   _getLocalFile: function dl__getLocalFile(aFileURI) {
     // if this is a URL, get the file from that
     // XXX it's possible that using a null char-set here is bad
     const fileUrl = Services.io.newURI(aFileURI, null, null).QueryInterface(Ci.nsIFileURL);
     return fileUrl.file.clone().QueryInterface(Ci.nsILocalFile);
   },
 
@@ -39,16 +40,40 @@ var Downloads = {
       return;
     this._initialized = true;
 
     // Monitor downloads and display alerts
     this._dlmgr = Cc["@mozilla.org/download-manager;1"].getService(Ci.nsIDownloadManager);
     this._progressAlert = new AlertDownloadProgressListener();
     this._dlmgr.addPrivacyAwareListener(this._progressAlert);
     Services.obs.addObserver(this, "last-pb-context-exited", true);
+
+    // All click, cancel, and button presses will be handled by this handler as part of the Notifications callback API.
+    Notifications.registerHandler(this._notificationKey, this);
+  },
+
+  onClick: function(aCookie) {
+    Services.console.logStringMessage("Onclick " + aCookie);
+    Downloads.clickCallback(aCookie);
+  },
+
+  onCancel: function(aCookie) {
+    Services.console.logStringMessage("onCancel " + aCookie);
+    Downloads.notificationCanceledCallback(aCookie);
+  },
+
+  onButtonClick: function(aButtonId, aCookie) {
+    Services.console.logStringMessage("onButtonClick " + aCookie);
+    if (aButtonId === PAUSE_BUTTON.buttonId) {
+      Downloads.pauseClickCallback(aCookie);
+    } else if (aButtonId === RESUME_BUTTON.buttonId) {
+      Downloads.resumeClickCallback(aCookie);
+    } else if (aButtonId === CANCEL_BUTTON.buttonId) {
+      Downloads.cancelClickCallback(aCookie);
+    }
   },
 
   openDownload: function dl_openDownload(aDownload) {
     let fileUri = aDownload.target.spec;
     let guid = aDownload.guid;
     let f = this._getLocalFile(fileUri);
     try {
       f.launch();
@@ -115,20 +140,18 @@ var Downloads = {
 
   cancelClickCallback: function dl_buttonPauseCallback(aDownloadId) {
     this._dlmgr.getDownloadByGUID(aDownloadId, (function(status, download) {
           if (Components.isSuccessCode(status))
             this.cancelDownload(download);
         }).bind(this));
   },
 
-  notificationCanceledCallback: function dl_notifCancelCallback(aId, aDownloadId) {
-    let notificationId = this._downloadsIdMap[aDownloadId];
-    if (notificationId && notificationId == aId)
-      delete this._downloadsIdMap[aDownloadId];
+  notificationCanceledCallback: function dl_notifCancelCallback(aDownloadId) {
+    delete this._downloadsIdMap[aDownloadId];
   },
 
   createNotification: function dl_createNotif(aDownload, aOptions) {
     let notificationId = Notifications.create(aOptions);
     this._downloadsIdMap[aDownload.guid] = notificationId;
   },
 
   updateNotification: function dl_updateNotif(aDownload, aOptions) {
@@ -164,52 +187,38 @@ var Downloads = {
     return this;
   }
 };
 
 const PAUSE_BUTTON = {
   buttonId: "pause",
   title : Strings.browser.GetStringFromName("alertDownloadsPause"),
   icon : URI_PAUSE_ICON,
-  onClicked: function (aId, aCookie) {
-    Downloads.pauseClickCallback(aCookie);
-  }
 };
 
 const CANCEL_BUTTON = {
   buttonId: "cancel",
   title : Strings.browser.GetStringFromName("alertDownloadsCancel"),
   icon : URI_CANCEL_ICON,
-  onClicked: function (aId, aCookie) {
-    Downloads.cancelClickCallback(aCookie);
-  }
 };
 
 const RESUME_BUTTON = {
   buttonId: "resume",
   title : Strings.browser.GetStringFromName("alertDownloadsResume"),
   icon: URI_RESUME_ICON,
-  onClicked: function (aId, aCookie) {
-    Downloads.resumeClickCallback(aCookie);
-  }
 };
 
 function DownloadNotifOptions (aDownload, aTitle, aMessage) {
   this.icon = URI_GENERIC_ICON_DOWNLOAD;
-  this.onCancel = function (aId, aCookie) {
-    Downloads.notificationCanceledCallback(aId, aCookie);
-  }
-  this.onClick = function (aId, aCookie) {
-    Downloads.clickCallback(aCookie);
-  }
   this.title = aTitle;
   this.message = aMessage;
   this.buttons = null;
   this.cookie = aDownload.guid;
   this.persistent = true;
+  this.handlerKey = Downloads._notificationKey;
 }
 
 function DownloadProgressNotifOptions (aDownload, aButtons) {
   DownloadNotifOptions.apply(this, [aDownload, aDownload.displayName, aDownload.percentComplete + "%"]);
   this.ongoing = true;
   this.progress = aDownload.percentComplete;
   this.buttons = aButtons;
 }
--- a/mobile/android/modules/Notifications.jsm
+++ b/mobile/android/modules/Notifications.jsm
@@ -9,17 +9,18 @@ let Ci = Components.interfaces;
 Components.utils.import("resource://gre/modules/Services.jsm");
 
 this.EXPORTED_SYMBOLS = ["Notifications"];
 
 function log(msg) {
   // Services.console.logStringMessage(msg);
 }
 
-var _notificationsMap = {};
+let _notificationsMap = {};
+let _handlersMap = {};
 
 function Notification(aId, aOptions) {
   this._id = aId;
   this._when = (new Date).getTime();
   this.fillWithOptions(aOptions);
 }
 
 Notification.prototype = {
@@ -75,46 +76,52 @@ Notification.prototype = {
     else
       this._onClick = null;
 
     if ("cookie" in aOptions && aOptions.cookie != null)
       this._cookie = aOptions.cookie;
     else
       this._cookie = null;
 
+    if ("handlerKey" in aOptions && aOptions.handlerKey != null)
+      this._handlerKey = aOptions.handlerKey;
+
     if ("persistent" in aOptions && aOptions.persistent != null)
       this._persistent = aOptions.persistent;
     else
       this._persistent = false;
   },
 
   show: function() {
     let msg = {
         type: "Notification:Show",
         id: this._id,
         title: this._title,
         smallIcon: this._icon,
         ongoing: this._ongoing,
         when: this._when,
-        persistent: this._persistent
+        persistent: this._persistent,
     };
 
     if (this._message)
       msg.text = this._message;
 
     if (this._progress) {
       msg.progress_value = this._progress;
       msg.progress_max = 100;
       msg.progress_indeterminate = false;
     } else if (Number.isNaN(this._progress)) {
       msg.progress_value = 0;
       msg.progress_max = 0;
       msg.progress_indeterminate = true;
     }
 
+    if (this._cookie)
+      msg.cookie = JSON.stringify(this._cookie);
+
     if (this._priority)
       msg.priority = this._priority;
 
     if (this._buttons) {
       msg.actions = [];
       let buttonName;
       for (buttonName in this._buttons) {
         let button = this._buttons[buttonName];
@@ -125,48 +132,61 @@ Notification.prototype = {
         };
         msg.actions.push(obj);
       }
     }
 
     if (this._light)
       msg.light = this._light;
 
+    if (this._handlerKey)
+      msg.handlerKey = this._handlerKey;
+
     Services.androidBridge.handleGeckoMessage(msg);
     return this;
   },
 
   cancel: function() {
     let msg = {
-        type: "Notification:Hide",
-        id: this._id
+      type: "Notification:Hide",
+      id: this._id,
+      handlerKey: this._handlerKey,
+      cookie: JSON.stringify(this._cookie),
     };
     Services.androidBridge.handleGeckoMessage(msg);
   }
 }
 
-var Notifications = {
-  _initObserver: function() {
-    if (!this._observerAdded) {
-      Services.obs.addObserver(this, "Notification:Event", true);
-      this._observerAdded = true;
-    }
-  },
-
+let Notifications = {
   get idService() {
     delete this.idService;
     return this.idService = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
   },
 
+  registerHandler: function(key, handler) {
+    if (!_handlersMap[key]) {
+      _handlersMap[key] = [];
+    }
+    _handlersMap[key].push(handler);
+  },
+
+  unregisterHandler: function(key, handler) {
+    let i = _handlersMap[key].indexOf(handler);
+    if (i > -1) {
+      _handlersMap.splice(i, 1);
+    }
+  },
+
   create: function notif_notify(aOptions) {
-    this._initObserver();
     let id = this.idService.generateUUID().toString();
+
     let notification = new Notification(id, aOptions);
     _notificationsMap[id] = notification;
     notification.show();
+
     return id;
   },
 
   update: function notif_update(aId, aOptions) {
     let notification = _notificationsMap[aId];
     if (!notification)
       throw "Unknown notification id";
     notification.fillWithOptions(aOptions);
@@ -175,43 +195,61 @@ 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);
     let id = data.id;
+    let handlerKey = data.handlerKey;
+    let cookie = data.cookie ? JSON.parse(data.cookie) : undefined;
     let notification = _notificationsMap[id];
-    if (!notification) {
-      Services.console.logStringMessage("Notifications.jsm observe: received unknown event id " + id);
-      return;
-    }
 
     switch (data.eventType) {
       case "notification-clicked":
-        if (notification._onClick)
+        if (notification && notification._onClick)
           notification._onClick(id, notification._cookie);
+
+        if (handlerKey) {
+          _handlersMap[handlerKey].forEach(function(handler) {
+            handler.onClick(cookie);
+          });
+        }
+
         break;
-      case "notification-button-clicked": {
-        if (!notification._buttons) {
-          Services.console.logStringMessage("Notifications.jsm: received button clicked event but no buttons are available");
+      case "notification-button-clicked":
+        if (handlerKey) {
+          _handlersMap[handlerKey].forEach(function(handler) {
+            handler.onButtonClick(data.buttonId, cookie);
+          });
+        }
+
+        if (notification && !notification._buttons) {
           break;
         }
 
         let button = notification._buttons[data.buttonId];
-        if (button)
+        if (button) {
           button.onClicked(id, notification._cookie);
         }
         break;
       case "notification-cleared":
       case "notification-closed":
-        if (notification._onCancel)
+        if (handlerKey) {
+          _handlersMap[handlerKey].forEach(function(handler) {
+            handler.onCancel(cookie);
+          });
+        }
+
+        if (notification && notification._onCancel)
           notification._onCancel(id, notification._cookie);
         delete _notificationsMap[id]; // since the notification was dismissed, we no longer need to hold a reference.
         break;
     }
   },
 
   QueryInterface: function (aIID) {
     if (!aIID.equals(Ci.nsISupports) &&