Bug 1530402 - Implement {Browser,Page}Action for GeckoView. r=snorp,mixedpuppy,esawin
authorAgi Sferro <agi@sferro.dev>
Mon, 18 Nov 2019 16:49:02 +0000
changeset 502436 59fd566c80c71ef2057073ad6a8192947e4fe90b
parent 502435 787a3ae7f1b759a6ec39b089fb1368f71ada9e4a
child 502437 10b03e28fe0d4fe38ca057d4616d9a79e7ee3413
push id114172
push userdluca@mozilla.com
push dateTue, 19 Nov 2019 11:31:10 +0000
treeherdermozilla-inbound@b5c5ba07d3db [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssnorp, mixedpuppy, esawin
bugs1530402
milestone72.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 1530402 - Implement {Browser,Page}Action for GeckoView. r=snorp,mixedpuppy,esawin Design doc: https://docs.google.com/document/d/1XJuKk9Hm_2RNbX8KRcyUOXTaELBWYMyXBUchz15OElY Differential Revision: https://phabricator.services.mozilla.com/D49041
browser/components/extensions/ext-browser.json
browser/components/extensions/schemas/browser_action.json
browser/components/extensions/schemas/jar.mn
browser/components/extensions/schemas/page_action.json
mobile/android/components/extensions/ext-android.js
mobile/android/components/extensions/ext-browserAction.js
mobile/android/components/extensions/ext-pageAction.js
mobile/android/components/geckoview/GeckoViewStartup.js
mobile/android/geckoview/api.txt
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/background.js
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/expected.png
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-19.png
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-38.png
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/icon.svg
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/content.js
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/manifest.json
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.html
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.js
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.html
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.js
mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.html
mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/icon.svg
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionEventDispatcher.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
mobile/android/modules/geckoview/GeckoViewWebExtension.jsm
toolkit/components/extensions/schemas/browser_action.json
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/schemas/page_action.json
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -4,17 +4,17 @@
     "schema": "chrome://browser/content/schemas/bookmarks.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["bookmarks"]
     ]
   },
   "browserAction": {
     "url": "chrome://browser/content/parent/ext-browserAction.js",
-    "schema": "chrome://browser/content/schemas/browser_action.json",
+    "schema": "chrome://extensions/content/schemas/browser_action.json",
     "scopes": ["addon_parent"],
     "manifest": ["browser_action"],
     "paths": [
       ["browserAction"]
     ]
   },
   "browsingData": {
     "url": "chrome://browser/content/parent/ext-browsingData.js",
@@ -135,17 +135,17 @@
     "scopes": ["addon_parent"],
     "manifest": ["omnibox"],
     "paths": [
       ["omnibox"]
     ]
   },
   "pageAction": {
     "url": "chrome://browser/content/parent/ext-pageAction.js",
-    "schema": "chrome://browser/content/schemas/page_action.json",
+    "schema": "chrome://extensions/content/schemas/page_action.json",
     "scopes": ["addon_parent"],
     "manifest": ["page_action"],
     "paths": [
       ["pageAction"]
     ]
   },
   "pkcs11": {
     "url": "chrome://browser/content/parent/ext-pkcs11.js",
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -1,29 +1,27 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 browser.jar:
     content/browser/schemas/bookmarks.json
-    content/browser/schemas/browser_action.json
     content/browser/schemas/browsing_data.json
     content/browser/schemas/chrome_settings_overrides.json
     content/browser/schemas/commands.json
     content/browser/schemas/devtools.json
     content/browser/schemas/devtools_inspected_window.json
     content/browser/schemas/devtools_network.json
     content/browser/schemas/devtools_panels.json
     content/browser/schemas/find.json
     content/browser/schemas/history.json
     content/browser/schemas/menus.json
     content/browser/schemas/menus_child.json
     content/browser/schemas/normandyAddonStudy.json
     content/browser/schemas/omnibox.json
-    content/browser/schemas/page_action.json
     content/browser/schemas/pkcs11.json
     content/browser/schemas/search.json
     content/browser/schemas/sessions.json
     content/browser/schemas/sidebar_action.json
     content/browser/schemas/tabs.json
     content/browser/schemas/top_sites.json
     content/browser/schemas/url_overrides.json
     content/browser/schemas/urlbar.json
--- a/mobile/android/components/extensions/ext-android.js
+++ b/mobile/android/components/extensions/ext-android.js
@@ -65,23 +65,37 @@ global.openOptionsPage = extension => {
   } else {
     BrowserApp.openAddonManager({ addonId: extension.id });
   }
 
   return Promise.resolve();
 };
 
 extensions.registerModules({
+  browserAction: {
+    url: "chrome://geckoview/content/ext-browserAction.js",
+    schema: "chrome://extensions/content/schemas/browser_action.json",
+    scopes: ["addon_parent"],
+    manifest: ["browser_action"],
+    paths: [["browserAction"]],
+  },
   browsingData: {
     url: "chrome://geckoview/content/ext-browsingData.js",
     schema: "chrome://geckoview/content/schemas/browsing_data.json",
     scopes: ["addon_parent"],
     manifest: ["browsing_data"],
     paths: [["browsingData"]],
   },
+  pageAction: {
+    url: "chrome://geckoview/content/ext-pageAction.js",
+    schema: "chrome://extensions/content/schemas/page_action.json",
+    scopes: ["addon_parent"],
+    manifest: ["page_action"],
+    paths: [["pageAction"]],
+  },
   tabs: {
     url: "chrome://geckoview/content/ext-tabs.js",
     schema: "chrome://geckoview/content/schemas/tabs.json",
     scopes: ["addon_parent"],
     paths: [["tabs"]],
   },
   geckoViewAddons: {
     schema: "chrome://geckoview/content/schemas/gecko_view_addons.json",
new file mode 100644
--- /dev/null
+++ b/mobile/android/components/extensions/ext-browserAction.js
@@ -0,0 +1,128 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// The ext-* files are imported into the same scopes.
+/* import-globals-from ext-utils.js */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.jsm",
+  ExtensionActionHelper: "resource://gre/modules/GeckoViewWebExtension.jsm",
+});
+
+const { BrowserActionBase } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionActions.jsm"
+);
+
+const BROWSER_ACTION_PROPERTIES = [
+  "title",
+  "icon",
+  "popup",
+  "badgeText",
+  "badgeBackgroundColor",
+  "badgeTextColor",
+  "enabled",
+  "patternMatching",
+];
+
+class BrowserAction extends BrowserActionBase {
+  constructor(extension, clickDelegate) {
+    const tabContext = new TabContext(tabId => this.getContextData(null));
+    super(tabContext, extension);
+    this.clickDelegate = clickDelegate;
+    this.helper = new ExtensionActionHelper({
+      extension,
+      tabTracker,
+      windowTracker,
+      tabContext,
+      properties: BROWSER_ACTION_PROPERTIES,
+    });
+  }
+
+  updateOnChange(tab) {
+    const tabId = tab ? tab.id : null;
+    const action = tab
+      ? this.getContextData(tab)
+      : this.helper.extractProperties(this.globals);
+    this.helper.sendRequestForResult(tabId, {
+      action,
+      type: "GeckoView:BrowserAction:Update",
+    });
+  }
+
+  openPopup() {
+    const tab = tabTracker.activeTab;
+    const action = this.getContextData(tab);
+    this.helper.sendRequest(tab.id, {
+      action,
+      type: "GeckoView:BrowserAction:OpenPopup",
+    });
+  }
+
+  getTab(tabId) {
+    return this.helper.getTab(tabId);
+  }
+
+  getWindow(windowId) {
+    return this.helper.getWindow(windowId);
+  }
+
+  click() {
+    this.clickDelegate.onClick();
+  }
+}
+
+this.browserAction = class extends ExtensionAPI {
+  async onManifestEntry(entryName) {
+    const { extension } = this;
+    this.action = new BrowserAction(extension, this);
+    await this.action.loadIconData();
+
+    GeckoViewWebExtension.browserActions.set(extension, this.action);
+
+    // Notify the embedder of this action
+    this.action.updateOnChange(null);
+  }
+
+  onShutdown() {
+    const { extension } = this;
+    this.action.onShutdown();
+    GeckoViewWebExtension.browserActions.delete(extension);
+  }
+
+  onClick() {
+    this.emit("click", tabTracker.activeTab);
+  }
+
+  getAPI(context) {
+    const { extension } = context;
+    const { tabManager } = extension;
+    const { action } = this;
+
+    return {
+      browserAction: {
+        ...action.api(context),
+
+        onClicked: new EventManager({
+          context,
+          name: "browserAction.onClicked",
+          register: fire => {
+            const listener = (event, tab) => {
+              fire.async(tabManager.convert(tab));
+            };
+            this.on("click", listener);
+            return () => {
+              this.off("click", listener);
+            };
+          },
+        }).api(),
+
+        openPopup: function() {
+          action.openPopup();
+        },
+      },
+    };
+  }
+};
+
+global.browserActionFor = this.browserAction.for;
new file mode 100644
--- /dev/null
+++ b/mobile/android/components/extensions/ext-pageAction.js
@@ -0,0 +1,122 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+// The ext-* files are imported into the same scopes.
+/* import-globals-from ext-utils.js */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.jsm",
+  ExtensionActionHelper: "resource://gre/modules/GeckoViewWebExtension.jsm",
+});
+
+const { PageActionBase } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionActions.jsm"
+);
+
+const PAGE_ACTION_PROPERTIES = [
+  "title",
+  "icon",
+  "popup",
+  "badgeText",
+  "enabled",
+  "patternMatching",
+];
+
+class PageAction extends PageActionBase {
+  constructor(extension, clickDelegate) {
+    const tabContext = new TabContext(tabId => this.getContextData(null));
+    super(tabContext, extension);
+    this.clickDelegate = clickDelegate;
+    this.helper = new ExtensionActionHelper({
+      extension,
+      tabTracker,
+      windowTracker,
+      tabContext,
+      properties: PAGE_ACTION_PROPERTIES,
+    });
+  }
+
+  updateOnChange(tab) {
+    const tabId = tab ? tab.id : null;
+    // The embedder only gets the override, not the full object
+    const action = tab
+      ? this.getContextData(tab)
+      : this.helper.extractProperties(this.globals);
+    this.helper.sendRequestForResult(tabId, {
+      action,
+      type: "GeckoView:PageAction:Update",
+    });
+  }
+
+  openPopup() {
+    const action = this.getContextData(tabTracker.activeTab);
+    this.helper.sendRequest(tabTracker.activeTab.id, {
+      action,
+      type: "GeckoView:PageAction:OpenPopup",
+    });
+  }
+
+  getTab(tabId) {
+    return this.helper.getTab(tabId);
+  }
+
+  click() {
+    this.clickDelegate.onClick();
+  }
+}
+
+this.pageAction = class extends ExtensionAPI {
+  async onManifestEntry(entryName) {
+    const { extension } = this;
+    const action = new PageAction(extension, this);
+    await action.loadIconData();
+    this.action = action;
+
+    GeckoViewWebExtension.pageActions.set(extension, action);
+
+    // Notify the embedder of this action
+    action.updateOnChange(null);
+  }
+
+  onClick() {
+    this.emit("click", tabTracker.activeTab);
+  }
+
+  onShutdown() {
+    const { extension, action } = this;
+    action.onShutdown();
+    GeckoViewWebExtension.pageActions.delete(extension);
+  }
+
+  getAPI(context) {
+    const { extension } = context;
+    const { tabManager } = extension;
+    const { action } = this;
+
+    return {
+      pageAction: {
+        ...action.api(context),
+
+        onClicked: new EventManager({
+          context,
+          name: "pageAction.onClicked",
+          register: fire => {
+            const listener = (event, tab) => {
+              fire.async(tabManager.convert(tab));
+            };
+            this.on("click", listener);
+            return () => {
+              this.off("click", listener);
+            };
+          },
+        }).api(),
+
+        openPopup() {
+          action.openPopup();
+        },
+      },
+    };
+  }
+};
--- a/mobile/android/components/geckoview/GeckoViewStartup.js
+++ b/mobile/android/components/geckoview/GeckoViewStartup.js
@@ -64,16 +64,18 @@ GeckoViewStartup.prototype = {
 
         GeckoViewUtils.addLazyGetter(this, "GeckoViewConsole", {
           module: "resource://gre/modules/GeckoViewConsole.jsm",
         });
 
         GeckoViewUtils.addLazyGetter(this, "GeckoViewWebExtension", {
           module: "resource://gre/modules/GeckoViewWebExtension.jsm",
           ged: [
+            "GeckoView:BrowserAction:Click",
+            "GeckoView:PageAction:Click",
             "GeckoView:RegisterWebExtension",
             "GeckoView:UnregisterWebExtension",
             "GeckoView:WebExtension:PortDisconnect",
             "GeckoView:WebExtension:PortMessageFromApp",
           ],
         });
 
         GeckoViewUtils.addLazyGetter(this, "GeckoViewStorageController", {
--- a/mobile/android/geckoview/api.txt
+++ b/mobile/android/geckoview/api.txt
@@ -596,16 +596,17 @@ package org.mozilla.geckoview {
     method @UiThread @Nullable public GeckoSession.ProgressDelegate getProgressDelegate();
     method @AnyThread @Nullable public GeckoSession.PromptDelegate getPromptDelegate();
     method @UiThread @Nullable public GeckoSession.ScrollDelegate getScrollDelegate();
     method @AnyThread @Nullable public GeckoSession.SelectionActionDelegate getSelectionActionDelegate();
     method @AnyThread @NonNull public GeckoSessionSettings getSettings();
     method @UiThread public void getSurfaceBounds(@NonNull Rect);
     method @AnyThread @NonNull public SessionTextInput getTextInput();
     method @AnyThread @NonNull public GeckoResult<String> getUserAgent();
+    method @AnyThread @Nullable public WebExtension.ActionDelegate getWebExtensionActionDelegate(@NonNull WebExtension);
     method @AnyThread public void goBack();
     method @AnyThread public void goForward();
     method @AnyThread public void gotoHistoryIndex(int);
     method @AnyThread public boolean isOpen();
     method @AnyThread public void loadData(@NonNull byte[], @Nullable String);
     method @AnyThread public void loadString(@NonNull String, @Nullable String);
     method @AnyThread public void loadUri(@NonNull String);
     method @AnyThread public void loadUri(@NonNull String, @Nullable Map<String,String>);
@@ -634,16 +635,17 @@ package org.mozilla.geckoview {
     method @AnyThread public void setMediaDelegate(@Nullable GeckoSession.MediaDelegate);
     method @AnyThread public void setMessageDelegate(@NonNull WebExtension, @Nullable WebExtension.MessageDelegate, @NonNull String);
     method @UiThread public void setNavigationDelegate(@Nullable GeckoSession.NavigationDelegate);
     method @UiThread public void setPermissionDelegate(@Nullable GeckoSession.PermissionDelegate);
     method @UiThread public void setProgressDelegate(@Nullable GeckoSession.ProgressDelegate);
     method @AnyThread public void setPromptDelegate(@Nullable GeckoSession.PromptDelegate);
     method @UiThread public void setScrollDelegate(@Nullable GeckoSession.ScrollDelegate);
     method @UiThread public void setSelectionActionDelegate(@Nullable GeckoSession.SelectionActionDelegate);
+    method @AnyThread public void setWebExtensionActionDelegate(@NonNull WebExtension, @Nullable WebExtension.ActionDelegate);
     method @AnyThread public void stop();
     method @UiThread protected void setShouldPinOnScreen(boolean);
     field public static final Parcelable.Creator<GeckoSession> CREATOR;
     field public static final int FINDER_DISPLAY_DIM_PAGE = 2;
     field public static final int FINDER_DISPLAY_DRAW_LINK_OUTLINE = 4;
     field public static final int FINDER_DISPLAY_HIGHLIGHT_ALL = 1;
     field public static final int FINDER_FIND_BACKWARDS = 1;
     field public static final int FINDER_FIND_LINKS_ONLY = 8;
@@ -1382,22 +1384,47 @@ package org.mozilla.geckoview {
     field public static final long PERMISSIONS = 64L;
     field public static final long SITE_DATA = 471L;
     field public static final long SITE_SETTINGS = 192L;
   }
 
   public class WebExtension {
     ctor public WebExtension(@NonNull String, @NonNull String, long);
     ctor public WebExtension(@NonNull String);
+    method @AnyThread public void setActionDelegate(@Nullable WebExtension.ActionDelegate);
     method @UiThread public void setMessageDelegate(@Nullable WebExtension.MessageDelegate, @NonNull String);
     field public final long flags;
     field @NonNull public final String id;
     field @NonNull public final String location;
   }
 
+  @AnyThread public static class WebExtension.Action {
+    ctor protected Action();
+    method @UiThread public void click();
+    method @NonNull public WebExtension.Action withDefault(@NonNull WebExtension.Action);
+    field @Nullable public final Integer badgeBackgroundColor;
+    field @Nullable public final String badgeText;
+    field @Nullable public final Integer badgeTextColor;
+    field @Nullable public final Boolean enabled;
+    field @Nullable public final WebExtension.ActionIcon icon;
+    field @Nullable public final String title;
+  }
+
+  public static interface WebExtension.ActionDelegate {
+    method @UiThread default public void onBrowserAction(@NonNull WebExtension, @Nullable GeckoSession, @NonNull WebExtension.Action);
+    method @UiThread @Nullable default public GeckoResult<GeckoSession> onOpenPopup(@NonNull WebExtension, @NonNull WebExtension.Action);
+    method @UiThread default public void onPageAction(@NonNull WebExtension, @Nullable GeckoSession, @NonNull WebExtension.Action);
+    method @UiThread @Nullable default public GeckoResult<GeckoSession> onTogglePopup(@NonNull WebExtension, @NonNull WebExtension.Action);
+  }
+
+  public static class WebExtension.ActionIcon {
+    ctor protected ActionIcon();
+    method @AnyThread @NonNull public GeckoResult<Bitmap> get(int);
+  }
+
   public static class WebExtension.Flags {
     ctor protected Flags();
     field public static final long ALLOW_CONTENT_MESSAGING = 1L;
     field public static final long NONE = 0L;
   }
 
   @UiThread public static interface WebExtension.MessageDelegate {
     method @Nullable default public void onConnect(@NonNull WebExtension.Port);
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/background.js
@@ -0,0 +1,140 @@
+const port = browser.runtime.connectNative("browser");
+port.onMessage.addListener(message => {
+  handleMessage(message, null);
+});
+
+browser.runtime.onMessage.addListener((message, sender) => {
+  handleMessage(message, sender.tab.id);
+});
+
+browser.pageAction.onClicked.addListener(tab => {
+  port.postMessage({ method: "onClicked", tabId: tab.id, type: "pageAction" });
+});
+
+browser.browserAction.onClicked.addListener(tab => {
+  port.postMessage({
+    method: "onClicked",
+    tabId: tab.id,
+    type: "browserAction",
+  });
+});
+
+function handlePageActionMessage(message, tabId) {
+  switch (message.action) {
+    case "enable":
+      browser.pageAction.show(tabId);
+      break;
+
+    case "disable":
+      browser.pageAction.hide(tabId);
+      break;
+
+    case "setPopup":
+      browser.pageAction.setPopup({
+        tabId,
+        popup: message.popup,
+      });
+      break;
+
+    case "setTitle":
+      browser.pageAction.setTitle({
+        tabId,
+        title: message.title,
+      });
+      break;
+
+    case "setIcon":
+      browser.pageAction.setIcon({
+        tabId,
+        imageData: message.imageData,
+        path: message.path,
+      });
+      break;
+
+    default:
+      throw new Error(`Page Action does not support ${message.action}`);
+  }
+}
+
+function handleBrowserActionMessage(message, tabId) {
+  switch (message.action) {
+    case "enable":
+      browser.browserAction.enable(tabId);
+      break;
+
+    case "disable":
+      browser.browserAction.disable(tabId);
+      break;
+
+    case "setBadgeText":
+      browser.browserAction.setBadgeText({
+        tabId,
+        text: message.text,
+      });
+      break;
+
+    case "setBadgeTextColor":
+      browser.browserAction.setBadgeTextColor({
+        tabId,
+        color: message.color,
+      });
+      break;
+
+    case "setBadgeBackgroundColor":
+      browser.browserAction.setBadgeBackgroundColor({
+        tabId,
+        color: message.color,
+      });
+      break;
+
+    case "setPopup":
+      browser.browserAction.setPopup({
+        tabId,
+        popup: message.popup,
+      });
+      break;
+
+    case "setTitle":
+      browser.browserAction.setTitle({
+        tabId,
+        title: message.title,
+      });
+      break;
+
+    case "setIcon":
+      browser.browserAction.setIcon({
+        tabId,
+        imageData: message.imageData,
+        path: message.path,
+      });
+      break;
+
+    default:
+      throw new Error(`Browser Action does not support ${message.action}`);
+  }
+}
+
+function handleMessage(message, tabId) {
+  switch (message.type) {
+    case "ping":
+      port.postMessage({ method: "pong" });
+      return;
+
+    case "load":
+      browser.tabs.update(tabId, {
+        url: message.url,
+      });
+      return;
+
+    case "browserAction":
+      handleBrowserActionMessage(message, tabId);
+      return;
+
+    case "pageAction":
+      handlePageActionMessage(message, tabId);
+      return;
+
+    default:
+      throw new Error(`Unsupported message type ${message.type}`);
+  }
+}
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..aea2c19784430b624d37821c2fc86386b40e52bd
GIT binary patch
literal 1074
zc%17D@N?(olHy`uVBq!ia0vp^DIm<j1|$m}O$`B3EX7WqAsj$Z!;#X#z`*>{)5S5Q
zV$R#S4+C!-h&0%5{TrVjP|lp+Fmpi((;d!J8aWe6m~JSPFkRxzQd5}g@Hf#>x=$do
z=*jm<-|s#xVxP41$LYzckwpg#cn$;63_0_1R-w!W|A&7~uN`o%Fz0h%jbSoBu(<f#
z@jVU4Cp<TjIecOXn^@0;4esZZWG+j1vg#D6?omu{boDWj6?Sn+Ia0E@F6g0*iIz^(
zu|US}OH==-Kb4#O>C76VC7v^@Wr7&BpWj*c(Is%t_v{BUH_n8%?`+kXQylB%_WrMY
zTVuR@oIy!u_JYE5wLUiQgnmq$6mA{sw0RTbx<<1{Kg1Q^sx6%JE@6I@hN$xHZ3iZ8
zag62Fai8|Jqv5ru=OTXr=a4PJQ*Ih5MM>;qP<OO_v}Daq+r}AA%xm1I#QR5Vi&(U%
zS<#5^;_s}E9uYqUuT_Hg&wXq7n(;QuNZ{(`MQ6;7&4P9_mCo$FePY=vy@2VnI+k(o
z&<&V=^TzF~sS?&x9$9U=sda33Q>y8?oio1ecw%0ZAGz?{(Pd>`Tr0n;a$Hs{XJ3EP
z{+-jz-z)fIC#rY3dAQ876jMDirE-d(kaN9K>boN?GXx&%`g9oJM|M6nduAoRzn}f)
z*YDFN>65Rj_1&qmHF(JKG$gxPzfoH~<zh+ZA*Hw358U5vENTro&|AFj?AjBC5BBb7
z-ZyDum0kFWQz<#ZOXe(pRzE+m?D?GCr%u#Qv3Q<k^zFoRmlN*;c6?-dU&~+PcXIZv
zx4AyY@(%6veDz=nm*uOk>x3tk^yMUI@CYlxQO`<s@oph8ult+!`u{zo`Yv#KgO{S?
z5mD>1sLJ=&ZF}Wk9a)jRY0vll5^S-j%_r$Lsx1j^)lyV6vU>E+{)>b7&i~Flt6B{1
z6iXOqelMBt#Cxknhx_SFUQ?$-Ul;hFxRt$Y+6qCZd&X<hs{dV$FyTD;^3bZ6|5gc3
z+T7rD`4P9N6I1L$o6QS-PkeW(Omm((;ldXkx0J|?taT1E1sKe2OpM)59a6PRDE7O;
z*_f_*GgD<zP{;Rw&qY8Cj*pYOUl}MKx}fzWZ;{EP3tCI&YxG(G!}toP<mKv1H!cUM
zw-g4y%vyGLK1h|ulb#(@pNc*;pOCiL&*Mh*My7oqcFoZGlfSf(zxz~cQ6k%SjrkU9
z^_==-kpc)5MswKNb4=dt*Kmv|jIW9Q64=MAru6g96BiHGFJh{<TVAa2oa27Fa{}*$
ztlI*`9*1^joSbpx%-$92C6}z&{ocS7m}OG_X`G$1Uo6)6kHUwQw(O~gzxgE`2Ios>
bnR>=-Ico;K<6C-wg#d%6tDnm{r-UW|4`}4>
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..90687de26d71e91b7c82565772a7df470ae277a6
GIT binary patch
literal 225
zc%17D@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmSQK*5Dp-y;YjHK@;M7UB8wRq
zxP?KOkzv*x37}xJr;B4qM&sM7j(iOY0?rpNR{Ym~eNUieh4I>d+mEvHuIy!K@bZ41
z<G=gpjAyae-$dK=GF;u(nO|M2nf#xLWrf-0#+>J}N$e^&*#q7kxbW`Aeg?)>n&l0$
z8xrIlb~3+dVExT-N;ZLA=LS%o!8+lf-GRA$F@Klex9jiV-^0Mj@Zdh*s&<Z;Pny1y
QfX-p?boFyt=akR{0F1y+Z~y=R
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..90687de26d71e91b7c82565772a7df470ae277a6
GIT binary patch
literal 225
zc%17D@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmSQK*5Dp-y;YjHK@;M7UB8wRq
zxP?KOkzv*x37}xJr;B4qM&sM7j(iOY0?rpNR{Ym~eNUieh4I>d+mEvHuIy!K@bZ41
z<G=gpjAyae-$dK=GF;u(nO|M2nf#xLWrf-0#+>J}N$e^&*#q7kxbW`Aeg?)>n&l0$
z8xrIlb~3+dVExT-N;ZLA=LS%o!8+lf-GRA$F@Klex9jiV-^0Mj@Zdh*s&<Z;Pny1y
QfX-p?boFyt=akR{0F1y+Z~y=R
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/icon.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" ?><!DOCTYPE svg  PUBLIC '-//W3C//DTD SVG 1.1//EN'  'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 500 500" height="500px" id="Layer_1" version="1.1" viewBox="0 0 500 500" width="500px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path clip-rule="evenodd" d="M131.889,150.061v63.597h-27.256  c-20.079,0-36.343,16.263-36.343,36.342v181.711c0,20.078,16.264,36.34,36.343,36.34h290.734c20.078,0,36.345-16.262,36.345-36.34  V250c0-20.079-16.267-36.342-36.345-36.342h-27.254v-63.597c0-65.232-52.882-118.111-118.112-118.111  S131.889,84.828,131.889,150.061z M177.317,213.658v-63.597c0-40.157,32.525-72.685,72.683-72.685  c40.158,0,72.685,32.528,72.685,72.685v63.597H177.317z M213.658,313.599c0-20.078,16.263-36.341,36.342-36.341  s36.341,16.263,36.341,36.341c0,12.812-6.634,24.079-16.625,30.529c0,0,3.55,21.446,7.542,46.699  c0,7.538-6.087,13.625-13.629,13.625h-27.258c-7.541,0-13.627-6.087-13.627-13.625l7.542-46.699  C220.294,337.678,213.658,326.41,213.658,313.599z" fill="#010101" fill-rule="evenodd"/></svg>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/content.js
@@ -0,0 +1,4 @@
+const port = browser.runtime.connectNative("browser");
+port.onMessage.addListener(message => {
+  browser.runtime.sendMessage(message);
+});
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/manifest.json
@@ -0,0 +1,30 @@
+{
+  "manifest_version": 2,
+  "name": "actions",
+  "version": "1.0",
+  "description": "Defines Page and Browser actions",
+  "browser_action": {
+    "default_title": "Test action default"
+  },
+  "page_action": {
+    "default_title": "Test action default",
+    "default_icon": {
+      "19": "button/geo-19.png",
+      "38": "button/geo-38.png"
+    }
+  },
+  "background": {
+    "scripts": ["background.js"]
+  },
+  "content_scripts": [
+    {
+      "matches": ["<all_urls>"],
+      "js": ["content.js"]
+    }
+  ],
+  "permissions": [
+    "tabs",
+    "geckoViewAddons",
+    "nativeMessaging"
+  ]
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.html
@@ -0,0 +1,10 @@
+<html>
+<head>
+    <script type="text/javascript" src="test-open-popup-browser-action.js"></script>
+</head>
+<body>
+    <body style="height: 100%">
+        <p>Hello, world!</p>
+    </body>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.js
@@ -0,0 +1,7 @@
+window.addEventListener("DOMContentLoaded", init);
+
+function init() {
+  document.body.addEventListener("click", event => {
+    browser.browserAction.openPopup();
+  });
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.html
@@ -0,0 +1,10 @@
+<html>
+<head>
+    <script type="text/javascript" src="test-open-popup-page-action.js"></script>
+</head>
+<body>
+    <body style="height: 100%">
+        <p>Hello, world!</p>
+    </body>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.js
@@ -0,0 +1,7 @@
+window.addEventListener("DOMContentLoaded", init);
+
+function init() {
+  document.body.addEventListener("click", event => {
+    browser.pageAction.openPopup();
+  });
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.html
@@ -0,0 +1,1 @@
+<h1> HELLO </h1>
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/icon.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" ?><!DOCTYPE svg  PUBLIC '-//W3C//DTD SVG 1.1//EN'  'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 500 500" height="500px" id="Layer_1" version="1.1" viewBox="0 0 500 500" width="500px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path clip-rule="evenodd" d="M131.889,150.061v63.597h-27.256  c-20.079,0-36.343,16.263-36.343,36.342v181.711c0,20.078,16.264,36.34,36.343,36.34h290.734c20.078,0,36.345-16.262,36.345-36.34  V250c0-20.079-16.267-36.342-36.345-36.342h-27.254v-63.597c0-65.232-52.882-118.111-118.112-118.111  S131.889,84.828,131.889,150.061z M177.317,213.658v-63.597c0-40.157,32.525-72.685,72.683-72.685  c40.158,0,72.685,32.528,72.685,72.685v63.597H177.317z M213.658,313.599c0-20.078,16.263-36.341,36.342-36.341  s36.341,16.263,36.341,36.341c0,12.812-6.634,24.079-16.625,30.529c0,0,3.55,21.446,7.542,46.699  c0,7.538-6.087,13.625-13.629,13.625h-27.258c-7.541,0-13.627-6.087-13.627-13.625l7.542-46.699  C220.294,337.678,213.658,326.41,213.658,313.599z" fill="#010101" fill-rule="evenodd"/></svg>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt
@@ -0,0 +1,540 @@
+package org.mozilla.geckoview.test
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.support.test.InstrumentationRegistry
+import android.support.test.filters.MediumTest
+import org.hamcrest.Matchers.equalTo
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeThat
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+import org.mozilla.geckoview.WebExtension
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule
+
+@MediumTest
+@RunWith(Parameterized::class)
+class ExtensionActionTest : BaseSessionTest() {
+    var extension: WebExtension? = null
+    var default: WebExtension.Action? = null
+    var backgroundPort: WebExtension.Port? = null
+    var windowPort: WebExtension.Port? = null
+
+    companion object {
+        @get:Parameterized.Parameters(name = "{0}")
+        @JvmStatic
+        val parameters: List<Array<out Any>> = listOf(
+                arrayOf("#pageAction"),
+                arrayOf("#browserAction"))
+    }
+
+    @field:Parameterized.Parameter(0) @JvmField var id: String = ""
+
+    @Before
+    fun setup() {
+        // This method installs the extension, opens up ports with the background script and the
+        // content script and captures the default action definition from the manifest
+        val browserActionDefaultResult = GeckoResult<WebExtension.Action>()
+        val pageActionDefaultResult = GeckoResult<WebExtension.Action>()
+
+        val windowPortResult = GeckoResult<WebExtension.Port>()
+        val backgroundPortResult = GeckoResult<WebExtension.Port>()
+
+        extension = WebExtension("resource://android/assets/web_extensions/actions/",
+                "actions", WebExtension.Flags.ALLOW_CONTENT_MESSAGING)
+
+        sessionRule.session.setMessageDelegate(
+                extension!!,
+                object : WebExtension.MessageDelegate {
+                    override fun onConnect(port: WebExtension.Port) {
+                        windowPortResult.complete(port)
+                    }
+                }, "browser")
+        extension!!.setMessageDelegate(object : WebExtension.MessageDelegate {
+            override fun onConnect(port: WebExtension.Port) {
+                backgroundPortResult.complete(port)
+            }
+        }, "browser")
+
+        sessionRule.addExternalDelegateDuringNextWait(
+                WebExtension.ActionDelegate::class,
+                extension!!::setActionDelegate,
+                { extension!!.setActionDelegate(null) },
+        object : WebExtension.ActionDelegate {
+            override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+                assertEquals(action.title, "Test action default")
+                browserActionDefaultResult.complete(action)
+            }
+            override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+                assertEquals(action.title, "Test action default")
+                pageActionDefaultResult.complete(action)
+            }
+        })
+
+        sessionRule.waitForResult(sessionRule.runtime.registerWebExtension(extension!!))
+
+        sessionRule.session.loadUri("http://example.com")
+        sessionRule.waitForPageStop()
+
+        default = when (id) {
+            "#pageAction" -> sessionRule.waitForResult(pageActionDefaultResult)
+            "#browserAction" -> sessionRule.waitForResult(browserActionDefaultResult)
+            else -> throw IllegalArgumentException()
+        }
+
+        windowPort = sessionRule.waitForResult(windowPortResult)
+        backgroundPort = sessionRule.waitForResult(backgroundPortResult)
+
+        if (id == "#pageAction") {
+            // Make sure that the pageAction starts enabled for this tab
+            testActionApi("""{"action": "enable"}""") { action ->
+                assertEquals(action.enabled, true)
+            }
+        }
+    }
+
+    private var type: String = ""
+        get() = when(id) {
+            "#pageAction" -> "pageAction"
+            "#browserAction" -> "browserAction"
+            else -> throw IllegalArgumentException()
+        }
+
+    @After
+    fun tearDown() {
+        sessionRule.waitForResult(sessionRule.runtime.unregisterWebExtension(extension!!))
+    }
+
+    private fun testBackgroundActionApi(message: String, tester: (WebExtension.Action) -> Unit) {
+        val result = GeckoResult<Void>()
+
+        val json = JSONObject(message)
+        json.put("type", type)
+
+        backgroundPort!!.postMessage(json)
+
+        sessionRule.addExternalDelegateDuringNextWait(
+                WebExtension.ActionDelegate::class,
+                extension!!::setActionDelegate,
+                { extension!!.setActionDelegate(null) },
+                object : WebExtension.ActionDelegate {
+            override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+                assertEquals(id, "#browserAction")
+                default = action
+                tester(action)
+                result.complete(null)
+            }
+            override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+                assertEquals(id, "#pageAction")
+                default = action
+                tester(action)
+                result.complete(null)
+            }
+        })
+
+        sessionRule.waitForResult(result)
+    }
+
+    private fun testActionApi(message: String, tester: (WebExtension.Action) -> Unit) {
+        val result = GeckoResult<Void>()
+
+        val json = JSONObject(message)
+        json.put("type", type)
+
+        windowPort!!.postMessage(json)
+
+        sessionRule.addExternalDelegateDuringNextWait(
+                WebExtension.ActionDelegate::class,
+                { delegate ->
+                    sessionRule.session.setWebExtensionActionDelegate(extension!!, delegate) },
+                { sessionRule.session.setWebExtensionActionDelegate(extension!!, null) },
+        object : WebExtension.ActionDelegate {
+            override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+                assertEquals(id, "#browserAction")
+                val resolved = action.withDefault(default!!)
+                tester(resolved)
+                result.complete(null)
+            }
+            override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) {
+                assertEquals(id, "#pageAction")
+                val resolved = action.withDefault(default!!)
+                tester(resolved)
+                result.complete(null)
+            }
+        })
+
+        sessionRule.waitForResult(result)
+    }
+
+    @Test
+    fun disableTest() {
+        testActionApi("""{"action": "disable"}""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.enabled, false)
+        }
+    }
+
+    @Test
+    fun enableTest() {
+        // First, make sure the action is disabled
+        testActionApi("""{"action": "disable"}""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.enabled, false)
+        }
+
+        testActionApi("""{"action": "enable"}""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.enabled, true)
+        }
+    }
+
+    @Test
+    fun setOverridenTitle() {
+        testActionApi("""{
+               "action": "setTitle",
+               "title": "overridden title"
+            }""") { action ->
+            assertEquals(action.title, "overridden title")
+            assertEquals(action.enabled, true)
+        }
+    }
+
+    @Test
+    fun setBadgeText() {
+        assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction"))
+
+        testActionApi("""{
+           "action": "setBadgeText",
+           "text": "12"
+        }""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.badgeText, "12")
+            assertEquals(action.enabled, true)
+        }
+    }
+
+    @Test
+    fun setBadgeBackgroundColor() {
+        assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction"))
+
+        colorTest("setBadgeBackgroundColor", "#ABCDEF", "#FFABCDEF")
+        colorTest("setBadgeBackgroundColor", "#F0A", "#FFFF00AA")
+        colorTest("setBadgeBackgroundColor", "red", "#FFFF0000")
+        colorTest("setBadgeBackgroundColor", "rgb(0, 0, 255)", "#FF0000FF")
+        colorTest("setBadgeBackgroundColor", "rgba(0, 255, 0, 0.5)", "#8000FF00")
+        colorRawTest("setBadgeBackgroundColor", "[0, 0, 255, 128]", "#800000FF")
+    }
+
+    private fun colorTest(actionName: String, color: String, expectedHex: String) {
+        colorRawTest(actionName, "\"$color\"", expectedHex)
+    }
+
+    private fun colorRawTest(actionName: String, color: String, expectedHex: String) {
+        testActionApi("""{
+           "action": "$actionName",
+           "color": $color
+        }""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.badgeText, "")
+            assertEquals(action.enabled, true)
+
+            val result = when (actionName) {
+                "setBadgeTextColor" -> action.badgeTextColor!!
+                "setBadgeBackgroundColor" -> action.badgeBackgroundColor!!
+                else -> throw IllegalArgumentException()
+            }
+
+            val hexColor = String.format("#%08X", result)
+            assertEquals(hexColor, "$expectedHex")
+        }
+    }
+
+    @Test
+    fun setBadgeTextColor() {
+        assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction"))
+
+        colorTest("setBadgeTextColor", "#ABCDEF", "#FFABCDEF")
+        colorTest("setBadgeTextColor", "#F0A", "#FFFF00AA")
+        colorTest("setBadgeTextColor", "red", "#FFFF0000")
+        colorTest("setBadgeTextColor", "rgb(0, 0, 255)", "#FF0000FF")
+        colorTest("setBadgeTextColor", "rgba(0, 255, 0, 0.5)", "#8000FF00")
+        colorRawTest("setBadgeTextColor", "[0, 0, 255, 128]", "#800000FF")
+    }
+
+    @Test
+    fun setDefaultTitle() {
+        assumeThat("Only browserAction supports default properties.", id, equalTo("#browserAction"))
+
+        // Setting a default value will trigger the default handler on the extension object
+        testBackgroundActionApi("""{
+            "action": "setTitle",
+            "title": "new default title"
+        }""") { action ->
+            assertEquals(action.title, "new default title")
+            assertEquals(action.badgeText, "")
+            assertEquals(action.enabled, true)
+        }
+
+        // When an overridden title is set, the default has no effect
+        testActionApi("""{
+           "action": "setTitle",
+           "title": "test override"
+        }""") { action ->
+            assertEquals(action.title, "test override")
+            assertEquals(action.badgeText, "")
+            assertEquals(action.enabled, true)
+        }
+
+        // When the override is null, the new default takes effect
+        testActionApi("""{
+           "action": "setTitle",
+           "title": null
+        }""") { action ->
+            assertEquals(action.title, "new default title")
+            assertEquals(action.badgeText, "")
+            assertEquals(action.enabled, true)
+        }
+
+        // When the default value is null, the manifest value is used
+        testBackgroundActionApi("""{
+           "action": "setTitle",
+           "title": null
+        }""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.badgeText, "")
+            assertEquals(action.enabled, true)
+        }
+    }
+
+    private fun compareBitmap(expectedLocation: String, actual: Bitmap) {
+        val stream = InstrumentationRegistry.getTargetContext().assets
+                .open(expectedLocation)
+
+        val expected = BitmapFactory.decodeStream(stream)
+        for (x in 0 until actual.height) {
+            for (y in 0 until actual.width) {
+                assertEquals(expected.getPixel(x, y), actual.getPixel(x, y))
+            }
+        }
+    }
+
+    @Test
+    fun setIconSvg() {
+        val svg = GeckoResult<Void>()
+
+        testActionApi("""{
+           "action": "setIcon",
+           "path": "button/icon.svg"
+        }""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.enabled, true)
+
+            action.icon!!.get(100).accept { actual ->
+                compareBitmap("web_extensions/actions/button/expected.png", actual!!)
+                svg.complete(null)
+            }
+        }
+
+        sessionRule.waitForResult(svg)
+    }
+
+    @Test
+    fun setIconPng() {
+        val png100 = GeckoResult<Void>()
+        val png38 = GeckoResult<Void>()
+        val png19 = GeckoResult<Void>()
+        val png10 = GeckoResult<Void>()
+
+        testActionApi("""{
+           "action": "setIcon",
+           "path": {
+             "19": "button/geo-19.png",
+             "38": "button/geo-38.png"
+           }
+        }""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.enabled, true)
+
+            action.icon!!.get(100).accept { actual ->
+                compareBitmap("web_extensions/actions/button/geo-38.png", actual!!)
+                png100.complete(null)
+            }
+
+            action.icon!!.get(38).accept { actual ->
+                compareBitmap("web_extensions/actions/button/geo-38.png", actual!!)
+                png38.complete(null)
+            }
+
+            action.icon!!.get(19).accept { actual ->
+                compareBitmap("web_extensions/actions/button/geo-19.png", actual!!)
+                png19.complete(null)
+            }
+
+            action.icon!!.get(10).accept { actual ->
+                compareBitmap("web_extensions/actions/button/geo-19.png", actual!!)
+                png10.complete(null)
+            }
+        }
+
+        sessionRule.waitForResult(png100)
+        sessionRule.waitForResult(png38)
+        sessionRule.waitForResult(png19)
+        sessionRule.waitForResult(png10)
+    }
+
+    @Test
+    fun setIconError() {
+        val error = GeckoResult<Void>()
+
+        testActionApi("""{
+            "action": "setIcon",
+            "path": "invalid/path/image.png"
+        }""") { action ->
+            action.icon!!.get(38).accept({
+                error.completeExceptionally(RuntimeException("Should not succeed."))
+            }, { exception ->
+                assertTrue(exception is IllegalArgumentException)
+                error.complete(null)
+            })
+        }
+
+        sessionRule.waitForResult(error)
+    }
+
+    @Test
+    @GeckoSessionTestRule.WithDisplay(width=100, height=100)
+    @Ignore // this test fails intermittently on try :(
+    fun testOpenPopup() {
+        // First, let's make sure we have a popup set
+        val actionResult = GeckoResult<Void>()
+        testActionApi("""{
+           "action": "setPopup",
+           "popup": "test-popup.html"
+        }""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.enabled, true)
+
+            actionResult.complete(null)
+        }
+
+        val url = when(id) {
+            "#browserAction" -> "/test-open-popup-browser-action.html"
+            "#pageAction" -> "/test-open-popup-page-action.html"
+            else -> throw IllegalArgumentException()
+        }
+
+        windowPort!!.postMessage(JSONObject("""{
+            "type": "load",
+            "url": "$url"
+        }"""))
+
+        val openPopup = GeckoResult<Void>()
+        sessionRule.session.setWebExtensionActionDelegate(extension!!,
+                object : WebExtension.ActionDelegate {
+            override fun onOpenPopup(extension: WebExtension,
+                                     popupAction: WebExtension.Action): GeckoResult<GeckoSession>? {
+                assertEquals(extension, this@ExtensionActionTest.extension)
+                // assertEquals(popupAction, this@ExtensionActionTest.default)
+                openPopup.complete(null)
+                return null
+            }
+        })
+
+        sessionRule.waitForPageStops(2)
+        // openPopup needs user activation
+        sessionRule.session.synthesizeTap(50, 50)
+
+        sessionRule.waitForResult(openPopup)
+    }
+
+    @Test
+    fun testClickWhenPopupIsNotDefined() {
+        val pong = GeckoResult<Void>()
+
+        backgroundPort!!.setDelegate(object : WebExtension.PortDelegate {
+            override fun onPortMessage(message: Any, port: WebExtension.Port) {
+                val json = message as JSONObject
+                if (json.getString("method") == "pong") {
+                    pong.complete(null)
+                } else {
+                    // We should NOT receive onClicked here
+                    pong.completeExceptionally(IllegalArgumentException(
+                            "Received unexpected: ${json.getString("method")}"))
+                }
+            }
+        })
+
+        val actionResult = GeckoResult<WebExtension.Action>()
+
+        testActionApi("""{
+           "action": "setPopup",
+           "popup": "test-popup.html"
+        }""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.enabled, true)
+
+            actionResult.complete(action)
+        }
+
+        val togglePopup = GeckoResult<Void>()
+        val action = sessionRule.waitForResult(actionResult)
+
+        extension!!.setActionDelegate(object : WebExtension.ActionDelegate {
+            override fun onTogglePopup(extension: WebExtension,
+                                     popupAction: WebExtension.Action): GeckoResult<GeckoSession>? {
+                assertEquals(extension, this@ExtensionActionTest.extension)
+                assertEquals(popupAction, action)
+                togglePopup.complete(null)
+                return null
+            }
+        })
+
+        // This click() will not cause an onClicked callback because popup is set
+        action.click()
+
+        // but it will cause togglePopup to be called
+        sessionRule.waitForResult(togglePopup)
+
+        // If the response to ping reaches us before the onClicked we know onClicked wasn't called
+        backgroundPort!!.postMessage(JSONObject("""{
+            "type": "ping"
+        }"""))
+
+        sessionRule.waitForResult(pong)
+    }
+
+    @Test
+    fun testClickWhenPopupIsDefined() {
+        val onClicked = GeckoResult<Void>()
+        backgroundPort!!.setDelegate(object : WebExtension.PortDelegate {
+            override fun onPortMessage(message: Any, port: WebExtension.Port) {
+                val json = message as JSONObject
+                assertEquals(json.getString("method"), "onClicked")
+                assertEquals(json.getString("type"), type)
+                onClicked.complete(null)
+            }
+        })
+
+        testActionApi("""{
+           "action": "setPopup",
+           "popup": null
+        }""") { action ->
+            assertEquals(action.title, "Test action default")
+            assertEquals(action.enabled, true)
+
+            // This click() WILL cause an onClicked callback
+            action.click()
+        }
+
+        sessionRule.waitForResult(onClicked)
+    }
+}
+
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -358,55 +358,78 @@ public class GeckoSession implements Par
             result = 31 * result + (webExtensionId != null ? webExtensionId.hashCode() : 0);
             result = 31 * result + (nativeApp != null ? nativeApp.hashCode() : 0);
             return result;
         }
     }
 
     private final class WebExtensionListener implements BundleEventListener {
         final private HashMap<WebExtensionSender, WebExtension.MessageDelegate> mMessageDelegates;
+        final private HashMap<String, WebExtension.ActionDelegate> mActionDelegates;
 
         public WebExtensionListener() {
             mMessageDelegates = new HashMap<>();
+            mActionDelegates = new HashMap<>();
         }
 
         /* package */ void registerListeners() {
             getEventDispatcher().registerUiThreadListener(this,
                     "GeckoView:WebExtension:Message",
                     "GeckoView:WebExtension:PortMessage",
                     "GeckoView:WebExtension:Connect",
                     "GeckoView:WebExtension:CloseTab",
+
+                    // Browser and Page Actions
+                    "GeckoView:BrowserAction:Update",
+                    "GeckoView:BrowserAction:OpenPopup",
+                    "GeckoView:PageAction:Update",
+                    "GeckoView:PageAction:OpenPopup",
                     null);
         }
 
-        public void setDelegate(final WebExtension webExtension,
+        public void setActionDelegate(final WebExtension webExtension,
+                                      final WebExtension.ActionDelegate delegate) {
+            mActionDelegates.put(webExtension.id, delegate);
+        }
+
+        public WebExtension.ActionDelegate getActionDelegate(final WebExtension webExtension) {
+            return mActionDelegates.get(webExtension.id);
+        }
+
+        public void setMessageDelegate(final WebExtension webExtension,
                                 final WebExtension.MessageDelegate delegate,
                                 final String nativeApp) {
             mMessageDelegates.put(new WebExtensionSender(webExtension.id, nativeApp), delegate);
         }
 
-        public WebExtension.MessageDelegate getDelegate(final WebExtension webExtension,
-                                                        final String nativeApp) {
+        public WebExtension.MessageDelegate getMessageDelegate(final WebExtension webExtension,
+                                                               final String nativeApp) {
             return mMessageDelegates.get(new WebExtensionSender(webExtension.id, nativeApp));
         }
 
         @Override
         public void handleMessage(final String event, final GeckoBundle message,
                                   final EventCallback callback) {
             if (mWindow == null) {
                 return;
             }
 
             if ("GeckoView:WebExtension:Message".equals(event)
                     || "GeckoView:WebExtension:PortMessage".equals(event)
-                    || "GeckoView:WebExtension:Connect".equals(event)) {
+                    || "GeckoView:WebExtension:Connect".equals(event)
+                    || "GeckoView:PageAction:Update".equals(event)
+                    || "GeckoView:PageAction:OpenPopup".equals(event)
+                    || "GeckoView:BrowserAction:Update".equals(event)
+                    || "GeckoView:BrowserAction:OpenPopup".equals(event)) {
                 mWindow.runtime.getWebExtensionDispatcher()
                         .handleMessage(event, message, callback, GeckoSession.this);
+                return;
             } else if ("GeckoView:WebExtension:CloseTab".equals(event)) {
                 mWindow.runtime.getWebExtensionController().closeTab(message, callback, GeckoSession.this);
+                return;
             }
         }
     }
 
     private final WebExtensionListener mWebExtensionListener;
 
     /**
      * Get the message delegate for <code>nativeApp</code>.
@@ -416,17 +439,17 @@ public class GeckoSession implements Par
      * @return The {@link WebExtension.MessageDelegate} attached to the
      *         <code>nativeApp</code>.  <code>null</code> if no delegate is
      *         present.
      */
     @AnyThread
     public @Nullable WebExtension.MessageDelegate getMessageDelegate(
             final @NonNull WebExtension webExtension,
             final @NonNull String nativeApp) {
-        return mWebExtensionListener.getDelegate(webExtension, nativeApp);
+        return mWebExtensionListener.getMessageDelegate(webExtension, nativeApp);
     }
 
     /**
      * Defines a message delegate for a Native App.
      *
      * If a delegate is already present, this delegate will replace the
      * existing one.
      *
@@ -445,17 +468,51 @@ public class GeckoSession implements Par
      * @param nativeApp which native app id this message delegate will handle
      *                  messaging for.
      * @see WebExtension#setMessageDelegate
      */
     @AnyThread
     public void setMessageDelegate(final @NonNull WebExtension webExtension,
                                    final @Nullable WebExtension.MessageDelegate delegate,
                                    final @NonNull String nativeApp) {
-        mWebExtensionListener.setDelegate(webExtension, delegate, nativeApp);
+        mWebExtensionListener.setMessageDelegate(webExtension, delegate, nativeApp);
+    }
+
+    /**
+     * Set the Action delegate for this session.
+     *
+     * This delegate will receive page and browser action overrides specific to
+     * this session.  The default Action will be received by the delegate set
+     * by {@link WebExtension#setActionDelegate}.
+     *
+     * @param webExtension the {@link WebExtension} object this delegate will
+     *                     receive updates for
+     * @param delegate the {@link WebExtension.ActionDelegate} that will
+     *                 receive updates.
+     * @see WebExtension.Action
+     */
+    @AnyThread
+    public void setWebExtensionActionDelegate(final @NonNull WebExtension webExtension,
+                                              final @Nullable WebExtension.ActionDelegate delegate) {
+        mWebExtensionListener.setActionDelegate(webExtension, delegate);
+    }
+
+    /**
+     * Get the Action delegate for this session.
+     *
+     * @param webExtension {@link WebExtension} that this delegates receive
+     *                     updates for.
+     * @return {@link WebExtension.ActionDelegate} for this
+     *         session
+     */
+    @AnyThread
+    @Nullable
+    public WebExtension.ActionDelegate getWebExtensionActionDelegate(
+            final @NonNull WebExtension webExtension) {
+        return mWebExtensionListener.getActionDelegate(webExtension);
     }
 
     private final GeckoSessionHandler<ContentDelegate> mContentHandler =
         new GeckoSessionHandler<ContentDelegate>(
             "GeckoViewContent", this,
             new String[]{
                 "GeckoView:ContentCrash",
                 "GeckoView:ContentKill",
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
@@ -1,26 +1,32 @@
 package org.mozilla.geckoview;
 
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.support.annotation.AnyThread;
 import android.support.annotation.IntDef;
 import android.support.annotation.LongDef;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.annotation.UiThread;
 import android.util.Log;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.util.GeckoBundle;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.UUID;
 
 /**
  * Represents a WebExtension that may be used by GeckoView.
  */
 public class WebExtension {
     /**
@@ -45,16 +51,18 @@ public class WebExtension {
      * {@link Flags} for this WebExtension.
      */
     public final @WebExtensionFlags long flags;
     /**
      * Delegates that handle messaging between this WebExtension and the app.
      */
     /* package */ final @NonNull Map<String, MessageDelegate> messageDelegates;
 
+    /* package */ @NonNull ActionDelegate actionDelegate;
+
     @Override
     public String toString() {
         return "WebExtension {" +
                 "location=" + location + ", " +
                 "id=" + id + ", " +
                 "flags=" + flags + "}";
     }
 
@@ -468,9 +476,460 @@ public class WebExtension {
          *
          * @return true if the MessageSender was sent from the top level frame,
          *         false otherwise.
          * */
         public boolean isTopLevel() {
             return this.isTopLevel;
         }
     }
+
+    /**
+     * Represents the Icon for a {@link Action}.
+     */
+    public static class ActionIcon {
+        private Map<Integer, String> mIconUris;
+
+        /**
+         * Get the best version of this icon for size <code>pixelSize</code>.
+         *
+         * Embedders are encouraged to cache the result of this method keyed with this instance.
+         *
+         * @param pixelSize pixel size at which this icon will be displayed at.
+         *
+         * @return A {@link GeckoResult} that resolves to the bitmap when ready.
+         */
+        @AnyThread
+        @NonNull
+        public GeckoResult<Bitmap> get(final int pixelSize) {
+            int size;
+
+            if (mIconUris.containsKey(pixelSize)) {
+                // If this size matches exactly, return it
+                size = pixelSize;
+            } else {
+                // Otherwise, find the smallest larger image (or the largest image if they are all
+                // smaller)
+                List<Integer> sizes = new ArrayList<>();
+                sizes.addAll(mIconUris.keySet());
+                Collections.sort(sizes, (a, b) -> Integer.compare(b - pixelSize, a - pixelSize));
+                size = sizes.get(0);
+            }
+
+            final String uri = mIconUris.get(size);
+            return ImageDecoder.instance().decode(uri, pixelSize);
+        }
+
+        /* package */ ActionIcon(final GeckoBundle bundle) {
+            mIconUris = new HashMap<>();
+
+            for (final String key: bundle.keys()) {
+                final Integer intKey = Integer.valueOf(key);
+                if (intKey == null) {
+                    Log.e(LOGTAG, "Non-integer icon key: " + intKey);
+                    if (BuildConfig.DEBUG) {
+                        throw new RuntimeException("Non-integer icon key: " + key);
+                    }
+                    continue;
+                }
+                mIconUris.put(intKey, bundle.getString(key));
+            }
+        }
+
+        /** Override for tests. */
+        protected ActionIcon() {
+            mIconUris = null;
+        }
+
+        @Override
+        public boolean equals(final Object o) {
+            if (o == this) {
+                return true;
+            }
+
+            if (!(o instanceof ActionIcon)) {
+                return false;
+            }
+
+            return mIconUris.equals(((ActionIcon) o).mIconUris);
+        }
+
+        @Override
+        public int hashCode() {
+            return mIconUris.hashCode();
+        }
+    }
+
+    /**
+     * Represents either a Browser Action or a Page Action from the
+     * WebExtension API.
+     *
+     * Instances of this class may represent the default <code>Action</code>
+     * which applies to all WebExtension tabs or a tab-specific override. To
+     * reconstruct the full <code>Action</code> object, you can use
+     * {@link Action#withDefault}.
+     *
+     * Tab specific overrides can be obtained by registering a delegate using
+     * {@link GeckoSession#setWebExtensionActionDelegate}, while default values
+     * can be obtained by registering a delegate using
+     * {@link #setActionDelegate}.
+     *
+     * <br>
+     * See also
+     * <ul>
+     *     <li><a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction">
+     *         WebExtensions/API/browserAction
+     *     </a></li>
+     *     <li><a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction">
+     *         WebExtensions/API/pageAction
+     *     </a></li>
+     * </ul>
+     */
+    @AnyThread
+    public static class Action {
+        /**
+         * Title of this Action.
+         *
+         * See also:
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/getTitle">
+         *     pageAction/getTitle</a>,
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getTitle">
+         *     browserAction/getTitle</a>
+         */
+        final public @Nullable String title;
+        /**
+         * Icon for this Action.
+         *
+         * See also:
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/setIcon">
+         *     pageAction/setIcon</a>,
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/setIcon">
+         *     browserAction/setIcon</a>
+         */
+        final public @Nullable ActionIcon icon;
+        /**
+         * URI of the Popup to display when the user taps on the icon for this
+         * Action.
+         *
+         * See also:
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/getPopup">
+         *     pageAction/getPopup</a>,
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getPopup">
+         *     browserAction/getPopup</a>
+         */
+        final private @Nullable String mPopupUri;
+        /**
+         * Whether this action is enabled and should be visible.
+         *
+         * Note: for page action, this is <code>true</code> when the extension calls
+         * <code>pageAction.show</code> and <code>false</code> when the extension
+         * calls <code>pageAction.hide</code>.
+         *
+         * See also:
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/show">
+         *     pageAction/show</a>,
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/enabled">
+         *     browserAction/enabled</a>
+         */
+        final public @Nullable Boolean enabled;
+        /**
+         * Badge text for this action.
+         *
+         * See also:
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeText">
+         *     browserAction/getBadgeText</a>
+         */
+        final public @Nullable String badgeText;
+        /**
+         * Background color for the badge for this Action.
+         *
+         * This method will return an Android color int that can be used in
+         * {@link android.widget.TextView#setBackgroundColor(int)} and similar
+         * methods.
+         *
+         * See also:
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeBackgroundColor">
+         *     browserAction/getBadgeBackgroundColor</a>
+         */
+        final public @Nullable Integer badgeBackgroundColor;
+        /**
+         * Text color for the badge for this Action.
+         *
+         * This method will return an Android color int that can be used in
+         * {@link android.widget.TextView#setTextColor(int)} and similar
+         * methods.
+         *
+         * See also:
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeTextColor">
+         *     browserAction/getBadgeTextColor</a>
+         */
+        final public @Nullable Integer badgeTextColor;
+
+        final private WebExtension mExtension;
+
+        /* package */ final static int TYPE_BROWSER_ACTION = 1;
+        /* package */ final static int TYPE_PAGE_ACTION = 2;
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef({TYPE_BROWSER_ACTION, TYPE_PAGE_ACTION})
+        /* package */ @interface ActionType {}
+
+        /* package */ final @ActionType int type;
+
+        /* package */ Action(final @ActionType int type,
+                             final GeckoBundle bundle, final WebExtension extension) {
+            mExtension = extension;
+            mPopupUri = bundle.getString("popup");
+
+            this.type = type;
+
+            title = bundle.getString("title");
+            badgeText = bundle.getString("badgeText");
+            badgeBackgroundColor = colorFromRgbaArray(
+                    bundle.getDoubleArray("badgeBackgroundColor"));
+            badgeTextColor = colorFromRgbaArray(
+                    bundle.getDoubleArray("badgeTextColor"));
+
+            if (bundle.containsKey("icon")) {
+                icon = new ActionIcon(bundle.getBundle("icon"));
+            } else {
+                icon = null;
+            }
+
+            if (bundle.getBoolean("patternMatching", false)) {
+                // This action was enabled by pattern matching
+                enabled = true;
+            } else if (bundle.containsKey("enabled")) {
+                enabled = bundle.getBoolean("enabled");
+            } else {
+                enabled = null;
+            }
+        }
+
+        private Integer colorFromRgbaArray(final double[] c) {
+            if (c == null) {
+                return null;
+            }
+
+            return Color.argb((int) c[3], (int) c[0], (int) c[1], (int) c[2]);
+        }
+
+        @Override
+        public String toString() {
+            return "Action {\n"
+                    + "\ttitle: " + this.title + ",\n"
+                    + "\ticon: " + this.icon + ",\n"
+                    + "\tpopupUri: " + this.mPopupUri + ",\n"
+                    + "\tenabled: " + this.enabled + ",\n"
+                    + "\tbadgeText: " + this.badgeText + ",\n"
+                    + "\tbadgeTextColor: " + this.badgeTextColor + ",\n"
+                    + "\tbadgeBackgroundColor: " + this.badgeBackgroundColor + ",\n"
+                    + "}";
+        }
+
+        // For testing
+        protected Action() {
+            type = TYPE_BROWSER_ACTION;
+            mExtension = null;
+            mPopupUri = null;
+            title = null;
+            icon = null;
+            enabled = null;
+            badgeText = null;
+            badgeTextColor = null;
+            badgeBackgroundColor = null;
+        }
+
+        /**
+         * Merges values from this Action with the default Action.
+         *
+         * @param defaultValue the default Action as received from
+         *                     {@link ActionDelegate#onBrowserAction}
+         *                     or {@link ActionDelegate#onPageAction}.
+         *
+         * @return an {@link Action} where all <code>null</code> values from
+         *         this instance are replaced with values from
+         *         <code>defaultValue</code>.
+         * @throws IllegalArgumentException if defaultValue is not of the same
+         *         type, e.g. if this Action is a Page Action and default
+         *         value is a Browser Action.
+         */
+        @NonNull
+        public Action withDefault(final @NonNull Action defaultValue) {
+            return new Action(this, defaultValue);
+        }
+
+        /** @see Action#withDefault */
+        private Action(final Action source, final Action defaultValue) {
+            if (source.type != defaultValue.type) {
+                throw new IllegalArgumentException(
+                        "defaultValue must be of the same type.");
+            }
+
+            type = source.type;
+            mExtension = source.mExtension;
+
+            title = source.title != null ? source.title : defaultValue.title;
+            icon = source.icon != null ? source.icon : defaultValue.icon;
+            mPopupUri = source.mPopupUri != null ? source.mPopupUri : defaultValue.mPopupUri;
+            enabled = source.enabled != null  ? source.enabled : defaultValue.enabled;
+            badgeText = source.badgeText != null ? source.badgeText : defaultValue.badgeText;
+            badgeTextColor = source.badgeTextColor != null
+                    ? source.badgeTextColor : defaultValue.badgeTextColor;
+            badgeBackgroundColor = source.badgeBackgroundColor != null
+                    ? source.badgeBackgroundColor : defaultValue.badgeBackgroundColor;
+        }
+
+        @UiThread
+        public void click() {
+            if (mPopupUri != null && !mPopupUri.isEmpty()) {
+                if (mExtension.actionDelegate == null) {
+                    return;
+                }
+
+                GeckoResult<GeckoSession> popup =
+                        mExtension.actionDelegate.onTogglePopup(mExtension, this);
+                openPopup(popup);
+
+                // When popupUri is specified, the extension doesn't get a callback
+                return;
+            }
+
+            final GeckoBundle bundle = new GeckoBundle(1);
+            bundle.putString("extensionId", mExtension.id);
+
+            if (type == TYPE_BROWSER_ACTION) {
+                EventDispatcher.getInstance().dispatch(
+                        "GeckoView:BrowserAction:Click", bundle);
+            } else if (type == TYPE_PAGE_ACTION) {
+                EventDispatcher.getInstance().dispatch(
+                        "GeckoView:PageAction:Click", bundle);
+            } else {
+                throw new IllegalStateException("Unknown Action type");
+            }
+        }
+
+        /* package */ void openPopup(final GeckoResult<GeckoSession> popup) {
+            if (popup == null) {
+                return;
+            }
+
+            popup.accept(session -> {
+                if (session == null) {
+                    return;
+                }
+
+                session.getSettings().setIsPopup(true);
+                session.loadUri(mPopupUri);
+            });
+        }
+    }
+
+    /**
+     * Receives updates whenever a Browser action or a Page action has been
+     * defined by an extension.
+     *
+     * This delegate will receive the default action when registered with
+     * {@link WebExtension#setActionDelegate}. To receive
+     * {@link GeckoSession}-specific overrides you can use
+     * {@link GeckoSession#setWebExtensionActionDelegate}.
+     */
+    public interface ActionDelegate {
+        /**
+         * Called whenever a browser action is defined or updated.
+         *
+         * This method will be called whenever an extension that defines a
+         * browser action is registered or the properties of the Action are
+         * updated.
+         *
+         * See also <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction">
+         *  WebExtensions/API/browserAction
+         * </a>,
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_action">
+         *    WebExtensions/manifest.json/browser_action
+         * </a>.
+         *
+         * @param extension The extension that defined this browser action.
+         * @param session Either the {@link GeckoSession} corresponding to the
+         *                tab to which this Action override applies.
+         *                <code>null</code> if <code>action</code> is the new
+         *                default value.
+         * @param action {@link Action} containing the override values for this
+         *               {@link GeckoSession} or the default value if
+         *               <code>session</code> is <code>null</code>.
+         */
+        @UiThread
+        default void onBrowserAction(final @NonNull WebExtension extension,
+                                     final @Nullable GeckoSession session,
+                                     final @NonNull Action action) {}
+        /**
+         * Called whenever a page action is defined or updated.
+         *
+         * This method will be called whenever an extension that defines a page
+         * action is registered or the properties of the Action are updated.
+         *
+         * See also <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction">
+         *  WebExtensions/API/pageAction
+         * </a>,
+         * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/page_action">
+         *    WebExtensions/manifest.json/page_action
+         * </a>.
+         *
+         * @param extension The extension that defined this page action.
+         * @param session Either the {@link GeckoSession} corresponding to the
+         *                tab to which this Action override applies.
+         *                <code>null</code> if <code>action</code> is the new
+         *                default value.
+         * @param action {@link Action} containing the override values for this
+         *               {@link GeckoSession} or the default value if
+         *               <code>session</code> is <code>null</code>.
+         */
+        @UiThread
+        default void onPageAction(final @NonNull WebExtension extension,
+                                  final @Nullable GeckoSession session,
+                                  final @NonNull Action action) {}
+
+        /**
+         * Called whenever the action wants to toggle a popup view.
+         *
+         * @param extension The extension that wants to display a popup
+         * @param action The action where the popup is defined
+         * @return A GeckoSession that will be used to display the pop-up,
+         *         null if no popup will be displayed.
+         */
+        @UiThread
+        @Nullable
+        default GeckoResult<GeckoSession> onTogglePopup(final @NonNull WebExtension extension,
+                                                        final @NonNull Action action) {
+            return null;
+        }
+
+        /**
+         * Called whenever the action wants to open a popup view.
+         *
+         * @param extension The extension that wants to display a popup
+         * @param action The action where the popup is defined
+         * @return A GeckoSession that will be used to display the pop-up,
+         *         null if no popup will be displayed.
+         */
+        @UiThread
+        @Nullable
+        default GeckoResult<GeckoSession> onOpenPopup(final @NonNull WebExtension extension,
+                                                      final @NonNull Action action) {
+            return null;
+        }
+    }
+
+    /**
+     * Set the Action delegate for this WebExtension.
+     *
+     * This delegate will receive updates every time the default Action value
+     * changes.
+     *
+     * To listen for {@link GeckoSession}-specific updates, use
+     * {@link GeckoSession#setWebExtensionActionDelegate}
+     *
+     * @param delegate {@link ActionDelegate} that will receive updates.
+     */
+    @AnyThread
+    public void setActionDelegate(final @Nullable ActionDelegate delegate) {
+        actionDelegate = delegate;
+    }
 }
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionEventDispatcher.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionEventDispatcher.java
@@ -24,17 +24,23 @@ import java.util.Map;
 
     public void registerWebExtension(final WebExtension webExtension) {
         if (!mHandlerRegistered) {
             EventDispatcher.getInstance().registerUiThreadListener(
                     this,
                     "GeckoView:WebExtension:Message",
                     "GeckoView:WebExtension:PortMessage",
                     "GeckoView:WebExtension:Connect",
-                    "GeckoView:WebExtension:Disconnect"
+                    "GeckoView:WebExtension:Disconnect",
+
+                    // {Browser,Page}Actions
+                    "GeckoView:BrowserAction:Update",
+                    "GeckoView:BrowserAction:OpenPopup",
+                    "GeckoView:PageAction:Update",
+                    "GeckoView:PageAction:OpenPopup"
             );
             mHandlerRegistered = true;
         }
 
         mExtensions.put(webExtension.id, webExtension);
     }
 
     public void unregisterWebExtension(final WebExtension webExtension) {
@@ -226,24 +232,101 @@ import java.util.Map;
             return;
         }
 
         response.accept(
             value -> callback.sendSuccess(value),
             exception -> callback.sendError(exception));
     }
 
+    private WebExtension extensionFromBundle(final GeckoBundle message) {
+        final String extensionId = message.getString("extensionId");
+
+        final WebExtension extension = mExtensions.get(extensionId);
+        if (extension == null) {
+            if (BuildConfig.DEBUG) {
+                // TODO: Bug 1582185 Some gecko tests install WebExtensions that we
+                // don't know about and cause this to trigger.
+                // throw new RuntimeException("Could not find extension: " + extensionId);
+            }
+            Log.e(LOGTAG, "Could not find extension: " + extensionId);
+        }
+
+        return extension;
+    }
+
+    private void openPopup(final GeckoBundle message, final GeckoSession session,
+                           final @WebExtension.Action.ActionType int actionType) {
+        final WebExtension extension = extensionFromBundle(message);
+        if (extension == null) {
+            return;
+        }
+
+        final WebExtension.Action action = new WebExtension.Action(
+                actionType, message.getBundle("action"), extension);
+
+        final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, session);
+        if (delegate == null) {
+            return;
+        }
+
+        final GeckoResult<GeckoSession> popup = delegate.onOpenPopup(extension, action);
+        action.openPopup(popup);
+    }
+
+    private WebExtension.ActionDelegate actionDelegateFor(final WebExtension extension,
+                                                          final GeckoSession session) {
+        if (session == null) {
+            return extension.actionDelegate;
+        }
+
+        return session.getWebExtensionActionDelegate(extension);
+    }
+
+    private void actionUpdate(final GeckoBundle message, final GeckoSession session,
+                              final @WebExtension.Action.ActionType int actionType) {
+        final WebExtension extension = extensionFromBundle(message);
+        if (extension == null) {
+            return;
+        }
+
+        final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, session);
+        if (delegate == null) {
+            return;
+        }
+
+        final WebExtension.Action action = new WebExtension.Action(
+                actionType, message.getBundle("action"), extension);
+        if (actionType == WebExtension.Action.TYPE_BROWSER_ACTION) {
+            delegate.onBrowserAction(extension, session, action);
+        } else if (actionType == WebExtension.Action.TYPE_PAGE_ACTION) {
+            delegate.onPageAction(extension, session, action);
+        }
+    }
+
     public void handleMessage(final String event, final GeckoBundle message,
                               final EventCallback callback, final GeckoSession session) {
         if ("GeckoView:WebExtension:Disconnect".equals(event)) {
             disconnect(message.getLong("portId", -1), callback);
             return;
         } else if ("GeckoView:WebExtension:PortMessage".equals(event)) {
             portMessage(message, callback);
             return;
+        } else if ("GeckoView:BrowserAction:Update".equals(event)) {
+            actionUpdate(message, session, WebExtension.Action.TYPE_BROWSER_ACTION);
+            return;
+        } else if ("GeckoView:PageAction:Update".equals(event)) {
+            actionUpdate(message, session, WebExtension.Action.TYPE_PAGE_ACTION);
+            return;
+        } else if ("GeckoView:BrowserAction:OpenPopup".equals(event)) {
+            openPopup(message, session, WebExtension.Action.TYPE_BROWSER_ACTION);
+            return;
+        } else if ("GeckoView:PageAction:OpenPopup".equals(event)) {
+            openPopup(message, session, WebExtension.Action.TYPE_PAGE_ACTION);
+            return;
         }
 
         final String nativeApp = message.getString("nativeApp");
         if (nativeApp == null) {
             if (BuildConfig.DEBUG) {
                 throw new RuntimeException("Missing required nativeApp message parameter.");
             }
             callback.sendError("Missing nativeApp parameter.");
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -35,30 +35,34 @@ exclude: true
   ([bug 1581161]({{bugzilla}}1581161))
 - Changed [`BasicSelectionActionDelegate.clearSelection`][72.10] to public.
   ([bug 1581161]({{bugzilla}}1581161))
 - Added `Autofill` commit support.
   ([bug 1577005]({{bugzilla}}1577005))
 - Added [`GeckoView.setViewBackend`][72.11] to set whether GeckoView should be
   backed by a [`TextureView`][72.12] or a [`SurfaceView`][72.13].
   ([bug 1530402]({{bugzilla}}1530402))
+- Added support for Browser and Page Action from the WebExtension API.
+  See [`WebExtension.Action`][72.14].
+  ([bug 1530402]({{bugzilla}}1530402))
 
 [72.1]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture-
 [72.2]: {{javadoc_uri}}/Autofill.html
 [72.3]: {{javadoc_uri}}/WebResponse.html#body
 [72.4]: {{javadoc_uri}}/WebResponse.html#setReadTimeoutMillis-long-
 [72.5]: {{javadoc_uri}}/WebResponse.html#DEFAULT_READ_TIMEOUT_MS
 [72.6]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowActionRequest-org.mozilla.geckoview.GeckoSession-org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection-
 [72.7]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowActionRequest-org.mozilla.geckoview.GeckoSession-org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection-
 [72.8]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html
 [72.9]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#getSelection-
 [72.10]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#clearSelection-
 [72.11]: {{javadoc_uri}}/GeckoView.html#setViewBackend-int-
 [72.12]: https://developer.android.com/reference/android/view/TextureView
 [72.13]: https://developer.android.com/reference/android/view/SurfaceView
+[72.14]: {{javadoc_uri}}/WebExtension.Action.html
 
 ## v71
 - Added a content blocking flag for blocked social cookies to [`ContentBlocking`][70.17].
   ([bug 1584479]({{bugzilla}}1584479))
 - Added [`onBooleanScalar`][71.1], [`onLongScalar`][71.2],
   [`onStringScalar`][71.3] to [`RuntimeTelemetry.Delegate`][70.12] to support
   scalars in streaming telemetry. ⚠️  As part of this change,
   `onTelemetryReceived` has been renamed to [`onHistogram`][71.4], and
@@ -446,9 +450,9 @@ exclude: true
 [65.19]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isRedirect
 [65.20]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_BYPASS_CLASSIFIER    
 [65.21]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html
 [65.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onContextMenu-org.mozilla.geckoview.GeckoSession-int-int-org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement-
 [65.23]: {{javadoc_uri}}/GeckoSession.FinderResult.html
 [65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String-
 [65.25]: {{javadoc_uri}}/GeckoResult.html
 
-[api-version]: cff8d49f3436c4b3b5ae91f96f333b8a5d55ab96
+[api-version]: d4fbf3825322768a22d225f79c659bfd36eebbc6
--- a/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm
+++ b/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm
@@ -1,32 +1,102 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-var EXPORTED_SYMBOLS = ["GeckoViewConnection", "GeckoViewWebExtension"];
+var EXPORTED_SYMBOLS = [
+  "ExtensionActionHelper",
+  "GeckoViewConnection",
+  "GeckoViewWebExtension",
+];
 
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 const { GeckoViewUtils } = ChromeUtils.import(
   "resource://gre/modules/GeckoViewUtils.jsm"
 );
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   EventDispatcher: "resource://gre/modules/Messaging.jsm",
   Extension: "resource://gre/modules/Extension.jsm",
   ExtensionChild: "resource://gre/modules/ExtensionChild.jsm",
+  GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.jsm",
 });
 
 const { debug, warn } = GeckoViewUtils.initLogging("Console"); // eslint-disable-line no-unused-vars
 
+/** Provides common logic between page and browser actions */
+class ExtensionActionHelper {
+  constructor({
+    tabTracker,
+    windowTracker,
+    tabContext,
+    properties,
+    extension,
+  }) {
+    this.tabTracker = tabTracker;
+    this.windowTracker = windowTracker;
+    this.tabContext = tabContext;
+    this.properties = properties;
+    this.extension = extension;
+  }
+
+  getTab(aTabId) {
+    if (aTabId !== null) {
+      return this.tabTracker.getTab(aTabId);
+    }
+    return null;
+  }
+
+  getWindow(aWindowId) {
+    if (aWindowId !== null) {
+      return this.windowTracker.getWindow(aWindowId);
+    }
+    return null;
+  }
+
+  extractProperties(aAction) {
+    const merged = {};
+    for (const p of this.properties) {
+      merged[p] = aAction[p];
+    }
+    return merged;
+  }
+
+  eventDispatcherFor(aTabId) {
+    if (!aTabId) {
+      return EventDispatcher.instance;
+    }
+
+    const windowId = GeckoViewTabBridge.tabIdToWindowId(aTabId);
+    const window = this.windowTracker.getWindow(windowId);
+    return window.WindowEventDispatcher;
+  }
+
+  sendRequestForResult(aTabId, aData) {
+    return this.eventDispatcherFor(aTabId).sendRequestForResult({
+      ...aData,
+      aTabId,
+      extensionId: this.extension.id,
+    });
+  }
+
+  sendRequest(aTabId, aData) {
+    return this.eventDispatcherFor(aTabId).sendRequest({
+      ...aData,
+      aTabId,
+      extensionId: this.extension.id,
+    });
+  }
+}
+
 class EmbedderPort extends ExtensionChild.Port {
   constructor(...args) {
     super(...args);
     EventDispatcher.instance.registerListener(this, [
       "GeckoView:WebExtension:PortMessageFromApp",
       "GeckoView:WebExtension:PortDisconnect",
     ]);
   }
@@ -192,20 +262,57 @@ var GeckoViewWebExtension = {
       await scope.shutdown();
       this.extensionScopes.delete(aId);
       aCallback.onSuccess();
     } catch (ex) {
       aCallback.onError(`Error unregistering WebExtension ${aId}. ${ex}`);
     }
   },
 
+  extensionById(aId) {
+    const scope = this.extensionScopes.get(aId);
+    if (!scope) {
+      return null;
+    }
+
+    return scope.extension;
+  },
+
   onEvent(aEvent, aData, aCallback) {
     debug`onEvent ${aEvent} ${aData}`;
 
     switch (aEvent) {
+      case "GeckoView:BrowserAction:Click": {
+        const extension = this.extensionById(aData.extensionId);
+        if (!extension) {
+          return;
+        }
+
+        const browserAction = this.browserActions.get(extension);
+        if (!browserAction) {
+          return;
+        }
+
+        browserAction.click();
+        break;
+      }
+      case "GeckoView:PageAction:Click": {
+        const extension = this.extensionById(aData.extensionId);
+        if (!extension) {
+          return;
+        }
+
+        const pageAction = this.pageActions.get(extension);
+        if (!pageAction) {
+          return;
+        }
+
+        pageAction.click();
+        break;
+      }
       case "GeckoView:RegisterWebExtension": {
         const uri = Services.io.newURI(aData.locationUri);
         if (
           uri == null ||
           (!(uri instanceof Ci.nsIFileURL) && !(uri instanceof Ci.nsIJARURI))
         ) {
           aCallback.onError(
             `Extension does not point to a resource URI or a file URL. extension=${
@@ -255,8 +362,12 @@ var GeckoViewWebExtension = {
 
         this.unregisterWebExtension(aData.id, aCallback);
         break;
     }
   },
 };
 
 GeckoViewWebExtension.extensionScopes = new Map();
+// WeakMap[Extension -> BrowserAction]
+GeckoViewWebExtension.browserActions = new WeakMap();
+// WeakMap[Extension -> PageAction]
+GeckoViewWebExtension.pageActions = new WeakMap();
rename from browser/components/extensions/schemas/browser_action.json
rename to toolkit/components/extensions/schemas/browser_action.json
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -1,16 +1,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/.
 
 toolkit.jar:
 % content extensions %content/extensions/
     content/extensions/schemas/activity_log.json
     content/extensions/schemas/alarms.json
+    content/extensions/schemas/browser_action.json
     content/extensions/schemas/browser_settings.json
 #ifndef ANDROID
     content/extensions/schemas/captive_portal.json
 #endif
     content/extensions/schemas/clipboard.json
     content/extensions/schemas/content_scripts.json
     content/extensions/schemas/contextual_identities.json
     content/extensions/schemas/cookies.json
@@ -29,16 +30,17 @@ toolkit.jar:
     content/extensions/schemas/identity.json
 #endif
     content/extensions/schemas/idle.json
     content/extensions/schemas/management.json
     content/extensions/schemas/manifest.json
     content/extensions/schemas/native_manifest.json
     content/extensions/schemas/network_status.json
     content/extensions/schemas/notifications.json
+    content/extensions/schemas/page_action.json
     content/extensions/schemas/permissions.json
     content/extensions/schemas/proxy.json
     content/extensions/schemas/privacy.json
     content/extensions/schemas/runtime.json
     content/extensions/schemas/storage.json
     content/extensions/schemas/telemetry.json
     content/extensions/schemas/test.json
     content/extensions/schemas/theme.json
rename from browser/components/extensions/schemas/page_action.json
rename to toolkit/components/extensions/schemas/page_action.json