Bug 1518841 - Allow embedders to load WebExtensions in GeckoView. r=snorp,esawin
authorAgi Sferro <agi@mozilla.com>
Mon, 25 Feb 2019 17:00:18 +0000
changeset 518824 2969ec6e0436c2c1107161afdba66fc60b115784
parent 518823 7733f36c6e38cf684cf612e81f6b89cad4df3566
child 518825 deda484b0623a4ab81833df97e01ddc6bafc967a
push id10862
push userffxbld-merge
push dateMon, 11 Mar 2019 13:01:11 +0000
treeherdermozilla-beta@a2e7f5c935da [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssnorp, esawin
bugs1518841
milestone67.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 1518841 - Allow embedders to load WebExtensions in GeckoView. r=snorp,esawin Depends On D16913 Differential Revision: https://phabricator.services.mozilla.com/D16268
mobile/android/components/geckoview/GeckoViewStartup.js
mobile/android/geckoview/api.txt
mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/borderify.js
mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/border-48.png
mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/manifest.json
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
mobile/android/geckoview_example/build.gradle
mobile/android/modules/geckoview/GeckoViewWebExtension.jsm
mobile/android/modules/geckoview/moz.build
--- a/mobile/android/components/geckoview/GeckoViewStartup.js
+++ b/mobile/android/components/geckoview/GeckoViewStartup.js
@@ -42,16 +42,23 @@ GeckoViewStartup.prototype = {
             "GeckoView:AddCameraPermission",
           ],
         });
 
         GeckoViewUtils.addLazyGetter(this, "GeckoViewConsole", {
           module: "resource://gre/modules/GeckoViewConsole.jsm",
         });
 
+        GeckoViewUtils.addLazyGetter(this, "GeckoViewWebExtension", {
+          module: "resource://gre/modules/GeckoViewWebExtension.jsm",
+          ged: [
+            "GeckoView:RegisterWebExtension",
+          ],
+        });
+
         GeckoViewUtils.addLazyPrefObserver({
           name: "geckoview.console.enabled",
           default: false,
         }, {
           handler: _ => this.GeckoViewConsole,
         });
 
         // Handle invalid form submission. If we don't hook up to this,
