Bug 1414084 - Part 9 - Move add-on menu item cache out of BrowserApp. r?grisha draft
authorJan Henning <jh+bugzilla@buttercookie.de>
Sun, 25 Feb 2018 22:22:37 +0100
changeset 823884 f87beb9188f46e8336daf7d9b607c3bcf0e8c766
parent 823883 747887186a05d2d3ef3feedf957a104328d2f829
child 823885 060b82ea32b1323dbf029cbb2d943f38f0bf5d23
push id117809
push usermozilla@buttercookie.de
push dateSun, 29 Jul 2018 18:19:15 +0000
reviewersgrisha
bugs1414084, 832990
milestone63.0a1
Bug 1414084 - Part 9 - Move add-on menu item cache out of BrowserApp. r?grisha Bug 832990 solved the issue of us losing the menu item cache if BrowserApp was destroyed, however the issue remains that we'll miss any Menu:... messages that are sent while BrowserApp doesn't exist, e.g. if Gecko is initially loaded through a GeckoView-based activity. Therefore we now move the menu item cache and the listener for those messages into a separate class, whose lifetime better matches that of Gecko. Apart from any necessary changes, we move the existing code as is. The only additional change is that we make addAddonMenuItemToMenu() static, because we can. MozReview-Commit-ID: BJleonLnjmo
mobile/android/base/java/org/mozilla/gecko/AddonUICache.java
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
mobile/android/chrome/content/browser.js
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/AddonUICache.java
@@ -0,0 +1,296 @@
+/* -*- 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 android.text.TextUtils;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.SubMenu;
+
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * For certain UI items added by add-ons or other JS/Gecko code, Gecko notifies us whenever an item
+ * is added, changed or removed. Since we must not miss any of these notifications and need to re-
+ * member the current list of active UI items even if e.g. we're in background and our activities
+ * have been destroyed, we need a class whose lifetime matches (or even exceeds) that of Gecko.
+ *
+ * This class fulfills this purpose - it will be initialised early during app startup and just like
+ * Gecko, once initialised it will remain alive until the OS kills our app.
+ *
+ * After initialisation, we will start listening for the appropriate EventDispatcher messages from
+ * Gecko and maintain an internal list of UI items dynamically added by Gecko.
+ * In addition, for each class of UI elements an appropriate API will be provided through which the
+ * intended final consumer can make use of that list in order to actually show those elements in the
+ * UI.
+ */
+public class AddonUICache implements BundleEventListener {
+    private static final String LOGTAG = "GeckoAddonUICache";
+
+    private static final int GECKO_TOOLS_MENU_ID = -1;
+    // When changing this, make sure to adjust NativeWindow.toolsMenuID in browser.js, too.
+    private static final String GECKO_TOOLS_MENU_UUID = "{115b9308-2023-44f1-a4e9-3e2197669f07}";
+    private static final int ADDON_MENU_OFFSET = 1000;
+
+    private static class MenuItemInfo {
+        public int id;
+        public String uuid;
+        public String label;
+        public boolean checkable;
+        public boolean checked;
+        public boolean enabled = true;
+        public boolean visible = true;
+        public int parent;
+    }
+
+    private static final AddonUICache instance = new AddonUICache();
+
+    private List<MenuItemInfo> mAddonMenuItemsCache;
+    private int mAddonMenuNextID = ADDON_MENU_OFFSET;
+    private Menu mMenu;
+
+    private boolean mInitialized;
+
+    public static AddonUICache getInstance() {
+        return instance;
+    }
+
+    private AddonUICache() { }
+
+    public void init() {
+        if (mInitialized) {
+            return;
+        }
+
+        EventDispatcher.getInstance().registerUiThreadListener(this,
+            "Menu:Add",
+            "Menu:Update",
+            "Menu:Remove",
+            null);
+
+        mInitialized = true;
+    }
+
+    @Override
+    public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
+        switch (event) {
+            case "Menu:Add":
+                final MenuItemInfo info = new MenuItemInfo();
+                info.label = message.getString("name");
+                if (TextUtils.isEmpty(info.label)) {
+                    Log.e(LOGTAG, "Invalid menu item name");
+                    return;
+                }
+                info.id = mAddonMenuNextID++;
+                info.uuid = message.getString("uuid");
+                info.checked = message.getBoolean("checked", false);
+                info.enabled = message.getBoolean("enabled", true);
+                info.visible = message.getBoolean("visible", true);
+                info.checkable = message.getBoolean("checkable", false);
+                final String parentUUID = message.getString("parent");
+                if (GECKO_TOOLS_MENU_UUID.equals(parentUUID)) {
+                    info.parent = GECKO_TOOLS_MENU_ID;
+                } else if (!TextUtils.isEmpty(parentUUID)) {
+                    for (MenuItemInfo item : mAddonMenuItemsCache) {
+                        if (item.uuid.equals(parentUUID)) {
+                            info.parent = item.id;
+                            break;
+                        }
+                    }
+                }
+                addAddonMenuItem(info);
+                break;
+
+            case "Menu:Remove":
+                removeAddonMenuItem(message.getString("uuid"));
+                break;
+
+            case "Menu:Update":
+                updateAddonMenuItem(message.getString("uuid"),
+                                    message.getBundle("options"));
+                break;
+        }
+    }
+
+    /**
+     * Starts handling add-on menu items for the given {@link Menu} and also adds any
+     * menu items that have already been cached.
+     */
+    public void onCreateOptionsMenu(Menu menu) {
+        mMenu = menu;
+
+        // Add add-on menu items, if any exist.
+        if (mMenu != null && mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
+            for (MenuItemInfo item : mAddonMenuItemsCache) {
+                addAddonMenuItemToMenu(mMenu, item);
+            }
+        }
+    }
+
+    /**
+     * Clears the reference to the Menu passed in {@link AddonUICache#onCreateOptionsMenu}.
+     * <p>
+     * Note: Any {@link MenuItem MenuItem(s)} previously added by this class are <i>not</i> removed.
+     */
+    public void onDestroyOptionsMenu() {
+        mMenu = null;
+    }
+
+    /**
+     * Adds an addon menu item/webextension browser action to the menu.
+     */
+    private void addAddonMenuItem(final MenuItemInfo info) {
+        if (mAddonMenuItemsCache == null) {
+            mAddonMenuItemsCache = new ArrayList<>();
+        }
+
+        mAddonMenuItemsCache.add(info);
+
+        if (mMenu == null) {
+            return;
+        }
+
+        addAddonMenuItemToMenu(mMenu, info);
+    }
+
+    /**
+     * Removes an addon menu item/webextension browser action from the menu by its UUID.
+     */
+    private void removeAddonMenuItem(String uuid) {
+        int id = -1;
+
+        // Remove add-on menu item from cache, if available.
+        if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
+            for (MenuItemInfo item : mAddonMenuItemsCache) {
+                if (item.uuid.equals(uuid)) {
+                    id = item.id;
+                    mAddonMenuItemsCache.remove(item);
+                    break;
+                }
+            }
+        }
+
+        if (mMenu == null || id == -1) {
+            return;
+        }
+
+        final MenuItem menuItem = mMenu.findItem(id);
+        if (menuItem != null) {
+            mMenu.removeItem(id);
+        }
+    }
+
+    /**
+     * Updates the addon menu/webextension browser action with the specified UUID.
+     */
+    private void updateAddonMenuItem(String uuid, final GeckoBundle options) {
+        int id = -1;
+
+        // Set attribute for the menu item in cache, if available
+        if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
+            for (MenuItemInfo item : mAddonMenuItemsCache) {
+                if (item.uuid.equals(uuid)) {
+                    id = item.id;
+                    item.label = options.getString("name", item.label);
+                    item.checkable = options.getBoolean("checkable", item.checkable);
+                    item.checked = options.getBoolean("checked", item.checked);
+                    item.enabled = options.getBoolean("enabled", item.enabled);
+                    item.visible = options.getBoolean("visible", item.visible);
+                    break;
+                }
+            }
+        }
+
+        if (mMenu == null || id == -1) {
+            return;
+        }
+
+        final MenuItem menuItem = mMenu.findItem(id);
+        if (menuItem != null) {
+            menuItem.setTitle(options.getString("name", menuItem.getTitle().toString()));
+            menuItem.setCheckable(options.getBoolean("checkable", menuItem.isCheckable()));
+            menuItem.setChecked(options.getBoolean("checked", menuItem.isChecked()));
+            menuItem.setEnabled(options.getBoolean("enabled", menuItem.isEnabled()));
+            menuItem.setVisible(options.getBoolean("visible", menuItem.isVisible()));
+        }
+    }
+
+    /**
+     * Add the provided item to the provided menu, which should be
+     * the root (mMenu).
+     */
+    private static void addAddonMenuItemToMenu(final Menu menu, final MenuItemInfo info) {
+        final Menu destination;
+        if (info.parent == 0) {
+            destination = menu;
+        } else if (info.parent == GECKO_TOOLS_MENU_ID) {
+            // The tools menu only exists in our -v11 resources.
+            final MenuItem tools = menu.findItem(R.id.tools);
+            destination = tools != null ? tools.getSubMenu() : menu;
+        } else {
+            final MenuItem parent = menu.findItem(info.parent);
+            if (parent == null) {
+                return;
+            }
+
+            Menu parentMenu = findParentMenu(menu, parent);
+
+            if (!parent.hasSubMenu()) {
+                parentMenu.removeItem(parent.getItemId());
+                destination = parentMenu.addSubMenu(Menu.NONE, parent.getItemId(), Menu.NONE, parent.getTitle());
+                if (parent.getIcon() != null) {
+                    ((SubMenu) destination).getItem().setIcon(parent.getIcon());
+                }
+            } else {
+                destination = parent.getSubMenu();
+            }
+        }
+
+        final MenuItem item = destination.add(Menu.NONE, info.id, Menu.NONE, info.label);
+
+        item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
+            @Override
+            public boolean onMenuItemClick(MenuItem item) {
+                final GeckoBundle data = new GeckoBundle(1);
+                data.putString("item", info.uuid);
+                EventDispatcher.getInstance().dispatch("Menu:Clicked", data);
+                return true;
+            }
+        });
+
+        item.setCheckable(info.checkable);
+        item.setChecked(info.checked);
+        item.setEnabled(info.enabled);
+        item.setVisible(info.visible);
+    }
+
+    private static Menu findParentMenu(Menu menu, MenuItem item) {
+        final int itemId = item.getItemId();
+
+        final int count = (menu != null) ? menu.size() : 0;
+        for (int i = 0; i < count; i++) {
+            MenuItem menuItem = menu.getItem(i);
+            if (menuItem.getItemId() == itemId) {
+                return menu;
+            }
+            if (menuItem.hasSubMenu()) {
+                Menu parent = findParentMenu(menuItem.getSubMenu(), item);
+                if (parent != null) {
+                    return parent;
+                }
+            }
+        }
+
+        return null;
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -29,18 +29,16 @@ import android.graphics.drawable.Drawabl
 import android.net.Uri;
 import android.nfc.NdefMessage;
 import android.nfc.NdefRecord;
 import android.nfc.NfcAdapter;
 import android.nfc.NfcEvent;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Environment;
-import android.os.Parcel;
-import android.os.Parcelable;
 import android.os.StrictMode;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.annotation.StringRes;
 import android.support.design.widget.Snackbar;
 import android.support.v4.app.Fragment;
 import android.support.v4.app.FragmentManager;
 import android.support.v4.content.res.ResourcesCompat;
@@ -51,17 +49,16 @@ import android.util.Log;
 import android.view.HapticFeedbackConstants;
 import android.view.InputDevice;
 import android.view.KeyEvent;
 import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.MenuItem;
 import android.view.MotionEvent;
-import android.view.SubMenu;
 import android.view.View;
 import android.view.ViewConfiguration;
 import android.view.ViewGroup;
 import android.view.ViewStub;
 import android.view.ViewTreeObserver;
 import android.view.Window;
 import android.view.animation.Interpolator;
 import android.widget.Button;
@@ -173,17 +170,16 @@ import org.mozilla.gecko.widget.GeckoAct
 import org.mozilla.gecko.widget.SplashScreen;
 import org.mozilla.geckoview.GeckoSession;
 
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.lang.reflect.Method;
 import java.net.URLEncoder;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.regex.Pattern;
 
@@ -210,18 +206,16 @@ public class BrowserApp extends GeckoApp
 
     // Intent String extras used to specify custom Switchboard configurations.
     private static final String INTENT_KEY_SWITCHBOARD_SERVER = "switchboard-server";
 
     // TODO: Replace with kinto endpoint.
     private static final String SWITCHBOARD_SERVER = "https://firefox.settings.services.mozilla.com/v1/buckets/fennec/collections/experiments/records";
 
     private static final String STATE_ABOUT_HOME_TOP_PADDING = "abouthome_top_padding";
-    private static final String STATE_ADDON_MENU_ITEM_CACHE = "menuitems_cache";
-    private static final String STATE_ADDON_MENU_NEXT_ID = "menuitems_nextId";
 
     private static final String BROWSER_SEARCH_TAG = "browser_search";
 
     // Request ID for startActivityForResult.
     public static final int ACTIVITY_REQUEST_PREFERENCES = 1001;
     private static final int ACTIVITY_REQUEST_TAB_QUEUE = 2001;
     public static final int ACTIVITY_REQUEST_FIRST_READERVIEW_BOOKMARK = 3001;
     public static final int ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_GOTO_BOOKMARKS = 3002;
@@ -255,94 +249,34 @@ public class BrowserApp extends GeckoApp
      * home screen implementation (currently that is just the HomePager, but that will be extended
      * to permit further experimental replacement panels such as the activity-stream panel).
      */
     private ViewGroup mHomeScreenContainer;
     private int mCachedRecentTabsCount;
     private ActionModeCompat mActionMode;
     private TabHistoryController tabHistoryController;
 
-    private static final int GECKO_TOOLS_MENU_ID = -1;
-    // When changing this, make sure to adjust NativeWindow.toolsMenuID in browser.js, too.
-    private static final String GECKO_TOOLS_MENU_UUID = "{115b9308-2023-44f1-a4e9-3e2197669f07}";
-    private static final int ADDON_MENU_OFFSET = 1000;
     public static final String TAB_HISTORY_FRAGMENT_TAG = "tabHistoryFragment";
 
     // When the static action bar is shown, only the real toolbar chrome should be
     // shown when the toolbar is visible. Causing the toolbar animator to also
     // show the snapshot causes the content to shift under the users finger.
     // See: Bug 1358554
     private boolean mShowingToolbarChromeForActionBar;
 
     private SafeIntent safeStartingIntent;
     private Intent startingIntentAfterPip;
     private boolean isInAutomation;
 
-    private static class MenuItemInfo implements Parcelable {
-        public int id;
-        public String uuid;
-        public String label;
-        public boolean checkable;
-        public boolean checked;
-        public boolean enabled = true;
-        public boolean visible = true;
-        public int parent;
-
-        @Override
-        public int describeContents() {
-            return 0;
-        }
-
-        @Override
-        public void writeToParcel(Parcel dest, int flags) {
-            dest.writeInt(id);
-            dest.writeString(uuid);
-            dest.writeString(label);
-            dest.writeInt(checkable ? 1 : 0);
-            dest.writeInt(checked ? 1 : 0);
-            dest.writeInt(enabled ? 1 : 0);
-            dest.writeInt(visible ? 1 : 0);
-            dest.writeInt(parent);
-        }
-
-        public static final Parcelable.Creator<MenuItemInfo> CREATOR
-                = new Parcelable.Creator<MenuItemInfo>() {
-            @Override
-            public MenuItemInfo createFromParcel(Parcel source) {
-                return new MenuItemInfo(source);
-            }
-
-            @Override
-            public MenuItemInfo[] newArray(int size) {
-                return new MenuItemInfo[size];
-            }
-        };
-
-        private MenuItemInfo(Parcel source) {
-            id = source.readInt();
-            uuid = source.readString();
-            label = source.readString();
-            checkable = source.readInt() != 0;
-            checked = source.readInt() != 0;
-            enabled = source.readInt() != 0;
-            visible = source.readInt() != 0;
-            parent = source.readInt();
-        }
-
-        public MenuItemInfo() { }
-    }
-
     // The types of guest mode dialogs we show.
     public static enum GuestModeDialog {
         ENTERING,
         LEAVING
     }
 
-    private ArrayList<MenuItemInfo> mAddonMenuItemsCache;
-    private int mAddonMenuNextID = ADDON_MENU_OFFSET;
     private PropertyAnimator mMainLayoutAnimator;
 
     private static final Interpolator sTabsInterpolator = new Interpolator() {
         @Override
         public float getInterpolation(float t) {
             t -= 1.0f;
             return t * t * t * t * t + 1.0f;
         }
@@ -739,24 +673,16 @@ public class BrowserApp extends GeckoApp
             }
 
             @Override
             public boolean onTouch(View v, MotionEvent event) {
                 return false;
             }
         });
 
