Bug 1343678 - Add WebPush support to GeckoView r=jcj,lina,agi,geckoview-reviewers,droeh,mt
☠☠ backed out by 6390dc77aaf0 ☠ ☠
authorJames Willcox <snorp@snorp.net>
Thu, 29 Aug 2019 18:59:31 +0000
changeset 554445 85526faefe6d798eda96b9672be73a9ebd50819a
parent 554444 07b757a212228b0d4edb4af6c949398c2436a2fd
child 554446 7396789341b14d8fd1cc12ad0ec30c0180a5dfae
push id2165
push userffxbld-merge
push dateMon, 14 Oct 2019 16:30:58 +0000
treeherdermozilla-release@0eae18af659f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjcj, lina, agi, geckoview-reviewers, droeh, mt
bugs1343678
milestone70.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 1343678 - Add WebPush support to GeckoView r=jcj,lina,agi,geckoview-reviewers,droeh,mt Differential Revision: https://phabricator.services.mozilla.com/D41290
mobile/android/components/geckoview/GeckoView.manifest
mobile/android/components/geckoview/GeckoViewPush.js
mobile/android/components/geckoview/GeckoViewStartup.js
mobile/android/components/geckoview/moz.build
mobile/android/geckoview/api.txt
mobile/android/geckoview/src/androidTest/assets/www/push/push.html
mobile/android/geckoview/src/androidTest/assets/www/push/push.js
mobile/android/geckoview/src/androidTest/assets/www/push/sw.js
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
mobile/android/installer/package-manifest.in
mobile/android/modules/geckoview/GeckoViewPushController.jsm
mobile/android/modules/geckoview/moz.build
widget/android/Base64UtilsSupport.h
widget/android/nsAppShell.cpp
--- a/mobile/android/components/geckoview/GeckoView.manifest
+++ b/mobile/android/components/geckoview/GeckoView.manifest
@@ -18,8 +18,12 @@ contract @mozilla.org/prompter;1 {076ac1
 component {aa0dd6fc-73dd-4621-8385-c0b377e02cee} GeckoViewPrompt.js process=main
 contract @mozilla.org/colorpicker;1 {aa0dd6fc-73dd-4621-8385-c0b377e02cee} process=main
 component {e4565e36-f101-4bf5-950b-4be0887785a9} GeckoViewPrompt.js process=main
 contract @mozilla.org/filepicker;1 {e4565e36-f101-4bf5-950b-4be0887785a9} process=main
 
 # GeckoViewExternalAppService.js
 component {a89eeec6-6608-42ee-a4f8-04d425992f45} GeckoViewExternalAppService.js
 contract @mozilla.org/uriloader/external-helper-app-service;1 {a89eeec6-6608-42ee-a4f8-04d425992f45}
+
+# GeckoViewPush.js
+component {a54d84d7-98a4-4fec-b664-e42e512ae9cc} GeckoViewPush.js
+contract @mozilla.org/push/Service;1 {a54d84d7-98a4-4fec-b664-e42e512ae9cc}
new file mode 100644
--- /dev/null
+++ b/mobile/android/components/geckoview/GeckoViewPush.js
@@ -0,0 +1,253 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { GeckoViewUtils } = ChromeUtils.import(
+  "resource://gre/modules/GeckoViewUtils.jsm"
+);
+
+const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPush"); // eslint-disable-line no-unused-vars
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "EventDispatcher",
+  "resource://gre/modules/Messaging.jsm"
+);
+
+// Observer notification topics for push messages and subscription status
+// changes. These are duplicated and used in `nsIPushNotifier`. They're exposed
+// on `nsIPushService` so that JS callers only need to import this service.
+const OBSERVER_TOPIC_PUSH = "push-message";
+const OBSERVER_TOPIC_SUBSCRIPTION_CHANGE = "push-subscription-change";
+const OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED = "push-subscription-modified";
+
+function createSubscription({
+  scope,
+  principal,
+  browserPublicKey,
+  authSecret,
+  endpoint,
+  appServerKey,
+}) {
+  const decodedBrowserKey = ChromeUtils.base64URLDecode(browserPublicKey, {
+    padding: "ignore",
+  });
+  const decodedAuthSecret = ChromeUtils.base64URLDecode(authSecret, {
+    padding: "ignore",
+  });
+
+  return new PushSubscription({
+    endpoint: endpoint,
+    scope,
+    p256dhKey: decodedBrowserKey,
+    authenticationSecret: decodedAuthSecret,
+    appServerKey,
+  });
+}
+
+function scopeWithAttrs(scope, attrs) {
+  return scope + ChromeUtils.originAttributesToSuffix(attrs);
+}
+
+function PushService() {
+  this.wrappedJSObject = this;
+}
+
+PushService.prototype = {
+  classID: Components.ID("{a54d84d7-98a4-4fec-b664-e42e512ae9cc}"),
+  contractID: "@mozilla.org/push/Service;1",
+  QueryInterface: ChromeUtils.generateQI([
+    Ci.nsIObserver,
+    Ci.nsISupportsWeakReference,
+    Ci.nsIPushService,
+    Ci.nsIPushQuotaManager,
+    Ci.nsIPushErrorReporter,
+  ]),
+
+  pushTopic: OBSERVER_TOPIC_PUSH,
+  subscriptionChangeTopic: OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
+  subscriptionModifiedTopic: OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED,
+
+  // nsIObserver methods
+
+  observe(subject, topic, data) {},
+
+  // nsIPushService methods
+
+  subscribe(scope, principal, callback) {
+    this.subscribeWithKey(scope, principal, null, callback);
+  },
+
+  async subscribeWithKey(scope, principal, appServerKey, callback) {
+    try {
+      const response = await EventDispatcher.instance.sendRequestForResult({
+        type: "GeckoView:PushSubscribe",
+        scope: scopeWithAttrs(scope, principal.originAttributes),
+        appServerKey: appServerKey
+          ? ChromeUtils.base64URLEncode(new Uint8Array(appServerKey), {
+              pad: true,
+            })
+          : null,
+      });
+
+      let subscription = null;
+      if (response) {
+        subscription = createSubscription({
+          ...response,
+          scope,
+          principal,
+          appServerKey,
+        });
+      }
+
+      callback.onPushSubscription(Cr.NS_OK, subscription);
+    } catch (e) {
+      callback.onPushSubscription(Cr.NS_ERROR_FAILURE, null);
+    }
+  },
+
+  async unsubscribe(scope, principal, callback) {
+    try {
+      await EventDispatcher.instance.sendRequestForResult({
+        type: "GeckoView:PushUnsubscribe",
+        scope: scopeWithAttrs(scope, principal.originAttributes),
+      });
+
+      callback.onUnsubscribe(Cr.NS_OK, true);
+    } catch (e) {
+      callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
+    }
+  },
+
+  async getSubscription(scope, principal, callback) {
+    try {
+      const response = await EventDispatcher.instance.sendRequestForResult({
+        type: "GeckoView:PushGetSubscription",
+        scope: scopeWithAttrs(scope, principal.originAttributes),
+      });
+
+      let subscription = null;
+      if (response) {
+        subscription = createSubscription({
+          ...response,
+          scope,
+          principal,
+        });
+      }
+
+      callback.onPushSubscription(Cr.NS_OK, subscription);
+    } catch (e) {
+      callback.onPushSubscription(Cr.NS_ERROR_FAILURE, null);
+    }
+  },
+
+  clearForDomain(domain, callback) {
+    callback.onClear(Cr.NS_OK);
+  },
+
+  // nsIPushQuotaManager methods
+
+  notificationForOriginShown(origin) {},
+
+  notificationForOriginClosed(origin) {},
+
+  // nsIPushErrorReporter methods
+
+  reportDeliveryError(messageId, reason) {},
+};
+
+/** `PushSubscription` instances are passed to all subscription callbacks. */
+function PushSubscription(props) {
+  this._props = props;
+}
+
+PushSubscription.prototype = {
+  QueryInterface: ChromeUtils.generateQI([Ci.nsIPushSubscription]),
+
+  /** The URL for sending messages to this subscription. */
+  get endpoint() {
+    return this._props.endpoint;
+  },
+
+  /** The last time a message was sent to this subscription. */
+  get lastPush() {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  /** The total number of messages sent to this subscription. */
+  get pushCount() {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  /**
+   * The app will take care of throttling, so we don't
+   * care about the quota stuff here.
+   */
+  get quota() {
+    return -1;
+  },
+
+  /**
+   * Indicates whether this subscription was created with the system principal.
+   * System subscriptions are exempt from the background message quota and
+   * permission checks.
+   */
+  get isSystemSubscription() {
+    return false;
+  },
+
+  /** The private key used to decrypt incoming push messages, in JWK format */
+  get p256dhPrivateKey() {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  },
+
+  /**
+   * Indicates whether this subscription is subject to the background message
+   * quota.
+   */
+  quotaApplies() {
+    return false;
+  },
+
+  /**
+   * Indicates whether this subscription exceeded the background message quota,
+   * or the user revoked the notification permission. The caller must request a
+   * new subscription to continue receiving push messages.
+   */
+  isExpired() {
+    return false;
+  },
+
+  /**
+   * Returns a key for encrypting messages sent to this subscription. JS
+   * callers receive the key buffer as a return value, while C++ callers
+   * receive the key size and buffer as out parameters.
+   */
+  getKey(name) {
+    switch (name) {
+      case "p256dh":
+        return this._getRawKey(this._props.p256dhKey);
+
+      case "auth":
+        return this._getRawKey(this._props.authenticationSecret);
+
+      case "appServer":
+        return this._getRawKey(this._props.appServerKey);
+    }
+    return [];
+  },
+
+  _getRawKey(key) {
+    if (!key) {
+      return [];
+    }
+    return new Uint8Array(key);
+  },
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([PushService]);
--- a/mobile/android/components/geckoview/GeckoViewStartup.js
+++ b/mobile/android/components/geckoview/GeckoViewStartup.js
@@ -67,16 +67,21 @@ GeckoViewStartup.prototype = {
           module: "resource://gre/modules/GeckoViewStorageController.jsm",
           ged: [
             "GeckoView:ClearData",
             "GeckoView:ClearSessionContextData",
             "GeckoView:ClearHostData",
           ],
         });
 
+        GeckoViewUtils.addLazyGetter(this, "GeckoViewPushController", {
+          module: "resource://gre/modules/GeckoViewPushController.jsm",
+          ged: ["GeckoView:PushEvent", "GeckoView:PushSubscriptionChanged"],
+        });
+
         GeckoViewUtils.addLazyPrefObserver(
           {
             name: "geckoview.console.enabled",
             default: false,
           },
           {
             handler: _ => this.GeckoViewConsole,
           }
--- a/mobile/android/components/geckoview/moz.build
+++ b/mobile/android/components/geckoview/moz.build
@@ -19,12 +19,13 @@ if CONFIG['MOZ_ANDROID_HISTORY']:
     ]
     include('/ipc/chromium/chromium-config.mozbuild')
 
 EXTRA_COMPONENTS += [
     'GeckoView.manifest',
     'GeckoViewExternalAppService.js',
     'GeckoViewPermission.js',
     'GeckoViewPrompt.js',
+    'GeckoViewPush.js',
     'GeckoViewStartup.js',
 ]
 
 FINAL_LIBRARY = 'xul'
--- a/mobile/android/geckoview/api.txt
+++ b/mobile/android/geckoview/api.txt
@@ -80,16 +80,19 @@ import org.mozilla.geckoview.SessionText
 import org.mozilla.geckoview.SlowScriptResponse;
 import org.mozilla.geckoview.StorageController;
 import org.mozilla.geckoview.WebExtension;
 import org.mozilla.geckoview.WebExtensionController;
 import org.mozilla.geckoview.WebExtensionEventDispatcher;
 import org.mozilla.geckoview.WebMessage;
 import org.mozilla.geckoview.WebNotification;
 import org.mozilla.geckoview.WebNotificationDelegate;
+import org.mozilla.geckoview.WebPushController;
+import org.mozilla.geckoview.WebPushDelegate;
+import org.mozilla.geckoview.WebPushSubscription;
 import org.mozilla.geckoview.WebRequest;
 import org.mozilla.geckoview.WebRequestError;
 import org.mozilla.geckoview.WebResponse;
 
 package org.mozilla.geckoview {
 
   @AnyThread public final enum AllowOrDeny {
     method public static AllowOrDeny valueOf(String);
@@ -310,16 +313,17 @@ package org.mozilla.geckoview {
     method @UiThread @NonNull public static synchronized GeckoRuntime getDefault(@NonNull Context);
     method @UiThread @Nullable public GeckoRuntime.Delegate getDelegate();
     method @UiThread @Nullable public File getProfileDir();
     method @AnyThread @NonNull public GeckoRuntimeSettings getSettings();
     method @UiThread @NonNull public StorageController getStorageController();
     method @UiThread @NonNull public RuntimeTelemetry getTelemetry();
     method @UiThread @NonNull public WebExtensionController getWebExtensionController();
     method @UiThread @Nullable public WebNotificationDelegate getWebNotificationDelegate();
+    method @UiThread @NonNull public WebPushController getWebPushController();
     method @UiThread public void orientationChanged();
     method @UiThread public void orientationChanged(int);
     method @AnyThread public void readFromParcel(@NonNull Parcel);
     method @UiThread @NonNull public GeckoResult<Void> registerWebExtension(@NonNull WebExtension);
     method @UiThread public void setDelegate(@Nullable GeckoRuntime.Delegate);
     method @UiThread public void setWebNotificationDelegate(@Nullable WebNotificationDelegate);
     method @AnyThread public void shutdown();
     method @UiThread @NonNull public GeckoResult<Void> unregisterWebExtension(@NonNull WebExtension);
@@ -1268,16 +1272,39 @@ package org.mozilla.geckoview {
     field @Nullable public final String title;
   }
 
   public interface WebNotificationDelegate {
     method @AnyThread default public void onCloseNotification(@NonNull WebNotification);
     method @AnyThread default public void onShowNotification(@NonNull WebNotification);
   }
 
+  public class WebPushController {
+    method @UiThread public void onPushEvent(@NonNull WebPushSubscription);
+    method @UiThread public void onPushEvent(@NonNull WebPushSubscription, @Nullable byte[]);
+    method @UiThread public void onSubscriptionChanged(@NonNull WebPushSubscription);
+    method @UiThread public void setDelegate(@Nullable WebPushDelegate);
+  }
+
+  public interface WebPushDelegate {
+    method @UiThread @Nullable default public GeckoResult<WebPushSubscription> onGetSubscription(@NonNull String);
+    method @UiThread @Nullable default public GeckoResult<WebPushSubscription> onSubscribe(@NonNull String, @Nullable byte[]);
+    method @UiThread @Nullable default public GeckoResult<Void> onUnsubscribe(@NonNull String);
+  }
+
+  public class WebPushSubscription implements Parcelable {
+    ctor public WebPushSubscription(@NonNull String, @NonNull String, @Nullable byte[], @NonNull byte[], @NonNull byte[]);
+    field public static final Parcelable.Creator<WebPushSubscription> CREATOR;
+    field @Nullable public final byte[] appServerKey;
+    field @NonNull public final byte[] authSecret;
+    field @NonNull public final byte[] browserPublicKey;
+    field @NonNull public final String endpoint;
+    field @NonNull public final String scope;
+  }
+
   @AnyThread public class WebRequest extends WebMessage {
     ctor public WebRequest(@NonNull String);
     field public static final int CACHE_MODE_DEFAULT = 1;
     field public static final int CACHE_MODE_FORCE_CACHE = 5;
     field public static final int CACHE_MODE_NO_CACHE = 4;
     field public static final int CACHE_MODE_NO_STORE = 2;
     field public static final int CACHE_MODE_ONLY_IF_CACHED = 6;
     field public static final int CACHE_MODE_RELOAD = 3;
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/push/push.html
@@ -0,0 +1,9 @@
+<html>
+    <head>
+        <title>Push API test</title>
+    </head>
+    <body>
+        <p>Hello, world!</p>
+        <script src="push.js"></script>
+    </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/push/push.js
@@ -0,0 +1,44 @@
+window.doSubscribe = async function(applicationServerKey) {
+  const registration = await navigator.serviceWorker.register("./sw.js");
+  const sub = await registration.pushManager.subscribe({
+    applicationServerKey,
+  });
+  return sub.toJSON();
+};
+
+window.doGetSubscription = async function() {
+  const registration = await navigator.serviceWorker.register("./sw.js");
+  const sub = await registration.pushManager.getSubscription();
+  if (sub) {
+    return sub.toJSON();
+  }
+
+  return null;
+};
+
+window.doUnsubscribe = async function() {
+  const registration = await navigator.serviceWorker.register("./sw.js");
+  const sub = await registration.pushManager.getSubscription();
+  sub.unsubscribe();
+  return {};
+};
+
+window.doWaitForPushEvent = function() {
+  return new Promise(resolve => {
+    navigator.serviceWorker.addEventListener("message", function(e) {
+      if (e.data.type === "push") {
+        resolve(e.data.payload);
+      }
+    });
+  });
+};
+
+window.doWaitForSubscriptionChange = function() {
+  return new Promise(resolve => {
+    navigator.serviceWorker.addEventListener("message", function(e) {
+      if (e.data.type === "pushsubscriptionchange") {
+        resolve(e.data.type);
+      }
+    });
+  });
+};
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/push/sw.js
@@ -0,0 +1,21 @@
+self.addEventListener("install", function() {
+  self.skipWaiting();
+});
+
+self.addEventListener("activate", function(e) {
+  e.waitUntil(self.clients.claim());
+});
+
+self.addEventListener("push", async function(e) {
+  const clients = await self.clients.matchAll();
+  clients.forEach(function(client) {
+    client.postMessage({ type: "push", payload: e.data.text() });
+  });
+});
+
+self.addEventListener("pushsubscriptionchange", async function(e) {
+  const clients = await self.clients.matchAll();
+  clients.forEach(function(client) {
+    client.postMessage({ type: "pushsubscriptionchange" });
+  });
+});
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt
@@ -54,17 +54,21 @@ open class BaseSessionTest(noErrorCollec
         const val VIEWPORT_PATH = "/assets/www/viewport.html"
         const val IFRAME_REDIRECT_LOCAL = "/assets/www/iframe_redirect_local.html"
         const val IFRAME_REDIRECT_AUTOMATION = "/assets/www/iframe_redirect_automation.html"
         const val AUTOPLAY_PATH = "/assets/www/autoplay.html"
         const val SCROLL_TEST_PATH = "/assets/www/scroll.html"
         const val COLORS_HTML_PATH = "/assets/www/colors.html"
         const val FIXED_BOTTOM = "/assets/www/fixedbottom.html"
         const val STORAGE_TITLE_HTML_PATH = "/assets/www/reflect_local_storage_into_title.html"
+<<<<<<< HEAD
         const val HUNG_SCRIPT = "/assets/www/hungScript.html"
+=======
+        const val PUSH_HTML_PATH = "/assets/www/push/push.html"
+>>>>>>> Bug 1343678 - Add WebPush support to GeckoView r?#geckoview-reviewers
     }
 
     @get:Rule val sessionRule = GeckoSessionTestRule()
 
     @get:Rule val errors = ErrorCollector()
 
     val mainSession get() = sessionRule.session
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt
@@ -0,0 +1,212 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test
+
+import android.os.Parcel
+import android.support.test.filters.MediumTest
+import android.support.test.runner.AndroidJUnit4
+import android.util.Base64
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.*
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException
+import org.mozilla.geckoview.test.util.Callbacks
+import java.math.BigInteger
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.SecureRandom
+import java.security.interfaces.ECPublicKey
+import java.security.spec.ECGenParameterSpec
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class WebPushTest : BaseSessionTest() {
+    companion object {
+        val PUSH_ENDPOINT: String = "https://test.endpoint"
+        val APP_SERVER_KEY_PAIR: KeyPair = generateKeyPair()
+        val AUTH_SECRET: ByteArray = generateAuthSecret()
+        val BROWSER_KEY_PAIR: KeyPair = generateKeyPair()
+
+        private fun generateKeyPair(): KeyPair {
+            try {
+                val spec = ECGenParameterSpec("secp256r1")
+                val generator = KeyPairGenerator.getInstance("EC")
+                generator.initialize(spec)
+                return generator.generateKeyPair()
+            } catch (e: Exception) {
+                throw RuntimeException(e)
+            }
+        }
+
+        private fun generateAuthSecret(): ByteArray {
+            val bytes = BigInteger(128, SecureRandom()).toByteArray()
+            if (bytes.size > 16) {
+                return bytes.copyOfRange(bytes.size - 16, bytes.size)
+            }
+
+            return bytes
+        }
+    }
+
+    var delegate: TestPushDelegate? = null
+
+    @Before
+    fun setup() {
+        // Grant "desktop notification" permission
+        mainSession.delegateUntilTestEnd(object : Callbacks.PermissionDelegate {
+            override fun onContentPermissionRequest(session: GeckoSession, uri: String?, type: Int, callback: GeckoSession.PermissionDelegate.Callback) {
+                assertThat("Should grant DESKTOP_NOTIFICATIONS permission", type, equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION))
+                callback.grant()
+            }
+        })
+
+        delegate = TestPushDelegate()
+
+        sessionRule.addExternalDelegateUntilTestEnd(WebPushDelegate::class,
+                { d -> sessionRule.runtime.webPushController.setDelegate(d) },
+                { sessionRule.runtime.webPushController.setDelegate(null) }, delegate!!)
+
+
+        mainSession.loadTestPath(PUSH_HTML_PATH)
+        mainSession.waitForPageStop()
+    }
+
+    @After
+    fun tearDown() {
+        sessionRule.runtime.webPushController.setDelegate(null)
+        delegate = null
+    }
+
+    private fun verifySubscription(subscription: JSONObject) {
+        assertThat("Push endpoint should match", subscription.getString("endpoint"), equalTo(PUSH_ENDPOINT))
+
+        val keys = subscription.getJSONObject("keys")
+        val authSecret = Base64.decode(keys.getString("auth"), Base64.URL_SAFE)
+        val encryptionKey = WebPushUtils.keyFromString(keys.getString("p256dh"))
+
+        assertThat("Auth secret should match", authSecret, equalTo(AUTH_SECRET))
+        assertThat("Encryption key should match", encryptionKey, equalTo(BROWSER_KEY_PAIR.public))
+    }
+
+    @Test
+    fun subscribe() {
+        // PushManager.subscribe()
+        val appServerKey = WebPushUtils.keyToString(APP_SERVER_KEY_PAIR.public as ECPublicKey)
+        var pushSubscription = mainSession.evaluatePromiseJS("window.doSubscribe(\"$appServerKey\")").value as JSONObject
+        assertThat("Should have a stored subscription", delegate!!.storedSubscription, notNullValue())
+        verifySubscription(pushSubscription)
+
+        // PushManager.getSubscription()
+        pushSubscription = mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject
+        verifySubscription(pushSubscription)
+    }
+
+    @Test
+    fun subscribeNoAppServerKey() {
+        // PushManager.subscribe()
+        var pushSubscription = mainSession.evaluatePromiseJS("window.doSubscribe()").value as JSONObject
+        assertThat("Should have a stored subscription", delegate!!.storedSubscription, notNullValue())
+        verifySubscription(pushSubscription)
+
+        // PushManager.getSubscription()
+        pushSubscription = mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject
+        verifySubscription(pushSubscription)
+    }
+
+    @Test(expected = RejectedPromiseException::class)
+    fun subscribeNullDelegate() {
+        sessionRule.runtime.webPushController.setDelegate(null)
+        mainSession.evaluatePromiseJS("window.doSubscribe()").value as JSONObject
+    }
+
+    @Test(expected = RejectedPromiseException::class)
+    fun getSubscriptionNullDelegate() {
+        sessionRule.runtime.webPushController.setDelegate(null)
+        mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject
+    }
+
+    @Test
+    fun unsubscribe() {
+        subscribe()
+
+        // PushManager.unsubscribe()
+        val unsubResult = mainSession.evaluatePromiseJS("window.doUnsubscribe()").value as JSONObject
+        assertThat("Unsubscribe result should be non-null", unsubResult, notNullValue())
+        assertThat("Should not have a stored subscription", delegate!!.storedSubscription, nullValue())
+    }
+
+    @Test
+    fun pushEvent() {
+        subscribe()
+
+        val p = mainSession.evaluatePromiseJS("window.doWaitForPushEvent()")
+
+        val testPayload = "The Payload";
+        sessionRule.runtime.webPushController.onPushEvent(delegate!!.storedSubscription!!, testPayload.toByteArray(Charsets.UTF_8))
+
+        assertThat("Push data should match", p.value as String, equalTo(testPayload))
+    }
+
+    @Test
+    fun subscriptionChanged() {
+        subscribe()
+
+        val p = mainSession.evaluatePromiseJS("window.doWaitForSubscriptionChange()")
+
+        sessionRule.runtime.webPushController.onSubscriptionChanged(delegate!!.storedSubscription!!)
+
+        assertThat("Result should not be null", p.value, notNullValue())
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun invalidDuplicateKeys() {
+        WebPushSubscription("https://scope", PUSH_ENDPOINT,
+                WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey),
+                WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey)!!, AUTH_SECRET)
+    }
+
+    @Test
+    fun parceling() {
+        val testScope = "https://test.scope";
+        val sub = WebPushSubscription(testScope, PUSH_ENDPOINT,
+                WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey),
+                WebPushUtils.keyToBytes(BROWSER_KEY_PAIR.public as ECPublicKey)!!, AUTH_SECRET)
+
+        val parcel = Parcel.obtain()
+        sub.writeToParcel(parcel, 0)
+        parcel.setDataPosition(0)
+
+        val sub2 = WebPushSubscription.CREATOR.createFromParcel(parcel)
+        assertThat("Scope should match", sub.scope, equalTo(sub2.scope))
+        assertThat("Endpoint should match", sub.endpoint, equalTo(sub2.endpoint))
+        assertThat("App server key should match", sub.appServerKey, equalTo(sub2.appServerKey))
+        assertThat("Encryption key should match", sub.browserPublicKey, equalTo(sub2.browserPublicKey))
+        assertThat("Auth secret should match", sub.authSecret, equalTo(sub2.authSecret))
+    }
+
+    class TestPushDelegate : WebPushDelegate {
+        var storedSubscription: WebPushSubscription? = null
+
+        override fun onGetSubscription(scope: String): GeckoResult<WebPushSubscription>? {
+            return GeckoResult.fromValue(storedSubscription)
+        }
+
+        override fun onUnsubscribe(scope: String): GeckoResult<Void>? {
+            storedSubscription = null
+            return GeckoResult.fromValue(null)
+        }
+
+        override fun onSubscribe(scope: String, appServerKey: ByteArray?): GeckoResult<WebPushSubscription>? {
+            appServerKey?.let { assertThat("Application server key should match", it, equalTo(WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey))) }
+            storedSubscription = WebPushSubscription(scope, PUSH_ENDPOINT, appServerKey, WebPushUtils.keyToBytes(BROWSER_KEY_PAIR.public as ECPublicKey)!!, AUTH_SECRET)
+            return GeckoResult.fromValue(storedSubscription)
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java
@@ -0,0 +1,170 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview.test;
+
+import android.support.annotation.AnyThread;
+import android.support.annotation.Nullable;
+import android.util.Base64;
+
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyFactory;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECFieldFp;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.ECPublicKeySpec;
+import java.security.spec.EllipticCurve;
+import java.security.spec.InvalidKeySpecException;
+
+/**
+ * Utilities for converting {@link ECPublicKey} to/from X9.62 encoding.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc8291">Message Encryption for Web Push</a>
+ */
+/* package */ class WebPushUtils {
+    public static final int P256_PUBLIC_KEY_LENGTH = 65; // 1 + 32 + 32
+    private static final byte NIST_HEADER = 0x04; // uncompressed format
+
+    private static ECParameterSpec sSpec;
+
+    private WebPushUtils() {
+    }
+
+    /**
+     * Encodes an {@link ECPublicKey} into X9.62 format as required
+     * by Web Push.
+     *
+     * @param key the {@link ECPublicKey} to encode
+     * @return the encoded {@link ECPublicKey}
+     */
+    @AnyThread
+    public static @Nullable byte[] keyToBytes(final @Nullable ECPublicKey key) {
+        if (key == null) {
+            return null;
+        }
+
+        final ByteBuffer buffer = ByteBuffer.allocate(P256_PUBLIC_KEY_LENGTH);
+        buffer.put(NIST_HEADER);
+
+        putUnsignedBigInteger(buffer, key.getW().getAffineX());
+        putUnsignedBigInteger(buffer, key.getW().getAffineY());
+
+        if (buffer.position() != P256_PUBLIC_KEY_LENGTH) {
+            throw new RuntimeException("Unexpected key length " + buffer.position());
+        }
+
+        return buffer.array();
+    }
+
+    private static void putUnsignedBigInteger(final ByteBuffer buffer, final BigInteger value) {
+        final byte[] bytes = value.toByteArray();
+        if (bytes.length < 32) {
+            buffer.put(new byte[32 - bytes.length]);
+            buffer.put(bytes);
+        } else {
+            buffer.put(bytes, bytes.length - 32, 32);
+        }
+    }
+
+    /**
+     * Encodes an {@link ECPublicKey} into X9.62 format as required
+     * by Web Push, further encoded into Base64.
+     *
+     * @param key the {@link ECPublicKey} to encode
+     * @return the encoded {@link ECPublicKey}
+     */
+    @AnyThread
+    public static @Nullable String keyToString(final @Nullable ECPublicKey key) {
+        return Base64.encodeToString(keyToBytes(key), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
+    }
+
+    /**
+     * @return A {@link ECParameterSpec} for P-256 (secp256r1).
+     */
+    public static ECParameterSpec getP256Spec() {
+        if (sSpec == null) {
+            try {
+                final KeyPairGenerator gen = KeyPairGenerator.getInstance("EC");
+                final ECGenParameterSpec genSpec = new ECGenParameterSpec("secp256r1");
+                gen.initialize(genSpec);
+                sSpec = ((ECPublicKey) gen.generateKeyPair().getPublic()).getParams();
+            } catch (NoSuchAlgorithmException e) {
+                throw new RuntimeException(e);
+            } catch (InvalidAlgorithmParameterException e) {
+                throw new RuntimeException(e);
+            }
+        }
+
+        return sSpec;
+    }
+
+    /**
+     * Converts a Base64 X9.62 encoded Web Push key into a {@link ECPublicKey}.
+     *
+     * @param base64Bytes the X9.62 data as Base64
+     * @return a {@link ECPublicKey}
+     */
+    @AnyThread
+    public static @Nullable ECPublicKey keyFromString(final @Nullable String base64Bytes) {
+        if (base64Bytes == null) {
+            return null;
+        }
+
+        return keyFromBytes(Base64.decode(base64Bytes, Base64.URL_SAFE));
+    }
+
+    private static BigInteger readUnsignedBigInteger(final byte[] bytes, final int offset, final int length) {
+        byte[] mag = bytes;
+        if (offset != 0 || length != bytes.length) {
+            mag = new byte[length];
+            System.arraycopy(bytes, offset, mag, 0, length);
+        }
+        return new BigInteger(1, mag);
+    }
+
+    /**
+     * Converts a X9.62 encoded Web Push key into a {@link ECPublicKey}.
+     *
+     * @param bytes the X9.62 data
+     * @return a {@link ECPublicKey}
+     */
+    @AnyThread
+    public static @Nullable ECPublicKey keyFromBytes(final @Nullable byte[] bytes) {
+        if (bytes == null) {
+            return null;
+        }
+
+        if (bytes.length != P256_PUBLIC_KEY_LENGTH) {
+            throw new IllegalArgumentException(String.format("Expected exactly %d bytes", P256_PUBLIC_KEY_LENGTH));
+        }
+
+        if (bytes[0] != NIST_HEADER) {
+            throw new IllegalArgumentException("Expected uncompressed NIST format");
+        }
+
+        try {
+            final BigInteger x = readUnsignedBigInteger(bytes, 1, 32);
+            final BigInteger y = readUnsignedBigInteger(bytes, 33, 32);
+
+            final ECPoint point = new ECPoint(x, y);
+            final ECPublicKeySpec spec = new ECPublicKeySpec(point, getP256Spec());
+            final KeyFactory factory = KeyFactory.getInstance("EC");
+            final ECPublicKey key = (ECPublicKey) factory.generatePublic(spec);
+
+            return key;
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException(e);
+        } catch (InvalidKeySpecException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
@@ -0,0 +1,14 @@
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * This class exposes the Base64 URL encode/decode functions from Gecko. They are different
+ * from android.util.Base64 in that they always use URL encoding, no padding, and are
+ * constant time. The last bit is important when dealing with values that might be secret
+ * as we do with Web Push.
+ */
+/* package */ class Base64Utils {
+    @WrapForJNI public static native byte[] decode(final String data);
+    @WrapForJNI public static native String encode(final byte[] data);
+}
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
@@ -160,16 +160,17 @@ public final class GeckoRuntime implemen
     private static GeckoRuntime sRuntime;
     private GeckoRuntimeSettings mSettings;
     private Delegate mDelegate;
     private WebNotificationDelegate mNotificationDelegate;
     private RuntimeTelemetry mTelemetry;
     private final WebExtensionEventDispatcher mWebExtensionDispatcher;
     private StorageController mStorageController;
     private final WebExtensionController mWebExtensionController;
+    private WebPushController mPushController;
 
     private GeckoRuntime() {
         mWebExtensionDispatcher = new WebExtensionEventDispatcher();
         mWebExtensionController = new WebExtensionController(this, mWebExtensionDispatcher);
         if (sRuntime != null) {
             throw new IllegalStateException("Only one GeckoRuntime instance is allowed");
         }
         sRuntime = this;
@@ -643,16 +644,34 @@ public final class GeckoRuntime implemen
         ThreadUtils.assertOnUiThread();
 
         if (mStorageController == null) {
             mStorageController = new StorageController();
         }
         return mStorageController;
     }
 
+    /**
+     * Get the Web Push controller for this runtime.
+     * The Web Push controller can be used to allow content
+     * to use the Web Push API.
+     *
+     * @return The {@link WebPushController} for this instance.
+     */
+    @UiThread
+    public @NonNull WebPushController getWebPushController() {
+        ThreadUtils.assertOnUiThread();
+
+        if (mPushController == null) {
+            mPushController = new WebPushController();
+        }
+
+        return mPushController;
+    }
+
     @Override // Parcelable
     @AnyThread
     public int describeContents() {
         return 0;
     }
 
     @Override // Parcelable
     @AnyThread
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
@@ -0,0 +1,138 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
+
+public class WebPushController {
+    private static final String LOGTAG = "WebPushController";
+
+    private WebPushDelegate mDelegate;
+    private BundleEventListener mEventListener;
+
+    /* package */ WebPushController() {
+        mEventListener = new EventListener();
+        EventDispatcher.getInstance().registerUiThreadListener(mEventListener,
+                "GeckoView:PushSubscribe",
+                "GeckoView:PushUnsubscribe",
+                "GeckoView:PushGetSubscription");
+    }
+
+    /**
+     * Sets the {@link WebPushDelegate} for this instance.
+     *
+     * @param delegate The {@link WebPushDelegate} instance.
+     */
+    @UiThread
+    public void setDelegate(final @Nullable WebPushDelegate delegate) {
+        ThreadUtils.assertOnUiThread();
+        mDelegate = delegate;
+    }
+
+    /**
+     * Send a push event for a given subscription.
+     *
+     * @param subscription The WebPushSubscription that the event belongs to.
+     */
+    @UiThread
+    public void onPushEvent(final @NonNull WebPushSubscription subscription) {
+        ThreadUtils.assertOnUiThread();
+        onPushEvent(subscription, null);
+    }
+
+    /**
+     * Send a push event with a payload for a given subscription.
+     *
+     * @param subscription The WebPushSubscription that the event belongs to.
+     * @param data The unencrypted payload.
+     */
+    @UiThread
+    public void onPushEvent(final @NonNull WebPushSubscription subscription, final @Nullable byte[] data) {
+        ThreadUtils.assertOnUiThread();
+
+        final GeckoBundle msg = new GeckoBundle(2);
+        msg.putBundle("subscription", subscription.toBundle());
+        msg.putString("data", Base64Utils.encode(data));
+        EventDispatcher.getInstance().dispatch("GeckoView:PushEvent", msg);
+    }
+
+    /**
+     * Notify that a given subscription has changed. This is normally a signal to the content
+     * that it needs to re-subscribe.
+     *
+     * @param subscription The subscription that changed.
+     */
+    @UiThread
+    public void onSubscriptionChanged(final @NonNull WebPushSubscription subscription) {
+        ThreadUtils.assertOnUiThread();
+
+        final GeckoBundle msg = new GeckoBundle(1);
+        msg.putBundle("subscription", subscription.toBundle());
+        EventDispatcher.getInstance().dispatch("GeckoView:PushSubscriptionChanged", msg);
+    }
+
+    private class EventListener implements BundleEventListener {
+
+        @Override
+        public void handleMessage(final String event, final GeckoBundle message, final EventCallback callback) {
+            if (mDelegate == null) {
+                callback.sendError("Not allowed");
+                return;
+            }
+
+            switch (event) {
+                case "GeckoView:PushSubscribe": {
+                    byte[] appServerKey = null;
+                    if (message.containsKey("appServerKey")) {
+                        appServerKey = Base64Utils.decode(message.getString("appServerKey"));
+                    }
+
+                    final GeckoResult<WebPushSubscription> result =
+                            mDelegate.onSubscribe(message.getString("scope"), appServerKey);
+
+                    if (result == null) {
+                        callback.sendSuccess(null);
+                        return;
+                    }
+
+                    result.accept(subscription -> callback.sendSuccess(subscription != null ? subscription.toBundle() : null),
+                        error -> callback.sendSuccess(null));
+                    break;
+                }
+                case "GeckoView:PushUnsubscribe": {
+                    final GeckoResult<Void> result = mDelegate.onUnsubscribe(message.getString("scope"));
+                    if (result == null) {
+                        callback.sendSuccess(null);
+                        return;
+                    }
+
+                    result.accept(val -> callback.sendSuccess(null), err -> callback.sendError(err.getMessage()));
+                    break;
+                }
+                case "GeckoView:PushGetSubscription": {
+                    final GeckoResult<WebPushSubscription> result = mDelegate.onGetSubscription(message.getString("scope"));
+                    if (result == null) {
+                        callback.sendSuccess(null);
+                        return;
+                    }
+
+                    result.accept(subscription -> callback.sendSuccess(subscription != null ? subscription.toBundle() : null),
+                        err -> callback.sendError(err.getMessage()));
+                    break;
+                }
+            }
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
@@ -0,0 +1,61 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
+
+public interface WebPushDelegate {
+    /**
+     * Creates a push subscription for the given service worker scope. A scope
+     * uniquely identifies a service worker. `appServerKey` optionally
+     * creates a restricted subscription.
+     *
+     * Applications will likely want to persist the returned {@link WebPushSubscription} in order
+     * to support {@link #onGetSubscription(String)}.
+     *
+     * @param scope The Service Worker scope.
+     * @param appServerKey An optional application server key.
+     * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription}
+     *
+     * @see <a href="http://w3c.github.io/push-api/#dom-pushmanager-subscribe">subscribe()</a>
+     * @see <a href="http://w3c.github.io/push-api/#dom-pushsubscriptionoptionsinit-applicationserverkey">Application server key</a>
+     */
+    @UiThread
+    default @Nullable GeckoResult<WebPushSubscription> onSubscribe(@NonNull String scope,
+                                                                   @Nullable byte[] appServerKey) {
+        return null;
+    }
+
+    /**
+     * Retrieves a subscription for the given service worker scope.
+     *
+     * @param scope The scope for the requested {@link WebPushSubscription}.
+     * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription}
+     *
+     * @see <a href="http://w3c.github.io/push-api/#dom-pushmanager-getsubscription">getSubscription()</a>
+     */
+    @UiThread
+    default @Nullable GeckoResult<WebPushSubscription> onGetSubscription(@NonNull String scope) {
+        return null;
+    }
+
+    /**
+     * Removes a push subscription. If this fails, apps should resolve the
+     * returned {@link GeckoResult} with an exception.
+     *
+     * @param scope The Service Worker scope for the subscription.
+     * @return A {@link GeckoResult}, which if non-exceptional indicates successfully unsubscribing.
+     *
+     * @see <a href="http://w3c.github.io/push-api/#dom-pushsubscription-unsubscribe">unsubscribe()</a>
+     */
+    @UiThread
+    default @Nullable GeckoResult<Void> onUnsubscribe(@NonNull String scope) {
+        return null;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
@@ -0,0 +1,175 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.util.GeckoBundle;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.AnyThread;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import java.util.Arrays;
+
+/**
+ * This class represents a single Web Push subscription, as described in
+ * the <a href="https://www.w3.org/TR/push-api/">Web Push API</a> specification.
+ *
+ * This is a low-level interface, allowing applications to do all of the heavy lifting
+ * themselves. It is recommended that consumers have a thorough understanding of the
+ * Web Push API, especially <a href="https://tools.ietf.org/html/rfc8291">RFC 8291</a>.
+ *
+ * Only trivial sanity checks are performed on the values held here. The application must
+ * ensure it is generating compliant keys/secrets itself.
+ */
+public class WebPushSubscription implements Parcelable {
+    private static final int P256_PUBLIC_KEY_LENGTH = 65;
+
+    /**
+     * The Service Worker scope associated with this subscription.
+     *
+     * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register">ServiceWorker registration</a>
+     */
+    @NonNull
+    public final String scope;
+
+    /**
+     * The Web Push endpoint for this subscription. This is the URL of a web service which
+     * implements the Web Push protocol.
+     *
+     * @see <a href="https://tools.ietf.org/html/rfc8030#section-5">RFC 8030</a>
+     */
+    @NonNull
+    public final String endpoint;
+
+    /**
+     * This is an optional public key provided by the application server to authenticate
+     * itself with the endpoint, formatted according to X9.62.
+     *
+     * This key is used for VAPID, the Voluntary Application Server Identification (VAPID)
+     * for Web Push, from <a href="https://tools.ietf.org/html/rfc8292">RFC 8292</a>.
+     *
+     * @see <a href="https://www.w3.org/TR/push-api/#dom-pushsubscriptionoptions-applicationserverkey">applicationServerKey</a>
+     * @see <a href="https://tools.ietf.org/html/rfc8291">Message Encryption for Web Push</a>
+     */
+    @Nullable
+    public final byte[] appServerKey;
+
+    /**
+     * The P-256 EC public key, formatted as X9.62, generated by the embedder, to be provided
+     * to the app server for message encryption.
+     *
+     * @see <a href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-p256dh">PushEncryptionKeyName - p256dh</a>
+     * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.1">RFC 8291 section 3.1</a>
+     */
+    @NonNull
+    public final byte[] browserPublicKey;
+
+    /**
+     * 16 byte secret key, generated by the embedder, to be provided to the app server for use
+     * in encrypting and authenticating messages sent to the {@link #endpoint}.
+     *
+     * @see <a href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-auth">PushEncryptionKeyName - auth</a>
+     * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.2">RFC 8291, section 3.2</a>
+     */
+    @NonNull
+    public final byte[] authSecret;
+
+    public WebPushSubscription(final @NonNull String scope,
+                               final @NonNull String endpoint,
+                               final @Nullable byte[] appServerKey,
+                               final @NonNull byte[] browserPublicKey,
+                               final @NonNull byte[] authSecret) {
+        this.scope = scope;
+        this.endpoint = endpoint;
+        this.appServerKey = appServerKey;
+        this.browserPublicKey = browserPublicKey;
+        this.authSecret = authSecret;
+
+        if (appServerKey != null) {
+            if (appServerKey.length != P256_PUBLIC_KEY_LENGTH) {
+                throw new IllegalArgumentException(String.format("appServerKey should be %d bytes", P256_PUBLIC_KEY_LENGTH));
+            }
+
+            if (Arrays.equals(appServerKey, browserPublicKey)) {
+                throw new IllegalArgumentException("appServerKey and browserPublicKey must differ");
+            }
+        }
+
+        if (browserPublicKey.length != P256_PUBLIC_KEY_LENGTH) {
+            throw new IllegalArgumentException(String.format("browserPublicKey should be %d bytes", P256_PUBLIC_KEY_LENGTH));
+        }
+
+        if (authSecret.length != 16) {
+            throw new IllegalArgumentException("authSecret must be 128 bits");
+        }
+    }
+
+    private WebPushSubscription(final Parcel in) {
+        this.scope = in.readString();
+        this.endpoint = in.readString();
+
+        if (ParcelableUtils.readBoolean(in)) {
+            this.appServerKey = new byte[P256_PUBLIC_KEY_LENGTH];
+            in.readByteArray(this.appServerKey);
+        } else {
+            appServerKey = null;
+        }
+
+        this.browserPublicKey = new byte[P256_PUBLIC_KEY_LENGTH];
+        in.readByteArray(this.browserPublicKey);
+
+        this.authSecret = new byte[16];
+        in.readByteArray(this.authSecret);
+    }
+
+    /* package */ GeckoBundle toBundle() {
+        final GeckoBundle bundle = new GeckoBundle(5);
+        bundle.putString("scope", scope);
+        bundle.putString("endpoint", endpoint);
+        if (appServerKey != null) {
+            bundle.putString("appServerKey", Base64Utils.encode(appServerKey));
+        }
+        bundle.putString("browserPublicKey", Base64Utils.encode(browserPublicKey));
+        bundle.putString("authSecret", Base64Utils.encode(authSecret));
+        return bundle;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(final Parcel out, final int flags) {
+        out.writeString(scope);
+        out.writeString(endpoint);
+
+        ParcelableUtils.writeBoolean(out, appServerKey != null);
+        if (appServerKey != null) {
+            out.writeByteArray(appServerKey);
+        }
+
+        out.writeByteArray(browserPublicKey);
+        out.writeByteArray(authSecret);
+    }
+
+    public static final Parcelable.Creator<WebPushSubscription> CREATOR = new Parcelable.Creator<WebPushSubscription>() {
+        @Override
+        @AnyThread
+        public WebPushSubscription createFromParcel(final Parcel parcel) {
+            return new WebPushSubscription(parcel);
+        }
+
+        @Override
+        @AnyThread
+        public WebPushSubscription[] newArray(final int size) {
+            return new WebPushSubscription[size];
+        }
+    };
+}
--- 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
@@ -51,16 +51,18 @@ exclude: true
 - Added [`WebExtensionController`][70.18] and [`WebExtensionController.TabDelegate`][70.19] to handle
   [`browser.tabs.create`][70.20] calls by WebExtensions.
   ([bug 1539144]({{bugzilla}}1539144))
 - Added [`onCloseTab`][70.21] to [`WebExtensionController.TabDelegate`][70.19] to handle
   [`browser.tabs.remove`][70.22] calls by WebExtensions.
   ([bug 1565782]({{bugzilla}}1565782))
 - Added onSlowScript to [`ContentDelegate`][70.23] which allows handling of slow and hung scripts.
   ([bug 1621094]({{bugzilla}}1621094))
+- Added support for Web Push via [`WebPushController`][70.24], [`WebPushDelegate`][70.25], and
+  [`WebPushSubscription`][70.26].
 
 [70.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#contextId-java.lang.String-
 [70.2]: {{javadoc_uri}}/StorageController.html#clearDataForSessionContext-java.lang.String-
 [70.3]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-java.io.File-java.io.File-java.lang.String-
 [70.4]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-java.io.File-java.util.Map-java.lang.String-
 [70.5]: {{javadoc_uri}}/GeckoView.html
 [70.6]: {{javadoc_uri}}/GeckoSession.html
 [70.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#CAPTURE_TYPE_NONE
@@ -75,16 +77,19 @@ exclude: true
 [70.16]: {{javadoc_uri}}/WebNotificationDelegate.html
 [70.17]: {{javadoc_uri}}/ContentBlocking.html
 [70.18]: {{javadoc_uri}}/WebExtensionController.html
 [70.19]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html
 [70.20]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create
 [70.21]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html#onCloseTab-org.mozilla.geckoview.WebExtension-org.mozilla.geckoview.GeckoSession-
 [70.22]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove
 [70.23]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html
+[70.24]: {{javadoc_uri}}/WebPushController.html
+[70.25]: {{javadoc_uri}}/WebPushDelegate.html
+[70.26]: {{javadoc_uri}}/WebPushSubscription.html
 
 ## v69
 - Modified behavior of ['setAutomaticFontSizeAdjustment'][69.1] so that it no 
   longer has any effect on ['setFontInflationEnabled'][69.2]
 - Add [GeckoSession.LOAD_FLAGS_FORCE_ALLOW_DATA_URI][69.14]
 - Added [`GeckoResult.accept`][69.3] for consuming a result without
   transforming it.
 - [`GeckoSession.setMessageDelegate`][69.13] callers must now specify the
@@ -318,9 +323,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]: 4199a7a889674d4402926366a7f4fb2f93de5c46
+[api-version]: 2ef7c30341c4cae55cdce87f62c19ae606435430
--- a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
+++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java
@@ -39,17 +39,19 @@ import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.drawable.Icon;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Environment;
+import android.os.Handler;
 import android.os.SystemClock;
+import android.security.keystore.KeyProperties;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.v4.app.ActivityCompat;
 import android.support.v4.app.NotificationCompat;
 import android.support.v4.app.NotificationManagerCompat;
 import android.support.v4.content.ContextCompat;
 import android.support.v7.app.ActionBar;
 import android.support.v7.app.AppCompatActivity;
@@ -60,16 +62,21 @@ import android.view.MenuItem;
 import android.view.View;
 import android.view.WindowManager;
 import android.widget.ProgressBar;
 
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECGenParameterSpec;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.Locale;
 
 public class GeckoViewActivity extends AppCompatActivity {
     private static final String LOGTAG = "GeckoViewActivity";
--- a/mobile/android/installer/package-manifest.in
+++ b/mobile/android/installer/package-manifest.in
@@ -236,16 +236,17 @@
 [mobile]
 @BINPATH@/chrome/geckoview@JAREXT@
 @BINPATH@/chrome/geckoview.manifest
 
 #ifdef MOZ_GECKOVIEW_JAR
 @BINPATH@/components/GeckoView.manifest
 @BINPATH@/components/GeckoViewExternalAppService.js
 @BINPATH@/components/GeckoViewPrompt.js
+@BINPATH@/components/GeckoViewPush.js
 @BINPATH@/components/GeckoViewPermission.js
 @BINPATH@/components/GeckoViewStartup.js
 #else
 @BINPATH@/chrome/chrome@JAREXT@
 @BINPATH@/chrome/chrome.manifest
 @BINPATH@/components/AboutRedirector.js
 @BINPATH@/components/AddonUpdateService.js
 @BINPATH@/components/BlocklistPrompt.js
new file mode 100644
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewPushController.jsm
@@ -0,0 +1,77 @@
+/* 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 = ["GeckoViewPushController"];
+
+const { GeckoViewUtils } = ChromeUtils.import(
+  "resource://gre/modules/GeckoViewUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+  this,
+  "PushNotifier",
+  "@mozilla.org/push/Notifier;1",
+  "nsIPushNotifier"
+);
+
+// eslint-disable-next-line no-unused-vars
+const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPushController");
+
+function createScopeAndPrincipal(scopeAndAttrs) {
+  const [scope, attrs] = scopeAndAttrs.split("^");
+  const uri = Services.io.newURI(scope);
+
+  return [
+    scope,
+    Services.scriptSecurityManager.createContentPrincipal(
+      uri,
+      ChromeUtils.createOriginAttributesFromOrigin(attrs)
+    ),
+  ];
+}
+
+const GeckoViewPushController = {
+  onEvent(aEvent, aData, aCallback) {
+    debug`onEvent ${aEvent} ${aData}`;
+
+    switch (aEvent) {
+      case "GeckoView:PushEvent": {
+        const {
+          subscription: { scope },
+          data,
+        } = aData;
+
+        const [url, principal] = createScopeAndPrincipal(scope);
+
+        if (!data) {
+          PushNotifier.notifyPush(url, principal);
+          return;
+        }
+
+        const payload = new Uint8Array(
+          ChromeUtils.base64URLDecode(data, { padding: "ignore" })
+        );
+
+        PushNotifier.notifyPushWithData(url, principal, "", payload);
+        break;
+      }
+      case "GeckoView:PushSubscriptionChanged": {
+        const {
+          subscription: { scope },
+        } = aData;
+
+        const [url, principal] = createScopeAndPrincipal(scope);
+
+        PushNotifier.notifySubscriptionChange(url, principal);
+        break;
+      }
+    }
+  },
+};
--- a/mobile/android/modules/geckoview/moz.build
+++ b/mobile/android/modules/geckoview/moz.build
@@ -13,16 +13,17 @@ EXTRA_JS_MODULES += [
     'GeckoViewChildModule.jsm',
     'GeckoViewConsole.jsm',
     'GeckoViewContent.jsm',
     'GeckoViewMedia.jsm',
     'GeckoViewModule.jsm',
     'GeckoViewNavigation.jsm',
     'GeckoViewProcessHangMonitor.jsm',
     'GeckoViewProgress.jsm',
+    'GeckoViewPushController.jsm',
     'GeckoViewRemoteDebugger.jsm',
     'GeckoViewSettings.jsm',
     'GeckoViewStorageController.jsm',
     'GeckoViewTab.jsm',
     'GeckoViewTelemetry.jsm',
     'GeckoViewUtils.jsm',
     'GeckoViewWebExtension.jsm',
     'LoadURIDelegate.jsm',
new file mode 100644
--- /dev/null
+++ b/widget/android/Base64UtilsSupport.h
@@ -0,0 +1,44 @@
+/* -*- Mode: c++; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef Base64UtilsSupport_h__
+#define Base64UtilsSupport_h__
+
+#include "GeneratedJNINatives.h"
+#include "mozilla/Base64.h"
+
+namespace mozilla {
+namespace widget {
+
+class Base64UtilsSupport final
+    : public java::Base64Utils::Natives<Base64UtilsSupport> {
+ public:
+  static jni::ByteArray::LocalRef Decode(jni::String::Param data) {
+    FallibleTArray<uint8_t> bytes;
+    if (NS_FAILED(Base64URLDecode(
+            data->ToCString(), Base64URLDecodePaddingPolicy::Ignore, bytes))) {
+      return nullptr;
+    }
+
+    return jni::ByteArray::New((const signed char*)bytes.Elements(),
+                               bytes.Length());
+  }
+
+  static jni::String::LocalRef Encode(jni::ByteArray::Param data) {
+    nsTArray<int8_t> bytes = data->GetElements();
+    nsCString result;
+    if (NS_FAILED(
+            Base64URLEncode(data->Length(), (const uint8_t*)bytes.Elements(),
+                            Base64URLEncodePaddingPolicy::Omit, result))) {
+      return nullptr;
+    }
+    return jni::StringParam(result);
+  }
+};
+
+}  // namespace widget
+}  // namespace mozilla
+
+#endif  // Base64UtilsSupport_h__
--- a/widget/android/nsAppShell.cpp
+++ b/widget/android/nsAppShell.cpp
@@ -67,16 +67,17 @@
 #include "GeckoTelemetryDelegate.h"
 #include "GeckoVRManager.h"
 #include "PrefsHelper.h"
 #include "ScreenHelperAndroid.h"
 #include "Telemetry.h"
 #include "fennec/MemoryMonitor.h"
 #include "fennec/ThumbnailHelper.h"
 #include "WebExecutorSupport.h"
+#include "Base64UtilsSupport.h"
 
 #ifdef DEBUG_ANDROID_EVENTS
 #  define EVLOG(args...) ALOG(args)
 #else
 #  define EVLOG(args...) \
     do {                 \
     } while (0)
 #endif
@@ -407,16 +408,17 @@ nsAppShell::nsAppShell()
     mozilla::GeckoBatteryManager::Init();
     mozilla::GeckoNetworkManager::Init();
     mozilla::GeckoProcessManager::Init();
     mozilla::GeckoScreenOrientation::Init();
     mozilla::GeckoSystemStateListener::Init();
     mozilla::PrefsHelper::Init();
     mozilla::widget::Telemetry::Init();
     mozilla::widget::WebExecutorSupport::Init();
+    mozilla::widget::Base64UtilsSupport::Init();
     nsWindow::InitNatives();
     mozilla::gl::AndroidSurfaceTexture::Init();
     mozilla::WebAuthnTokenManager::Init();
     mozilla::widget::GeckoTelemetryDelegate::Init();
 
     if (jni::IsFennec()) {
       BrowserLocaleManagerSupport::Init();
       mozilla::ANRReporter::Init();