--- a/mobile/android/geckoview/api.txt
+++ b/mobile/android/geckoview/api.txt
@@ -186,16 +186,17 @@ package org.mozilla.geckoview {
     method @android.support.annotation.UiThread @android.support.annotation.NonNull public static synchronized org.mozilla.geckoview.GeckoRuntime getDefault(@android.support.annotation.NonNull android.content.Context);
     method @android.support.annotation.UiThread @android.support.annotation.Nullable public org.mozilla.geckoview.GeckoRuntime.Delegate getDelegate();
     method @android.support.annotation.UiThread @android.support.annotation.Nullable public java.io.File getProfileDir();
     method @android.support.annotation.AnyThread @android.support.annotation.NonNull public org.mozilla.geckoview.GeckoRuntimeSettings getSettings();
     method @android.support.annotation.UiThread @android.support.annotation.NonNull public org.mozilla.geckoview.RuntimeTelemetry getTelemetry();
     method @android.support.annotation.UiThread public void orientationChanged();
     method @android.support.annotation.UiThread public void orientationChanged(int);
     method @android.support.annotation.AnyThread public void readFromParcel(@android.support.annotation.NonNull android.os.Parcel);
+    method @android.support.annotation.UiThread @android.support.annotation.NonNull public org.mozilla.geckoview.GeckoResult<java.lang.Void> registerWebExtension(@android.support.annotation.NonNull org.mozilla.geckoview.WebExtension);
     method @android.support.annotation.UiThread public void setDelegate(@android.support.annotation.Nullable org.mozilla.geckoview.GeckoRuntime.Delegate);
     method @android.support.annotation.AnyThread public void shutdown();
     field public static final java.lang.String ACTION_CRASHED = "org.mozilla.gecko.ACTION_CRASHED";
     field public static final android.os.Parcelable.Creator<org.mozilla.geckoview.GeckoRuntime> CREATOR;
     field public static final java.lang.String EXTRA_CRASH_FATAL = "fatal";
     field public static final java.lang.String EXTRA_EXTRAS_PATH = "extrasPath";
     field public static final java.lang.String EXTRA_MINIDUMP_PATH = "minidumpPath";
     field public static final java.lang.String EXTRA_MINIDUMP_SUCCESS = "minidumpSuccess";
@@ -913,16 +914,23 @@ package org.mozilla.geckoview {
     method @android.support.annotation.UiThread public boolean onKeyMultiple(int, int, @android.support.annotation.NonNull android.view.KeyEvent);
     method @android.support.annotation.UiThread public boolean onKeyPreIme(int, @android.support.annotation.NonNull android.view.KeyEvent);
     method @android.support.annotation.UiThread public boolean onKeyUp(int, @android.support.annotation.NonNull android.view.KeyEvent);
     method @android.support.annotation.UiThread public void onProvideAutofillVirtualStructure(@android.support.annotation.NonNull android.view.ViewStructure, int);
     method @android.support.annotation.UiThread public void setDelegate(@android.support.annotation.Nullable org.mozilla.geckoview.GeckoSession.TextInputDelegate);
     method @android.support.annotation.UiThread public synchronized void setView(@android.support.annotation.Nullable android.view.View);
   }
 
+  public class WebExtension {
+    ctor public WebExtension(@android.support.annotation.NonNull java.lang.String, @android.support.annotation.NonNull java.lang.String);
+    ctor public WebExtension(@android.support.annotation.NonNull java.lang.String);
+    field @android.support.annotation.NonNull public final java.lang.String id;
+    field @android.support.annotation.NonNull public final java.lang.String location;
+  }
+
   @android.support.annotation.AnyThread public abstract class WebMessage {
     ctor protected WebMessage(@android.support.annotation.NonNull org.mozilla.geckoview.WebMessage.Builder);
     field @android.support.annotation.NonNull public final java.util.Map<java.lang.String, java.lang.String> headers;
     field @android.support.annotation.NonNull public final java.lang.String uri;
   }
 
   @android.support.annotation.AnyThread public abstract static class WebMessage.Builder {
     method @android.support.annotation.NonNull public org.mozilla.geckoview.WebMessage.Builder addHeader(@android.support.annotation.NonNull java.lang.String, @android.support.annotation.NonNull java.lang.String);
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/borderify.js
@@ -0,0 +1,1 @@
+document.body.style.border = "5px solid red";
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/borderify/manifest.json
@@ -0,0 +1,15 @@
+{
+  "manifest_version": 2,
+  "name": "Borderify",
+  "version": "1.0",
+  "description": "Adds a red border to all webpages matching example.com.",
+  "icons": {
+    "48": "icons/border-48.png"
+  },
+  "content_scripts": [
+    {
+      "matches": ["*://*.example.com/*"],
+      "js": ["borderify.js"]
+    }
+  ]
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt
@@ -0,0 +1,84 @@
+/* -*- 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 org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ReuseSession
+
+import android.support.test.filters.MediumTest
+import android.support.test.runner.AndroidJUnit4
+import org.hamcrest.core.IsEqual.equalTo
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDevToolsAPI
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+@ReuseSession(false)
+class WebExtensionTest : BaseSessionTest() {
+    @Test
+    @WithDevToolsAPI
+    fun registerWebExtension() {
+        mainSession.loadUri("example.com")
+        sessionRule.waitForPageStop()
+
+        // First let's check that the color of the border is empty before loading
+        // the WebExtension
+        val colorBefore = sessionRule.evaluateJS(mainSession, "document.body.style.borderColor")
+        assertThat("The border color should be empty when loading without extensions.",
+                colorBefore as String, equalTo(""))
+
+        // Load the WebExtension that will add a border to the body
+        sessionRule.waitForResult(sessionRule.runtime.registerWebExtension(
+                WebExtension("resource://android/assets/web_extensions/borderify/")
+        ))
+
+        mainSession.reload()
+        sessionRule.waitForPageStop()
+
+        // Check that the WebExtension was applied by checking the border color
+        val color = sessionRule.evaluateJS(mainSession, "document.body.style.borderColor")
+        assertThat("Content script should have been applied",
+                color as String, equalTo("red"))
+    }
+
+    @Test
+    fun badFileType() {
+        testRegisterError("resource://android/bad/location/error",
+                "does not point to a folder or an .xpi")
+    }
+
+    @Test
+    fun badLocationXpi() {
+        testRegisterError("resource://android/bad/location/error.xpi",
+                "NS_ERROR_FILE_NOT_FOUND")
+    }
+
+    @Test
+    fun badLocationFolder() {
+        testRegisterError("resource://android/bad/location/error/",
+                "NS_ERROR_FILE_NOT_FOUND")
+    }
+
+    private fun testRegisterError(location: String, expectedError: String) {
+        try {
+            sessionRule.waitForResult(sessionRule.runtime.registerWebExtension(
+                    WebExtension(location)
+            ))
+        } catch (ex: Exception) {
+            // Let's make sure the error message contains the WebExtension URL
+            assertTrue(ex.message!!.contains(location))
+
+            // and it contains the expected error message
+            assertTrue(ex.message!!.contains(expectedError))
+
+            return
+        }
+
+        fail("The above code should throw.")
+    }
+}
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
@@ -260,16 +260,64 @@ public final class GeckoRuntime implemen
      */
     @UiThread
     public static @NonNull GeckoRuntime create(final @NonNull Context context) {
         ThreadUtils.assertOnUiThread();
         return create(context, new GeckoRuntimeSettings());
     }
 
     /**
+     * Register a {@link WebExtension} that will be run with this GeckoRuntime.
+     *
+     * <p>At this time, WebExtensions don't have access to any UI element and
+     * cannot communicate with the application. Any UI element will be
+     * ignored.</p>
+     *
+     * Example:
+     * <pre><code>
+     *     runtime.registerWebExtension(new WebExtension(
+     *              "resource://android/assets/web_extensions/my_webextension/"));
+     *
+     *     runtime.registerWebExtension(new WebExtension(
+     *              "file:///path/to/web_extension/my_webextension2.xpi",
+     *              "mywebextension2@example.com"));
+     * </code></pre>
+     *
+     * To learn more about WebExtensions refer to
+     * <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions">
+     *    Mozilla/Add-ons/WebExtensions
+     * </a>.
+     *
+     * @param webExtension {@link WebExtension} to register
+     *
+     * @return A {@link GeckoResult} that will complete when the WebExtension
+     * has been installed.
+     */
+    @UiThread
+    public @NonNull GeckoResult<Void> registerWebExtension(
+            final @NonNull WebExtension webExtension) {
+        final GeckoSession.CallbackResult<Void> result =
+                new GeckoSession.CallbackResult<Void>() {
+                    @Override
+                    public void sendSuccess(Object response) {
+                        complete(null);
+                    }
+                };
+
+        final GeckoBundle bundle = new GeckoBundle(1);
+        bundle.putString("locationUri", webExtension.location.toString());
+        bundle.putString("id", webExtension.id);
+
+        EventDispatcher.getInstance().dispatch("GeckoView:RegisterWebExtension",
+                bundle, result);
+
+        return result;
+    }
+
+    /**
      * Create a new runtime with the given settings and attach it to the given
      * context.
      *
      * Create will throw if there is already an active Gecko instance running,
      * to prevent that, bind the runtime to the process lifetime instead of the
      * activity lifetime.
      *
      * @param context The context of the runtime.
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
@@ -0,0 +1,74 @@
+package org.mozilla.geckoview;
+
+import android.support.annotation.NonNull;
+
+import java.util.UUID;
+
+/**
+ * Represents a WebExtension that may be used by GeckoView.
+ */
+public class WebExtension {
+    /**
+     * <code>file:</code> or <code>resource:</code> URI that points to the
+     * install location of this WebExtension. When the WebExtension is included
+     * with the APK the file can be specified using the
+     * <code>resource://android</code> alias. E.g.
+     *
+     * <pre><code>
+     *      resource://android/assets/web_extensions/my_webextension/
+     * </code></pre>
+     *
+     * Will point to folder
+     * <code>/assets/web_extensions/my_webextension/</code> in the APK.
+     */
+    public final @NonNull String location;
+    /**
+     * Unique identifier for this WebExtension
+     */
+    public final @NonNull String id;
+
+    /**
+     * Builds a WebExtension instance that can be loaded in GeckoView using
+     * {@link GeckoRuntime#registerWebExtension}
+     *
+     * @param location The WebExtension install location. It must be either a
+     *                 <code>resource:</code> URI to a folder inside the APK or
+     *                 a <code>file:</code> URL to a <code>.xpi</code> file.
+     * @param id Unique identifier for this WebExtension. This identifier must
+     *           either be a GUID or a string formatted like an email address.
+     *           E.g. <pre><code>
+     *              "extensionname@example.org"
+     *              "{daf44bf7-a45e-4450-979c-91cf07434c3d}"
+     *           </code></pre>
+     *
+     *           See also: <ul>
+     *           <li><a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings">
+     *                  WebExtensions/manifest.json/browser_specific_settings
+     *               </a>
+     *           <li><a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/WebExtensions_and_the_Add-on_ID#When_do_you_need_an_add-on_ID">
+     *                  WebExtensions/WebExtensions_and_the_Add-on_ID
+     *               </a>
+     *           </ul>
+     */
+    public WebExtension(final @NonNull String location, final @NonNull String id) {
+        this.location = location;
+        this.id = id;
+    }
+
+    /**
+     * Builds a WebExtension instance that can be loaded in GeckoView using
+     * {@link GeckoRuntime#registerWebExtension}
+     * The <code>id</code> for this web extension will be automatically
+     * generated.
+     *
+     * @param location The WebExtension install location. It must be either a
+     *                 <code>resource:</code> URI to a folder inside the APK or
+     *                 a <code>file:</code> URL to a <code>.xpi</code> file.
+     */
+    public WebExtension(final @NonNull String location) {
+        this.location = location;
+        this.id = "{" + UUID.randomUUID().toString() + "}";
+    }
+
+    // TODO (Bug 1518843) add messaging support
+}
--- 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
@@ -60,16 +60,21 @@ exclude: true
 - Changed `WebResponse.body` from a `ByteBuffer` to an `InputStream`. Apps that want access
   to the entire response body will now need to read the stream themselves.
 
 - Added `GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS`, which will cause `GeckoWebExecutor.fetch()` to not
   automatically follow HTTP redirects (e.g., 302).
 
 [67.1]: ../GeckoSession.html#getDefaultUserAgent--
 
+- Initial WebExtension support. [`GeckoRuntime#registerWebExtension`][67.15]
+  allows embedders to register a local web extension.
+
+[67.15]: ../GeckoRuntime.html#registerWebExtension-org.mozilla.geckoview.WebExtension-
+
 ## v66
 - Removed redundant field `trackingMode` from [`SecurityInformation`][66.6].
   Use `TrackingProtectionDelegate.onTrackerBlocked` for notification of blocked
   elements during page load.
 
 [66.6]: ../GeckoSession.ProgressDelegate.SecurityInformation.html
 
 - Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all APIs.
@@ -179,9 +184,9 @@ exclude: true
 [65.23]: ../GeckoSession.FinderResult.html
 
 - Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a
   [`GeckoResult<String>`][65.25].
 
 [65.24]: ../CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String-
 [65.25]: ../GeckoResult.html
 
-[api-version]: a1740e5cb61e34b3180b80f33b0b33243a34d588
+[api-version]: b26e5e12a78512a9c18d1ba3441864ca66d3dde8
--- a/mobile/android/geckoview_example/build.gradle
+++ b/mobile/android/geckoview_example/build.gradle
@@ -24,16 +24,25 @@ android {
 
     buildTypes {
         release {
             minifyEnabled false
             proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
         }
     }
 
+    // By default the android plugins ignores folders that start with `_`, but
+    // we need those in web extensions.
+    // See also:
+    //  - https://issuetracker.google.com/issues/36911326
+    //  - https://stackoverflow.com/questions/9206117/how-to-workaround-autoomitting-fiiles-folders-starting-with-underscore-in
+    aaptOptions {
+        ignoreAssetsPattern  '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
+    }
+
     project.configureProductFlavors.delegate = it
     project.configureProductFlavors()
 }
 
 dependencies {
     implementation "com.android.support:support-annotations:$support_library_version"
     implementation "com.android.support:appcompat-v7:$support_library_version"
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm
@@ -0,0 +1,74 @@
+/* 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 = ["GeckoViewWebExtension"];
+
+const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+const {GeckoViewUtils} = ChromeUtils.import("resource://gre/modules/GeckoViewUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  Extension: "resource://gre/modules/Extension.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "require", () => {
+  const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm", {});
+  return require;
+});
+
+XPCOMUtils.defineLazyGetter(this, "Services", () => {
+  const Services = require("Services");
+  return Services;
+});
+
+const {debug, warn} = GeckoViewUtils.initLogging("Console"); // eslint-disable-line no-unused-vars
+
+var GeckoViewWebExtension = {
+  async registerWebExtension(aId, aUri, aCallback) {
+    const params = {
+      id: aId,
+      resourceURI: aUri,
+      temporarilyInstalled: true,
+      builtIn: true,
+    };
+
+    let file;
+    if (aUri instanceof Ci.nsIFileURL) {
+      file = aUri.file;
+    }
+
+    try {
+      await Extension.getBootstrapScope(aId, file)
+          .startup(params, undefined);
+    } catch (ex) {
+      aCallback.onError(`Error registering WebExtension at: ${aUri.spec}. ${ex}`);
+      return;
+    }
+
+    aCallback.onSuccess();
+  },
+
+  onEvent(aEvent, aData, aCallback) {
+    debug `onEvent ${aEvent} ${aData}`;
+
+    switch (aEvent) {
+      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=${aData.locationUri}`);
+          return;
+        }
+
+        if (uri.fileName != "" && uri.fileExtension != "xpi") {
+          aCallback.onError(`Extension does not point to a folder or an .xpi file. Hint: the path needs to end with a "/" to be considered a folder. extension=${aData.locationUri}`);
+          return;
+        }
+
+        this.registerWebExtension(aData.id, uri, aCallback);
+      }
+    }
+  },
+};
--- a/mobile/android/modules/geckoview/moz.build
+++ b/mobile/android/modules/geckoview/moz.build
@@ -18,11 +18,12 @@ EXTRA_JS_MODULES += [
     'GeckoViewModule.jsm',
     'GeckoViewNavigation.jsm',
     'GeckoViewProgress.jsm',
     'GeckoViewRemoteDebugger.jsm',
     'GeckoViewSettings.jsm',
     'GeckoViewTab.jsm',
     'GeckoViewTelemetry.jsm',
     'GeckoViewUtils.jsm',
+    'GeckoViewWebExtension.jsm',
     'LoadURIDelegate.jsm',
     'Messaging.jsm',
 ]