-        // If the activity is being restored, the add-ons menu item cache only needs restoring if
-        // Gecko is already running. Otherwise, we'll simply catch the corresponding events when
-        // Gecko and the add-ons are starting up.
-        if (savedInstanceState != null && mIsRestoringActivity) {
-            mAddonMenuItemsCache = savedInstanceState.getParcelableArrayList(STATE_ADDON_MENU_ITEM_CACHE);
-            mAddonMenuNextID = savedInstanceState.getInt(STATE_ADDON_MENU_NEXT_ID);
-        }
-
         app.getLightweightTheme().addListener(this);
 
         mProgressView = (AnimatedProgressBar) findViewById(R.id.page_progress);
         mDynamicToolbar.setLayerView(mLayerView);
         mProgressView.setDynamicToolbar(mDynamicToolbar);
         mBrowserToolbar.setProgressBar(mProgressView);
 
         // Initialize Tab History Controller.
@@ -848,19 +774,16 @@ public class BrowserApp extends GeckoApp
 
         EventDispatcher.getInstance().registerGeckoThreadListener(this,
             "Search:Keyword",
             null);
 
         EventDispatcher.getInstance().registerUiThreadListener(this,
             "GeckoView:AccessibilityEnabled",
             "Menu:Open",
-            "Menu:Update",
-            "Menu:Add",
-            "Menu:Remove",
             "LightweightTheme:Update",
             "Tab:Added",
             "Video:Play",
             "CharEncoding:Data",
             "CharEncoding:State",
             "Settings:Show",
             "Updater:Launch",
             "Sanitize:Finished",
@@ -1542,16 +1465,18 @@ public class BrowserApp extends GeckoApp
             mProgressView.setDynamicToolbar(null);
         }
 
         mDynamicToolbar.destroy();
 
         final GeckoApplication app = (GeckoApplication) getApplication();
         app.getLightweightTheme().removeListener(this);
 
+        AddonUICache.getInstance().onDestroyOptionsMenu();
+
         if (mBrowserToolbar != null)
             mBrowserToolbar.onDestroy();
 
         if (mFindInPageBar != null) {
             mFindInPageBar.onDestroy();
             mFindInPageBar = null;
         }
 
@@ -1584,19 +1509,16 @@ public class BrowserApp extends GeckoApp
 
         EventDispatcher.getInstance().unregisterGeckoThreadListener(this,
             "Search:Keyword",
             null);
 
         EventDispatcher.getInstance().unregisterUiThreadListener(this,
             "GeckoView:AccessibilityEnabled",
             "Menu:Open",
-            "Menu:Update",
-            "Menu:Add",
-            "Menu:Remove",
             "LightweightTheme:Update",
             "Tab:Added",
             "Video:Play",
             "CharEncoding:Data",
             "CharEncoding:State",
             "Settings:Show",
             "Updater:Launch",
             "Sanitize:Finished",
@@ -1845,52 +1767,16 @@ public class BrowserApp extends GeckoApp
 
             case "Menu:Open":
                 if (mBrowserToolbar.isEditing()) {
                     mBrowserToolbar.cancelEdit();
                 }
                 openOptionsMenu();
                 break;
 
-            case "Menu:Add":
-                final MenuItemInfo info = new MenuItemInfo();
-                info.label = message.getString("name");
-                if (TextUtils.isEmpty(info.label)) {
-                    Log.e(LOGTAG, "Invalid menu item name");
-                    return;
-                }
-                info.id = mAddonMenuNextID++;
-                info.uuid = message.getString("uuid");
-                info.checked = message.getBoolean("checked", false);
-                info.enabled = message.getBoolean("enabled", true);
-                info.visible = message.getBoolean("visible", true);
-                info.checkable = message.getBoolean("checkable", false);
-                final String parentUUID = message.getString("parent");
-                if (GECKO_TOOLS_MENU_UUID.equals(parentUUID)) {
-                    info.parent = GECKO_TOOLS_MENU_ID;
-                } else if (!TextUtils.isEmpty(parentUUID)) {
-                    for (MenuItemInfo item : mAddonMenuItemsCache) {
-                        if (item.uuid.equals(parentUUID)) {
-                            info.parent = item.id;
-                            break;
-                        }
-                    }
-                }
-                addAddonMenuItem(info);
-                break;
-
-            case "Menu:Remove":
-                removeAddonMenuItem(message.getString("uuid"));
-                break;
-
-            case "Menu:Update":
-                updateAddonMenuItem(message.getString("uuid"),
-                                    message.getBundle("options"));
-                break;
-
             case "LightweightTheme:Update":
                 mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
                 break;
 
             case "Search:Keyword":
                 storeSearchQuery(message.getString("query"));
                 recordSearch(GeckoSharedPrefs.forProfile(this), message.getString("identifier"),
                         TelemetryContract.Method.ACTIONBAR);
@@ -2402,25 +2288,16 @@ public class BrowserApp extends GeckoApp
         mMainLayoutAnimator = null;
     }
 
     @Override
     public void onSaveInstanceState(Bundle outState) {
         super.onSaveInstanceState(outState);
         mDynamicToolbar.onSaveInstanceState(outState);
         outState.putInt(STATE_ABOUT_HOME_TOP_PADDING, mHomeScreenContainer.getPaddingTop());
-
-        // The various add-on UI item caches and event listeners should really live somewhere based
-        // on the Application, so that their lifetime more closely matches that of Gecko itself, as
-        // GeckoView-based activities can start Gecko (and therefore add-ons) while BrowserApp isn't
-        // even running.
-        // For now we'll only guard against the case where BrowserApp is destroyed and later re-
-        // created while Gecko keeps running throughout, and leave the full solution to bug 1414084.
-        outState.putParcelableArrayList(STATE_ADDON_MENU_ITEM_CACHE, mAddonMenuItemsCache);
-        outState.putInt(STATE_ADDON_MENU_NEXT_ID, mAddonMenuNextID);
     }
 
     /**
      * Attempts to switch to an open tab with the given URL.
      * <p>
      * If the tab exists, this method cancels any in-progress editing as well as
      * calling {@link Tabs#selectTab(int)}.
      *
@@ -3161,185 +3038,33 @@ public class BrowserApp extends GeckoApp
         }
 
         @Override
         public boolean onTouch(View view, MotionEvent event) {
             return false;
         }
     }
 
-    private static Menu findParentMenu(Menu menu, MenuItem item) {
-        final int itemId = item.getItemId();
-
-        final int count = (menu != null) ? menu.size() : 0;
-        for (int i = 0; i < count; i++) {
-            MenuItem menuItem = menu.getItem(i);
-            if (menuItem.getItemId() == itemId) {
-                return menu;
-            }
-            if (menuItem.hasSubMenu()) {
-                Menu parent = findParentMenu(menuItem.getSubMenu(), item);
-                if (parent != null) {
-                    return parent;
-                }
-            }
-        }
-
-        return null;
-    }
-
-    /**
-     * Add the provided item to the provided menu, which should be
-     * the root (mMenu).
-     */
-    private void addAddonMenuItemToMenu(final Menu menu, final MenuItemInfo info) {
-        final Menu destination;
-        if (info.parent == 0) {
-            destination = menu;
-        } else if (info.parent == GECKO_TOOLS_MENU_ID) {
-            // The tools menu only exists in our -v11 resources.
-            final MenuItem tools = menu.findItem(R.id.tools);
-            destination = tools != null ? tools.getSubMenu() : menu;
-        } else {
-            final MenuItem parent = menu.findItem(info.parent);
-            if (parent == null) {
-                return;
-            }
-
-            Menu parentMenu = findParentMenu(menu, parent);
-
-            if (!parent.hasSubMenu()) {
-                parentMenu.removeItem(parent.getItemId());
-                destination = parentMenu.addSubMenu(Menu.NONE, parent.getItemId(), Menu.NONE, parent.getTitle());
-                if (parent.getIcon() != null) {
-                    ((SubMenu) destination).getItem().setIcon(parent.getIcon());
-                }
-            } else {
-                destination = parent.getSubMenu();
-            }
-        }
-
-        final MenuItem item = destination.add(Menu.NONE, info.id, Menu.NONE, info.label);
-
-        item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
-            @Override
-            public boolean onMenuItemClick(MenuItem item) {
-                final GeckoBundle data = new GeckoBundle(1);
-                data.putString("item", info.uuid);
-                EventDispatcher.getInstance().dispatch("Menu:Clicked", data);
-                return true;
-            }
-        });
-
-        item.setCheckable(info.checkable);
-        item.setChecked(info.checked);
-        item.setEnabled(info.enabled);
-        item.setVisible(info.visible);
-    }
-
-    /**
-     * Adds an addon menu item/webextension browser action to the menu.
-     */
-    private void addAddonMenuItem(final MenuItemInfo info) {
-        if (mAddonMenuItemsCache == null) {
-            mAddonMenuItemsCache = new ArrayList<>();
-        }
-
-        // Always cache so we can rebuild after a locale switch.
-        mAddonMenuItemsCache.add(info);
-
-        if (mMenu == null) {
-            return;
-        }
-
-        addAddonMenuItemToMenu(mMenu, info);
-    }
-
-    /**
-     * Removes an addon menu item/webextension browser action from the menu by its UUID.
-     */
-    private void removeAddonMenuItem(String uuid) {
-        int id = -1;
-
-        // Remove add-on menu item from cache, if available.
-        if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
-            for (MenuItemInfo item : mAddonMenuItemsCache) {
-                if (item.uuid.equals(uuid)) {
-                    id = item.id;
-                    mAddonMenuItemsCache.remove(item);
-                    break;
-                }
-            }
-        }
-
-        if (mMenu == null || id == -1) {
-            return;
-        }
-
-        final MenuItem menuItem = mMenu.findItem(id);
-        if (menuItem != null) {
-            mMenu.removeItem(id);
-        }
-    }
-
-    /**
-     * Updates the addon menu/webextension browser action with the specified UUID.
-     */
-    private void updateAddonMenuItem(String uuid, final GeckoBundle options) {
-        int id = -1;
-
-        // Set attribute for the menu item in cache, if available
-        if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
-            for (MenuItemInfo item : mAddonMenuItemsCache) {
-                if (item.uuid.equals(uuid)) {
-                    id = item.id;
-                    item.label = options.getString("name", item.label);
-                    item.checkable = options.getBoolean("checkable", item.checkable);
-                    item.checked = options.getBoolean("checked", item.checked);
-                    item.enabled = options.getBoolean("enabled", item.enabled);
-                    item.visible = options.getBoolean("visible", item.visible);
-                    break;
-                }
-            }
-        }
-
-        if (mMenu == null || id == -1) {
-            return;
-        }
-
-        final MenuItem menuItem = mMenu.findItem(id);
-        if (menuItem != null) {
-            menuItem.setTitle(options.getString("name", menuItem.getTitle().toString()));
-            menuItem.setCheckable(options.getBoolean("checkable", menuItem.isCheckable()));
-            menuItem.setChecked(options.getBoolean("checked", menuItem.isChecked()));
-            menuItem.setEnabled(options.getBoolean("enabled", menuItem.isEnabled()));
-            menuItem.setVisible(options.getBoolean("visible", menuItem.isVisible()));
-        }
-    }
-
     @Override
     public boolean onCreateOptionsMenu(Menu menu) {
         // Sets mMenu = menu.
         super.onCreateOptionsMenu(menu);
 
         // Inform the menu about the action-items bar.
         if (menu instanceof GeckoMenu &&
             HardwareUtils.isTablet()) {
             ((GeckoMenu) menu).setActionItemBarPresenter(mBrowserToolbar);
         }
 
         MenuInflater inflater = getMenuInflater();
         inflater.inflate(R.menu.browser_app_menu, mMenu);
 
-        // Add add-on menu items, if any exist.
-        if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
-            for (MenuItemInfo item : mAddonMenuItemsCache) {
-                addAddonMenuItemToMenu(mMenu, item);
-            }
-        }
+        // Let the AddonUICache handle adding (and removing again) any add-on/browser action
+        // menu items as required.
+        AddonUICache.getInstance().onCreateOptionsMenu(mMenu);
 
         // Action providers are available only ICS+.
         GeckoMenuItem share = (GeckoMenuItem) mMenu.findItem(R.id.share);
 
         GeckoActionProvider provider = GeckoActionProvider.getForType(GeckoActionProvider.DEFAULT_MIME_TYPE, this);
 
         share.setActionProvider(provider);
 
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -329,16 +329,17 @@ public class GeckoApplication extends Ap
                 return IntentHelper.getHandlersForIntent(intent);
             }
         });
 
         HardwareUtils.init(context);
         FilePicker.init(context);
         DownloadsIntegration.init();
         HomePanelsManager.getInstance().init(context);
+        AddonUICache.getInstance().init();
 
         GlobalPageMetadata.getInstance().init();
 
         TelemetryBackgroundReceiver.getInstance().init(context);
 
         // We need to set the notification client before launching Gecko, since Gecko could start
         // sending notifications immediately after startup, which we don't want to lose/crash on.
         GeckoAppShell.setNotificationListener(new NotificationClient(context));
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -2320,17 +2320,17 @@ var NativeWindow = {
       "Doorhanger:Reply",
       "Menu:Clicked",
     ]);
     this.contextmenus.init();
   },
 
   menu: {
     _callbacks: [],
-    // This value must be kept in sync with GECKO_TOOLS_MENU_UUID in BrowserApp.java.
+    // This value must be kept in sync with GECKO_TOOLS_MENU_UUID in AddonUICache.java.
     toolsMenuID: "{115b9308-2023-44f1-a4e9-3e2197669f07}",
     add: function() {
       let options;
       if (arguments.length == 1) {
         options = arguments[0];
       } else if (arguments.length == 3) {
           Log.w("Browser", "This menu addon API has been deprecated. Instead, use the options object API.");
           options = {