summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java')
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java1752
1 files changed, 1752 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
new file mode 100644
index 0000000000..7e936f84f7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
@@ -0,0 +1,1752 @@
+/* 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.annotation.SuppressLint;
+import android.util.Log;
+import android.util.SparseArray;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import org.json.JSONException;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.MultiMap;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.WebExtension.InstallException;
+
+public class WebExtensionController {
+ private static final String LOGTAG = "WebExtension";
+
+ private AddonManagerDelegate mAddonManagerDelegate;
+ private ExtensionProcessDelegate mExtensionProcessDelegate;
+ private DebuggerDelegate mDebuggerDelegate;
+ private PromptDelegate mPromptDelegate;
+ private final WebExtension.Listener<WebExtension.TabDelegate> mListener;
+
+ // Map [ (extensionId, nativeApp, session) -> message ]
+ private final MultiMap<MessageRecipient, Message> mPendingMessages;
+ private final MultiMap<String, Message> mPendingNewTab;
+ private final MultiMap<String, Message> mPendingBrowsingData;
+ private final MultiMap<String, Message> mPendingDownload;
+
+ private final SparseArray<WebExtension.Download> mDownloads;
+
+ private static class Message {
+ final GeckoBundle bundle;
+ final EventCallback callback;
+ final String event;
+ final GeckoSession session;
+
+ public Message(
+ final String event,
+ final GeckoBundle bundle,
+ final EventCallback callback,
+ final GeckoSession session) {
+ this.bundle = bundle;
+ this.callback = callback;
+ this.event = event;
+ this.session = session;
+ }
+ }
+
+ private static class ExtensionStore {
+ private final Map<String, WebExtension> mData = new HashMap<>();
+ private Observer mObserver;
+
+ interface Observer {
+ /**
+ * * This event is fired every time a new extension object is created by the store.
+ *
+ * @param extension the newly-created extension object
+ */
+ WebExtension onNewExtension(final GeckoBundle extension);
+ }
+
+ public GeckoResult<WebExtension> get(final String id) {
+ final WebExtension extension = mData.get(id);
+ if (extension != null) {
+ return GeckoResult.fromValue(extension);
+ }
+
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Get", bundle)
+ .map(
+ extensionBundle -> {
+ final WebExtension ext = mObserver.onNewExtension(extensionBundle);
+ mData.put(ext.id, ext);
+ return ext;
+ });
+ }
+
+ public void setObserver(final Observer observer) {
+ mObserver = observer;
+ }
+
+ public void remove(final String id) {
+ mData.remove(id);
+ }
+
+ /**
+ * Add this extension to the store and update it's current value if it's already present.
+ *
+ * @param id the {@link WebExtension} id.
+ * @param extension the {@link WebExtension} to add to the store.
+ */
+ public void update(final String id, final WebExtension extension) {
+ mData.put(id, extension);
+ }
+ }
+
+ private ExtensionStore mExtensions = new ExtensionStore();
+
+ private Internals mInternals = new Internals();
+
+ // Avoids exposing listeners to the API
+ private class Internals implements BundleEventListener, ExtensionStore.Observer {
+ @Override
+ // BundleEventListener
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ WebExtensionController.this.handleMessage(event, message, callback, null);
+ }
+
+ @Override
+ public WebExtension onNewExtension(final GeckoBundle bundle) {
+ return WebExtension.fromBundle(mDelegateControllerProvider, bundle);
+ }
+ }
+
+ /* package */ void releasePendingMessages(
+ final WebExtension extension, final String nativeApp, final GeckoSession session) {
+ Log.i(
+ LOGTAG,
+ "releasePendingMessages:"
+ + " extension="
+ + extension.id
+ + " nativeApp="
+ + nativeApp
+ + " session="
+ + session);
+ final List<Message> messages =
+ mPendingMessages.remove(new MessageRecipient(nativeApp, extension.id, session));
+ if (messages == null) {
+ return;
+ }
+
+ for (final Message message : messages) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+ }
+
+ private class DelegateController implements WebExtension.DelegateController {
+ private final WebExtension mExtension;
+
+ public DelegateController(final WebExtension extension) {
+ mExtension = extension;
+ }
+
+ @Override
+ public void onMessageDelegate(
+ final String nativeApp, final WebExtension.MessageDelegate delegate) {
+ mListener.setMessageDelegate(mExtension, delegate, nativeApp);
+ }
+
+ @Override
+ public void onActionDelegate(final WebExtension.ActionDelegate delegate) {
+ mListener.setActionDelegate(mExtension, delegate);
+ }
+
+ @Override
+ public WebExtension.ActionDelegate getActionDelegate() {
+ return mListener.getActionDelegate(mExtension);
+ }
+
+ @Override
+ public void onBrowsingDataDelegate(final WebExtension.BrowsingDataDelegate delegate) {
+ mListener.setBrowsingDataDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingBrowsingData.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingBrowsingData.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.BrowsingDataDelegate getBrowsingDataDelegate() {
+ return mListener.getBrowsingDataDelegate(mExtension);
+ }
+
+ @Override
+ public void onTabDelegate(final WebExtension.TabDelegate delegate) {
+ mListener.setTabDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingNewTab.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingNewTab.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.TabDelegate getTabDelegate() {
+ return mListener.getTabDelegate(mExtension);
+ }
+
+ @Override
+ public void onDownloadDelegate(final WebExtension.DownloadDelegate delegate) {
+ mListener.setDownloadDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingDownload.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingDownload.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.DownloadDelegate getDownloadDelegate() {
+ return mListener.getDownloadDelegate(mExtension);
+ }
+ }
+
+ final WebExtension.DelegateControllerProvider mDelegateControllerProvider =
+ new WebExtension.DelegateControllerProvider() {
+ @Override
+ public WebExtension.DelegateController controllerFor(final WebExtension extension) {
+ return new DelegateController(extension);
+ }
+ };
+
+ /**
+ * This delegate will be called whenever an extension is about to be installed or it needs new
+ * permissions, e.g during an update or because it called <code>permissions.request</code>
+ */
+ @UiThread
+ public interface PromptDelegate {
+ /**
+ * Called whenever a new extension is being installed. This is intended as an opportunity for
+ * the app to prompt the user for the permissions required by this extension.
+ *
+ * @param extension The {@link WebExtension} that is about to be installed. You can use {@link
+ * WebExtension#metaData} to gather information about this extension when building the user
+ * prompt dialog.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if
+ * this extension should be installed or {@link AllowOrDeny#DENY DENY} if this extension
+ * should not be installed. A null value will be interpreted as {@link AllowOrDeny#DENY
+ * DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onInstallPrompt(final @NonNull WebExtension extension) {
+ return null;
+ }
+
+ /**
+ * Called whenever an updated extension has new permissions. This is intended as an opportunity
+ * for the app to prompt the user for the new permissions required by this extension.
+ *
+ * @param currentlyInstalled The {@link WebExtension} that is currently installed.
+ * @param updatedExtension The {@link WebExtension} that will replace the previous extension.
+ * @param newPermissions The new permissions that are needed.
+ * @param newOrigins The new origins that are needed.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if
+ * this extension should be update or {@link AllowOrDeny#DENY DENY} if this extension should
+ * not be update. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onUpdatePrompt(
+ @NonNull final WebExtension currentlyInstalled,
+ @NonNull final WebExtension updatedExtension,
+ @NonNull final String[] newPermissions,
+ @NonNull final String[] newOrigins) {
+ return null;
+ }
+
+ /**
+ * Called whenever permissions are requested. This is intended as an opportunity for the app to
+ * prompt the user for the permissions required by this extension at runtime.
+ *
+ * @param extension The {@link WebExtension} that is about to be installed. You can use {@link
+ * WebExtension#metaData} to gather information about this extension when building the user
+ * prompt dialog.
+ * @param permissions The permissions that are requested.
+ * @param origins The requested host permissions.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if the
+ * request should be approved or {@link AllowOrDeny#DENY DENY} if the request should be
+ * denied. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onOptionalPrompt(
+ final @NonNull WebExtension extension,
+ final @NonNull String[] permissions,
+ final @NonNull String[] origins) {
+ return null;
+ }
+ }
+
+ public interface DebuggerDelegate {
+ /**
+ * Called whenever the list of installed extensions has been modified using the debugger with
+ * tools like web-ext.
+ *
+ * <p>This is intended as an opportunity to refresh the list of installed extensions using
+ * {@link WebExtensionController#list} and to set delegates on the new {@link WebExtension}
+ * objects, e.g. using {@link WebExtension#setActionDelegate} and {@link
+ * WebExtension#setMessageDelegate}.
+ *
+ * @see <a
+ * href="https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext">
+ * Getting started with web-ext</a>
+ */
+ @UiThread
+ default void onExtensionListUpdated() {}
+ }
+
+ /** This delegate will be called whenever the state of an extension has changed. */
+ public interface AddonManagerDelegate {
+ /**
+ * Called whenever an extension is being disabled.
+ *
+ * @param extension The {@link WebExtension} that is being disabled.
+ */
+ @UiThread
+ default void onDisabling(@NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension has been disabled.
+ *
+ * @param extension The {@link WebExtension} that is being disabled.
+ */
+ @UiThread
+ default void onDisabled(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension is being enabled.
+ *
+ * @param extension The {@link WebExtension} that is being enabled.
+ */
+ @UiThread
+ default void onEnabling(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension has been enabled.
+ *
+ * @param extension The {@link WebExtension} that is being enabled.
+ */
+ @UiThread
+ default void onEnabled(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension is being uninstalled.
+ *
+ * @param extension The {@link WebExtension} that is being uninstalled.
+ */
+ @UiThread
+ default void onUninstalling(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension has been uninstalled.
+ *
+ * @param extension The {@link WebExtension} that is being uninstalled.
+ */
+ @UiThread
+ default void onUninstalled(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension is being installed.
+ *
+ * @param extension The {@link WebExtension} that is being installed.
+ */
+ @UiThread
+ default void onInstalling(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension has been installed.
+ *
+ * @param extension The {@link WebExtension} that is being installed.
+ */
+ @UiThread
+ default void onInstalled(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an error happened when installing a WebExtension.
+ *
+ * @param extension {@link WebExtension} which failed to be installed.
+ * @param installException {@link InstallException} indicates which type of error happened.
+ */
+ @UiThread
+ default void onInstallationFailed(
+ final @Nullable WebExtension extension, final @NonNull InstallException installException) {}
+
+ /**
+ * Called whenever an extension startup has been completed (and relative urls to assets packaged
+ * with the extension can be resolved into a full moz-extension url, e.g. optionsPageUrl is
+ * going to be empty until the extension has reached this callback).
+ *
+ * @param extension The {@link WebExtension} that has been fully started.
+ */
+ @UiThread
+ default void onReady(final @NonNull WebExtension extension) {}
+ }
+
+ /** This delegate is used to notify of extension process state changes. */
+ public interface ExtensionProcessDelegate {
+ /** Called when extension process spawning has been disabled. */
+ @UiThread
+ default void onDisabledProcessSpawning() {}
+ }
+
+ /**
+ * @return the current {@link PromptDelegate} instance.
+ * @see PromptDelegate
+ */
+ @UiThread
+ @Nullable
+ public PromptDelegate getPromptDelegate() {
+ return mPromptDelegate;
+ }
+
+ /**
+ * Set the {@link PromptDelegate} for this instance. This delegate will be used to be notified
+ * whenever an extension is being installed or needs new permissions.
+ *
+ * @param delegate the delegate instance.
+ * @see PromptDelegate
+ */
+ @UiThread
+ public void setPromptDelegate(final @Nullable PromptDelegate delegate) {
+ if (delegate == null && mPromptDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:InstallPrompt",
+ "GeckoView:WebExtension:UpdatePrompt",
+ "GeckoView:WebExtension:OptionalPrompt");
+ } else if (delegate != null && mPromptDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:InstallPrompt",
+ "GeckoView:WebExtension:UpdatePrompt",
+ "GeckoView:WebExtension:OptionalPrompt");
+ }
+
+ mPromptDelegate = delegate;
+ }
+
+ /**
+ * Set the {@link DebuggerDelegate} for this instance. This delegate will receive updates about
+ * extension changes using developer tools.
+ *
+ * @param delegate the Delegate instance
+ */
+ @UiThread
+ public void setDebuggerDelegate(final @NonNull DebuggerDelegate delegate) {
+ if (delegate == null && mDebuggerDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(mInternals, "GeckoView:WebExtension:DebuggerListUpdated");
+ } else if (delegate != null && mDebuggerDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mInternals, "GeckoView:WebExtension:DebuggerListUpdated");
+ }
+
+ mDebuggerDelegate = delegate;
+ }
+
+ /**
+ * Set the {@link AddonManagerDelegate} for this instance. This delegate will be used to be
+ * notified whenever the state of an extension has changed.
+ *
+ * @param delegate the delegate instance
+ * @see AddonManagerDelegate
+ */
+ @UiThread
+ public void setAddonManagerDelegate(final @Nullable AddonManagerDelegate delegate) {
+ if (delegate == null && mAddonManagerDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:OnDisabling",
+ "GeckoView:WebExtension:OnDisabled",
+ "GeckoView:WebExtension:OnEnabling",
+ "GeckoView:WebExtension:OnEnabled",
+ "GeckoView:WebExtension:OnUninstalling",
+ "GeckoView:WebExtension:OnUninstalled",
+ "GeckoView:WebExtension:OnInstalling",
+ "GeckoView:WebExtension:OnInstallationFailed",
+ "GeckoView:WebExtension:OnInstalled",
+ "GeckoView:WebExtension:OnReady");
+ } else if (delegate != null && mAddonManagerDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:OnDisabling",
+ "GeckoView:WebExtension:OnDisabled",
+ "GeckoView:WebExtension:OnEnabling",
+ "GeckoView:WebExtension:OnEnabled",
+ "GeckoView:WebExtension:OnUninstalling",
+ "GeckoView:WebExtension:OnUninstalled",
+ "GeckoView:WebExtension:OnInstalling",
+ "GeckoView:WebExtension:OnInstallationFailed",
+ "GeckoView:WebExtension:OnInstalled",
+ "GeckoView:WebExtension:OnReady");
+ }
+
+ mAddonManagerDelegate = delegate;
+ }
+
+ /**
+ * Set the {@link ExtensionProcessDelegate} for this instance. This delegate will be used to
+ * notify when the state of the extension process has changed.
+ *
+ * @param delegate the extension process delegate
+ * @see ExtensionProcessDelegate
+ */
+ @UiThread
+ public void setExtensionProcessDelegate(final @Nullable ExtensionProcessDelegate delegate) {
+ if (delegate == null && mExtensionProcessDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ mInternals, "GeckoView:WebExtension:OnDisabledProcessSpawning");
+ } else if (delegate != null && mExtensionProcessDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mInternals, "GeckoView:WebExtension:OnDisabledProcessSpawning");
+ }
+
+ mExtensionProcessDelegate = delegate;
+ }
+
+ /**
+ * Enable extension process spawning.
+ *
+ * <p>Extension process spawning can be disabled when the extension process has been killed or
+ * crashed beyond the threshold set for Gecko. This method can be called to reset the threshold
+ * count and allow the spawning again. If the threshold is reached again, {@link
+ * ExtensionProcessDelegate#onDisabledProcessSpawning()} will still be called.
+ *
+ * @see ExtensionProcessDelegate#onDisabledProcessSpawning()
+ */
+ @AnyThread
+ public void enableExtensionProcessSpawning() {
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:EnableProcessSpawning", null);
+ }
+
+ /**
+ * Disable extension process spawning.
+ *
+ * <p>Extension process spawning can be re-enabled with {@link
+ * WebExtensionController#enableExtensionProcessSpawning()}. This method does the opposite and
+ * stops the extension process. This method can be called when we no longer want to run extensions
+ * for the rest of the session.
+ *
+ * @see ExtensionProcessDelegate#onDisabledProcessSpawning()
+ */
+ @AnyThread
+ public void disableExtensionProcessSpawning() {
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:DisableProcessSpawning", null);
+ }
+
+ private static class InstallCanceller implements GeckoResult.CancellationDelegate {
+ public final String installId;
+
+ public InstallCanceller() {
+ installId = UUID.randomUUID().toString();
+ }
+
+ @Override
+ public GeckoResult<Boolean> cancel() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("installId", installId);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:CancelInstall", bundle)
+ .map(response -> response.getBoolean("cancelled"));
+ }
+ }
+
+ /**
+ * Install an extension.
+ *
+ * <p>An installed extension will persist and will be available even when restarting the {@link
+ * GeckoRuntime}.
+ *
+ * <p>Installed extensions through this method need to be signed by Mozilla, see <a
+ * href="https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/#distributing-your-addon">
+ * Distributing your add-on </a>.
+ *
+ * <p>When calling this method, the GeckoView library will download the extension, validate its
+ * manifest and signature, and give you an opportunity to verify its permissions through {@link
+ * PromptDelegate#installPrompt}, you can use this method to prompt the user if appropriate.
+ *
+ * @param uri URI to the extension's <code>.xpi</code> package. This can be a remote <code>https:
+ * </code> URI or a local <code>file:</code> or <code>resource:</code> URI. Note: the app
+ * needs the appropriate permissions for local URIs.
+ * @param installationMethod The method used by the embedder to install the {@link WebExtension}.
+ * @return A {@link GeckoResult} that will complete when the installation process finishes. For
+ * successful installations, the GeckoResult will return the {@link WebExtension} object that
+ * you can use to set delegates and retrieve information about the WebExtension using {@link
+ * WebExtension#metaData}.
+ * <p>If an error occurs during the installation process, the GeckoResult will complete
+ * exceptionally with a {@link WebExtension.InstallException InstallException} that will
+ * contain the relevant error code in {@link WebExtension.InstallException#code
+ * InstallException#code}.
+ * @see PromptDelegate#installPrompt
+ * @see WebExtension.InstallException.ErrorCodes
+ * @see WebExtension#metaData
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> install(
+ final @NonNull String uri, final @Nullable @InstallationMethod String installationMethod) {
+ final InstallCanceller canceller = new InstallCanceller();
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("locationUri", uri);
+ bundle.putString("installId", canceller.installId);
+ bundle.putString("installMethod", installationMethod);
+
+ final GeckoResult<WebExtension> result =
+ EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Install", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ result.setCancellationDelegate(canceller);
+ return result;
+ }
+
+ /**
+ * Install an extension.
+ *
+ * <p>An installed extension will persist and will be available even when restarting the {@link
+ * GeckoRuntime}.
+ *
+ * <p>Installed extensions through this method need to be signed by Mozilla, see <a
+ * href="https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/#distributing-your-addon">
+ * Distributing your add-on </a>.
+ *
+ * <p>When calling this method, the GeckoView library will download the extension, validate its
+ * manifest and signature, and give you an opportunity to verify its permissions through {@link
+ * PromptDelegate#installPrompt}, you can use this method to prompt the user if appropriate. If
+ * you are looking to provide an {@link InstallationMethod}, please use {@link
+ * WebExtensionController#install(String, String)}
+ *
+ * @param uri URI to the extension's <code>.xpi</code> package. This can be a remote <code>https:
+ * </code> URI or a local <code>file:</code> or <code>resource:</code> URI. Note: the app
+ * needs the appropriate permissions for local URIs.
+ * @return A {@link GeckoResult} that will complete when the installation process finishes. For
+ * successful installations, the GeckoResult will return the {@link WebExtension} object that
+ * you can use to set delegates and retrieve information about the WebExtension using {@link
+ * WebExtension#metaData}.
+ * <p>If an error occurs during the installation process, the GeckoResult will complete
+ * exceptionally with a {@link WebExtension.InstallException InstallException} that will
+ * contain the relevant error code in {@link WebExtension.InstallException#code
+ * InstallException#code}.
+ * @see PromptDelegate#installPrompt
+ * @see WebExtension.InstallException.ErrorCodes
+ * @see WebExtension#metaData
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> install(final @NonNull String uri) {
+ return install(uri, null);
+ }
+
+ /** The method used by the embedder to install the {@link WebExtension}. */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({INSTALLATION_METHOD_MANAGER, INSTALLATION_METHOD_FROM_FILE})
+ public @interface InstallationMethod {};
+
+ /** Indicates the {@link WebExtension} was installed using from the embedder's add-ons manager. */
+ public static final String INSTALLATION_METHOD_MANAGER = "manager";
+
+ /** Indicates the {@link WebExtension} was installed from a file. */
+ public static final String INSTALLATION_METHOD_FROM_FILE = "install-from-file";
+
+ /**
+ * Set whether an extension should be allowed to run in private browsing or not.
+ *
+ * @param extension the {@link WebExtension} instance to modify.
+ * @param allowed true if this extension should be allowed to run in private browsing pages, false
+ * otherwise.
+ * @return the updated {@link WebExtension} instance.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> setAllowedInPrivateBrowsing(
+ final @NonNull WebExtension extension, final boolean allowed) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("extensionId", extension.id);
+ bundle.putBoolean("allowed", allowed);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:SetPBAllowed", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Install a built-in extension.
+ *
+ * <p>Built-in extensions have access to native messaging, don't need to be signed and are
+ * installed from a folder in the APK instead of a .xpi bundle.
+ *
+ * <p>Example:
+ *
+ * <p><code>
+ * controller.installBuiltIn("resource://android/assets/example/");
+ * </code> Will install the built-in extension located at <code>/assets/example/</code> in the
+ * app's APK.
+ *
+ * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only
+ * <code>resource://android</code> URIs are allowed.
+ * @see WebExtension.MessageDelegate
+ * @return A {@link GeckoResult} that completes with the extension once it's installed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> installBuiltIn(final @NonNull String uri) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("locationUri", uri);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:InstallBuiltIn", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Ensure that a built-in extension is installed.
+ *
+ * <p>Similar to {@link #installBuiltIn}, except the extension is not re-installed if it's already
+ * present and it has the same version.
+ *
+ * <p>Example:
+ *
+ * <p><code>
+ * controller.ensureBuiltIn("resource://android/assets/example/", "example@example.com");
+ * </code> Will install the built-in extension located at <code>/assets/example/</code> in the
+ * app's APK.
+ *
+ * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only
+ * <code>resource://android</code> URIs are allowed.
+ * @param id Extension ID as present in the manifest.json file.
+ * @see WebExtension.MessageDelegate
+ * @return A {@link GeckoResult} that completes with the extension once it's installed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> ensureBuiltIn(
+ final @NonNull String uri, final @Nullable String id) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("locationUri", uri);
+ bundle.putString("webExtensionId", id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:EnsureBuiltIn", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Uninstall an extension.
+ *
+ * <p>Uninstalling an extension will remove it from the current {@link GeckoRuntime} instance,
+ * delete all its data and trigger a request to close all extension pages currently open.
+ *
+ * @param extension The {@link WebExtension} to be uninstalled.
+ * @return A {@link GeckoResult} that will complete when the uninstall process is completed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<Void> uninstall(final @NonNull WebExtension extension) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("webExtensionId", extension.id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Uninstall", bundle)
+ .accept(result -> unregisterWebExtension(extension));
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({EnableSource.USER, EnableSource.APP})
+ public @interface EnableSources {}
+
+ /**
+ * Contains the possible values for the <code>source</code> parameter in {@link #enable} and
+ * {@link #disable}.
+ */
+ public static class EnableSource {
+ /** Action has been requested by the user. */
+ public static final int USER = 1;
+
+ /**
+ * Action requested by the app itself, e.g. to disable an extension that is not supported in
+ * this version of the app.
+ */
+ public static final int APP = 2;
+
+ static String toString(final @EnableSources int flag) {
+ if (flag == USER) {
+ return "user";
+ } else if (flag == APP) {
+ return "app";
+ } else {
+ throw new IllegalArgumentException("Value provided in flags is not valid.");
+ }
+ }
+ }
+
+ /**
+ * Enable an extension that has been disabled. If the extension is already enabled, this method
+ * has no effect.
+ *
+ * @param extension The {@link WebExtension} to be enabled.
+ * @param source The agent that initiated this action, e.g. if the action has been initiated by
+ * the user,use {@link EnableSource#USER}.
+ * @return the new {@link WebExtension} instance, updated to reflect the enablement.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> enable(
+ final @NonNull WebExtension extension, final @EnableSources int source) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("webExtensionId", extension.id);
+ bundle.putString("source", EnableSource.toString(source));
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Enable", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Disable an extension that is enabled. If the extension is already disabled, this method has no
+ * effect.
+ *
+ * @param extension The {@link WebExtension} to be disabled.
+ * @param source The agent that initiated this action, e.g. if the action has been initiated by
+ * the user, use {@link EnableSource#USER}.
+ * @return the new {@link WebExtension} instance, updated to reflect the disablement.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> disable(
+ final @NonNull WebExtension extension, final @EnableSources int source) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("webExtensionId", extension.id);
+ bundle.putString("source", EnableSource.toString(source));
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Disable", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ private List<WebExtension> listFromBundle(final GeckoBundle response) {
+ final GeckoBundle[] bundles = response.getBundleArray("extensions");
+ final List<WebExtension> list = new ArrayList<>(bundles.length);
+
+ for (final GeckoBundle bundle : bundles) {
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, bundle);
+ list.add(registerWebExtension(extension));
+ }
+
+ return list;
+ }
+
+ /**
+ * List installed extensions for this {@link GeckoRuntime}.
+ *
+ * <p>The returned list can be used to set delegates on the {@link WebExtension} objects using
+ * {@link WebExtension#setActionDelegate}, {@link WebExtension#setMessageDelegate}.
+ *
+ * @return a {@link GeckoResult} that will resolve when the list of extensions is available.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<List<WebExtension>> list() {
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:List")
+ .map(this::listFromBundle);
+ }
+
+ /**
+ * Update a web extension.
+ *
+ * <p>When checking for an update, GeckoView will download the update manifest that is defined by
+ * the web extension's manifest property <a
+ * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/">browser_specific_settings.gecko.update_url</a>.
+ * If an update is found it will be downloaded and installed. If the extension needs any new
+ * permissions the {@link PromptDelegate#updatePrompt} will be triggered.
+ *
+ * <p>More information about the update manifest format is available <a
+ * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/#manifest-structure">here</a>.
+ *
+ * @param extension The extension to update.
+ * @return A {@link GeckoResult} that will complete when the update process finishes. If an update
+ * is found and installed successfully, the GeckoResult will return the updated {@link
+ * WebExtension}. If no update is available, null will be returned. If the updated extension
+ * requires new permissions, the {@link PromptDelegate#installPrompt} will be called.
+ * @see PromptDelegate#updatePrompt
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> update(final @NonNull WebExtension extension) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("webExtensionId", extension.id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Update", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /* package */ WebExtensionController(final GeckoRuntime runtime) {
+ mListener = new WebExtension.Listener<>(runtime);
+ mPendingMessages = new MultiMap<>();
+ mPendingNewTab = new MultiMap<>();
+ mPendingBrowsingData = new MultiMap<>();
+ mPendingDownload = new MultiMap<>();
+ mExtensions.setObserver(mInternals);
+ mDownloads = new SparseArray<>();
+ }
+
+ /* package */ WebExtension registerWebExtension(final WebExtension webExtension) {
+ if (webExtension != null) {
+ mExtensions.update(webExtension.id, webExtension);
+ }
+ return webExtension;
+ }
+
+ /* package */ void handleMessage(
+ final String event,
+ final GeckoBundle bundle,
+ final EventCallback callback,
+ final GeckoSession session) {
+ final Message message = new Message(event, bundle, callback, session);
+
+ Log.d(LOGTAG, "handleMessage " + event);
+
+ if ("GeckoView:WebExtension:InstallPrompt".equals(event)) {
+ installPrompt(bundle, callback);
+ return;
+ } else if ("GeckoView:WebExtension:UpdatePrompt".equals(event)) {
+ updatePrompt(bundle, callback);
+ return;
+ } else if ("GeckoView:WebExtension:DebuggerListUpdated".equals(event)) {
+ if (mDebuggerDelegate != null) {
+ mDebuggerDelegate.onExtensionListUpdated();
+ }
+ return;
+ } else if ("GeckoView:WebExtension:OnDisabling".equals(event)) {
+ onDisabling(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnDisabled".equals(event)) {
+ onDisabled(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnEnabling".equals(event)) {
+ onEnabling(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnEnabled".equals(event)) {
+ onEnabled(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnUninstalling".equals(event)) {
+ onUninstalling(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnUninstalled".equals(event)) {
+ onUninstalled(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnInstalling".equals(event)) {
+ onInstalling(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnInstalled".equals(event)) {
+ onInstalled(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnDisabledProcessSpawning".equals(event)) {
+ onDisabledProcessSpawning();
+ return;
+ } else if ("GeckoView:WebExtension:OnInstallationFailed".equals(event)) {
+ onInstallationFailed(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnReady".equals(event)) {
+ onReady(bundle);
+ return;
+ }
+
+ extensionFromBundle(bundle)
+ .accept(
+ extension -> {
+ if ("GeckoView:WebExtension:NewTab".equals(event)) {
+ newTab(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:UpdateTab".equals(event)) {
+ updateTab(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:CloseTab".equals(event)) {
+ closeTab(message, extension);
+ return;
+ } else if ("GeckoView:BrowserAction:Update".equals(event)) {
+ actionUpdate(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION);
+ return;
+ } else if ("GeckoView:PageAction:Update".equals(event)) {
+ actionUpdate(message, extension, WebExtension.Action.TYPE_PAGE_ACTION);
+ return;
+ } else if ("GeckoView:BrowserAction:OpenPopup".equals(event)) {
+ openPopup(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION);
+ return;
+ } else if ("GeckoView:PageAction:OpenPopup".equals(event)) {
+ openPopup(message, extension, WebExtension.Action.TYPE_PAGE_ACTION);
+ return;
+ } else if ("GeckoView:WebExtension:OpenOptionsPage".equals(event)) {
+ openOptionsPage(message, extension);
+ return;
+ } else if ("GeckoView:BrowsingData:GetSettings".equals(event)) {
+ getSettings(message, extension);
+ return;
+ } else if ("GeckoView:BrowsingData:Clear".equals(event)) {
+ browsingDataClear(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:Download".equals(event)) {
+ download(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:OptionalPrompt".equals(event)) {
+ optionalPrompt(message, extension);
+ return;
+ }
+
+ // GeckoView:WebExtension:Connect and GeckoView:WebExtension:Message
+ // are handled below.
+ final String nativeApp = bundle.getString("nativeApp");
+ if (nativeApp == null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing required nativeApp message parameter.");
+ }
+ callback.sendError("Missing nativeApp parameter.");
+ return;
+ }
+
+ final GeckoBundle senderBundle = bundle.getBundle("sender");
+ final WebExtension.MessageSender sender =
+ fromBundle(extension, senderBundle, session);
+ if (sender == null) {
+ if (callback != null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ try {
+ Log.e(
+ LOGTAG, "Could not find recipient for message: " + bundle.toJSONObject());
+ } catch (final JSONException ex) {
+ }
+ }
+ callback.sendError("Could not find recipient for " + bundle.getBundle("sender"));
+ }
+ return;
+ }
+
+ if ("GeckoView:WebExtension:Connect".equals(event)) {
+ connect(nativeApp, bundle.getLong("portId", -1), message, sender);
+ } else if ("GeckoView:WebExtension:Message".equals(event)) {
+ message(nativeApp, message, sender);
+ }
+ });
+ }
+
+ private void installPrompt(final GeckoBundle message, final EventCallback callback) {
+ final GeckoBundle extensionBundle = message.getBundle("extension");
+ if (extensionBundle == null
+ || !extensionBundle.containsKey("webExtensionId")
+ || !extensionBundle.containsKey("locationURI")) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing webExtensionId or locationURI");
+ }
+
+ Log.e(LOGTAG, "Missing webExtensionId or locationURI");
+ return;
+ }
+
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG, "Tried to install extension " + extension.id + " but no delegate is registered");
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> promptResponse = mPromptDelegate.onInstallPrompt(extension);
+ if (promptResponse == null) {
+ return;
+ }
+
+ callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void updatePrompt(final GeckoBundle message, final EventCallback callback) {
+ final GeckoBundle currentBundle = message.getBundle("currentlyInstalled");
+ final GeckoBundle updatedBundle = message.getBundle("updatedExtension");
+ final String[] newPermissions = message.getStringArray("newPermissions");
+ final String[] newOrigins = message.getStringArray("newOrigins");
+ if (currentBundle == null || updatedBundle == null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing bundle");
+ }
+
+ Log.e(LOGTAG, "Missing bundle");
+ return;
+ }
+
+ final WebExtension currentExtension =
+ new WebExtension(mDelegateControllerProvider, currentBundle);
+
+ final WebExtension updatedExtension =
+ new WebExtension(mDelegateControllerProvider, updatedBundle);
+
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG,
+ "Tried to update extension " + currentExtension.id + " but no delegate is registered");
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> promptResponse =
+ mPromptDelegate.onUpdatePrompt(
+ currentExtension, updatedExtension, newPermissions, newOrigins);
+ if (promptResponse == null) {
+ return;
+ }
+
+ callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void optionalPrompt(final Message message, final WebExtension extension) {
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG,
+ "Tried to request optional permissions for extension "
+ + extension.id
+ + " but no delegate is registered");
+ return;
+ }
+
+ final String[] permissions =
+ message.bundle.getBundle("permissions").getStringArray("permissions");
+ final String[] origins = message.bundle.getBundle("permissions").getStringArray("origins");
+ final GeckoResult<AllowOrDeny> promptResponse =
+ mPromptDelegate.onOptionalPrompt(extension, permissions, origins);
+ if (promptResponse == null) {
+ return;
+ }
+
+ message.callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void onInstallationFailed(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final int errorCode = bundle.getInt("error");
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ WebExtension extension = null;
+ final String extensionName = bundle.getString("addonName");
+
+ if (extensionBundle != null) {
+ extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ }
+ mAddonManagerDelegate.onInstallationFailed(
+ extension, new InstallException(errorCode, extensionName));
+ }
+
+ private void onDisabling(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onDisabling(extension);
+ }
+
+ private void onDisabled(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onDisabled(extension);
+ }
+
+ private void onEnabling(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onEnabling(extension);
+ }
+
+ private void onEnabled(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onEnabled(extension);
+ }
+
+ private void onUninstalling(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onUninstalling(extension);
+ }
+
+ private void onUninstalled(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onUninstalled(extension);
+ }
+
+ private void onInstalling(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onInstalling(extension);
+ }
+
+ private void onInstalled(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onInstalled(extension);
+ }
+
+ private void onReady(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onReady(extension);
+ }
+
+ private void onDisabledProcessSpawning() {
+ if (mExtensionProcessDelegate == null) {
+ Log.e(LOGTAG, "no extension process delegate registered");
+ return;
+ }
+
+ mExtensionProcessDelegate.onDisabledProcessSpawning();
+ }
+
+ @SuppressLint("WrongThread") // for .toGeckoBundle
+ private void getSettings(final Message message, final WebExtension extension) {
+ final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension);
+ if (delegate == null) {
+ mPendingBrowsingData.add(extension.id, message);
+ return;
+ }
+
+ final GeckoResult<WebExtension.BrowsingDataDelegate.Settings> settingsResult =
+ delegate.onGetSettings();
+ if (settingsResult == null) {
+ message.callback.sendError("browsingData.settings is not supported");
+ return;
+ }
+ message.callback.resolveTo(settingsResult.map(settings -> settings.toGeckoBundle()));
+ }
+
+ private void browsingDataClear(final Message message, final WebExtension extension) {
+ final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension);
+ if (delegate == null) {
+ mPendingBrowsingData.add(extension.id, message);
+ return;
+ }
+
+ final long unixTimestamp = message.bundle.getLong("since");
+ final String dataType = message.bundle.getString("dataType");
+
+ final GeckoResult<Void> response;
+ if ("downloads".equals(dataType)) {
+ response = delegate.onClearDownloads(unixTimestamp);
+ } else if ("formData".equals(dataType)) {
+ response = delegate.onClearFormData(unixTimestamp);
+ } else if ("history".equals(dataType)) {
+ response = delegate.onClearHistory(unixTimestamp);
+ } else if ("passwords".equals(dataType)) {
+ response = delegate.onClearPasswords(unixTimestamp);
+ } else {
+ throw new IllegalStateException("Illegal clear data type: " + dataType);
+ }
+
+ message.callback.resolveTo(response);
+ }
+
+ /* package */ void download(final Message message, final WebExtension extension) {
+ final WebExtension.DownloadDelegate delegate = mListener.getDownloadDelegate(extension);
+ if (delegate == null) {
+ mPendingDownload.add(extension.id, message);
+ return;
+ }
+
+ final GeckoBundle optionsBundle = message.bundle.getBundle("options");
+
+ final WebExtension.DownloadRequest request =
+ WebExtension.DownloadRequest.fromBundle(optionsBundle);
+
+ final GeckoResult<WebExtension.DownloadInitData> result =
+ delegate.onDownload(extension, request);
+ if (result == null) {
+ message.callback.sendError("downloads.download is not supported");
+ return;
+ }
+
+ message.callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == null) {
+ Log.e(LOGTAG, "onDownload returned invalid null value");
+ throw new IllegalArgumentException("downloads.download is not supported");
+ }
+
+ final GeckoBundle returnMessage =
+ WebExtension.Download.downloadInfoToBundle(value.initData);
+ returnMessage.putInt("id", value.download.id);
+
+ return returnMessage;
+ }));
+ }
+
+ /* package */ void openOptionsPage(final Message message, final WebExtension extension) {
+ final GeckoBundle bundle = message.bundle;
+ final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension);
+
+ if (delegate != null) {
+ delegate.onOpenOptionsPage(extension);
+ } else {
+ message.callback.sendError("runtime.openOptionsPage is not supported");
+ }
+
+ message.callback.sendSuccess(null);
+ }
+
+ /* package */
+ @SuppressLint("WrongThread") // for .isOpen
+ void newTab(final Message message, final WebExtension extension) {
+ final GeckoBundle bundle = message.bundle;
+
+ final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension);
+ final WebExtension.CreateTabDetails details =
+ new WebExtension.CreateTabDetails(bundle.getBundle("createProperties"));
+
+ final GeckoResult<GeckoSession> result;
+ if (delegate != null) {
+ result = delegate.onNewTab(extension, details);
+ } else {
+ mPendingNewTab.add(extension.id, message);
+ return;
+ }
+
+ if (result == null) {
+ message.callback.sendSuccess(false);
+ return;
+ }
+
+ final String newSessionId = message.bundle.getString("newSessionId");
+ message.callback.resolveTo(
+ result.map(
+ session -> {
+ if (session == null) {
+ return false;
+ }
+
+ if (session.isOpen()) {
+ throw new IllegalArgumentException("Must use an unopened GeckoSession instance");
+ }
+
+ session.open(mListener.runtime, newSessionId);
+ return true;
+ }));
+ }
+
+ /* package */ void updateTab(final Message message, final WebExtension extension) {
+ final WebExtension.SessionTabDelegate delegate =
+ message.session.getWebExtensionController().getTabDelegate(extension);
+ final EventCallback callback = message.callback;
+
+ if (delegate == null) {
+ callback.sendError("tabs.update is not supported");
+ return;
+ }
+
+ final WebExtension.UpdateTabDetails details =
+ new WebExtension.UpdateTabDetails(message.bundle.getBundle("updateProperties"));
+ callback.resolveTo(
+ delegate
+ .onUpdateTab(extension, message.session, details)
+ .map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return null;
+ } else {
+ throw new Exception("tabs.update is not supported");
+ }
+ }));
+ }
+
+ /* package */ void closeTab(final Message message, final WebExtension extension) {
+ final WebExtension.SessionTabDelegate delegate =
+ message.session.getWebExtensionController().getTabDelegate(extension);
+
+ final GeckoResult<AllowOrDeny> result;
+ if (delegate != null) {
+ result = delegate.onCloseTab(extension, message.session);
+ } else {
+ result = GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+
+ message.callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return null;
+ } else {
+ throw new Exception("tabs.remove is not supported");
+ }
+ }));
+ }
+
+ /**
+ * Notifies extensions about a active tab change over the `tabs.onActivated` event.
+ *
+ * @param session The {@link GeckoSession} of the newly selected session/tab.
+ * @param active true if the tab became active, false if the tab became inactive.
+ */
+ @AnyThread
+ public void setTabActive(@NonNull final GeckoSession session, final boolean active) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putBoolean("active", active);
+ session.getEventDispatcher().dispatch("GeckoView:WebExtension:SetTabActive", bundle);
+ }
+
+ /* package */ void unregisterWebExtension(final WebExtension webExtension) {
+ mExtensions.remove(webExtension.id);
+ mListener.unregisterWebExtension(webExtension);
+ }
+
+ private WebExtension.MessageSender fromBundle(
+ final WebExtension extension, final GeckoBundle sender, final GeckoSession session) {
+ if (extension == null) {
+ // All senders should have an extension
+ return null;
+ }
+
+ final String envType = sender.getString("envType");
+ @WebExtension.MessageSender.EnvType final int environmentType;
+
+ if ("content_child".equals(envType)) {
+ environmentType = WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT;
+ } else if ("addon_child".equals(envType)) {
+ // TODO Bug 1554277: check that this message is coming from the right process
+ environmentType = WebExtension.MessageSender.ENV_TYPE_EXTENSION;
+ } else {
+ environmentType = WebExtension.MessageSender.ENV_TYPE_UNKNOWN;
+ }
+
+ if (environmentType == WebExtension.MessageSender.ENV_TYPE_UNKNOWN) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing or unknown envType: " + envType);
+ }
+
+ return null;
+ }
+
+ final String url = sender.getString("url");
+ final boolean isTopLevel;
+ if (session == null || environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
+ // This message is coming from the background page, a popup, or an extension page
+ isTopLevel = true;
+ } else {
+ // If session is present we are either receiving this message from a content script or
+ // an extension page, let's make sure we have the proper identification so that
+ // embedders can check the origin of this message.
+ // -1 is an invalid frame id
+ final boolean hasFrameId =
+ sender.containsKey("frameId") && sender.getInt("frameId", -1) != -1;
+ final boolean hasUrl = sender.containsKey("url");
+ if (!hasFrameId || !hasUrl) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException(
+ "Missing sender information. hasFrameId: " + hasFrameId + " hasUrl: " + hasUrl);
+ }
+
+ // This message does not have the proper identification and may be compromised,
+ // let's ignore it.
+ return null;
+ }
+
+ isTopLevel = sender.getInt("frameId", -1) == 0;
+ }
+
+ return new WebExtension.MessageSender(extension, session, url, environmentType, isTopLevel);
+ }
+
+ private WebExtension.MessageDelegate getDelegate(
+ final String nativeApp,
+ final WebExtension.MessageSender sender,
+ final EventCallback callback) {
+ if ((sender.webExtension.flags & WebExtension.Flags.ALLOW_CONTENT_MESSAGING) == 0
+ && sender.environmentType == WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT) {
+ callback.sendError("This NativeApp can't receive messages from Content Scripts.");
+ return null;
+ }
+
+ WebExtension.MessageDelegate delegate = null;
+
+ if (sender.session != null) {
+ delegate =
+ sender
+ .session
+ .getWebExtensionController()
+ .getMessageDelegate(sender.webExtension, nativeApp);
+ } else if (sender.environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
+ delegate = mListener.getMessageDelegate(sender.webExtension, nativeApp);
+ }
+
+ return delegate;
+ }
+
+ private static class MessageRecipient {
+ public final String webExtensionId;
+ public final String nativeApp;
+ public final GeckoSession session;
+
+ public MessageRecipient(
+ final String webExtensionId, final String nativeApp, final GeckoSession session) {
+ this.webExtensionId = webExtensionId;
+ this.nativeApp = nativeApp;
+ this.session = session;
+ }
+
+ private static boolean equals(final Object a, final Object b) {
+ return Objects.equals(a, b);
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof MessageRecipient)) {
+ return false;
+ }
+
+ final MessageRecipient o = (MessageRecipient) other;
+ return equals(webExtensionId, o.webExtensionId)
+ && equals(nativeApp, o.nativeApp)
+ && equals(session, o.session);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {webExtensionId, nativeApp, session});
+ }
+ }
+
+ private void connect(
+ final String nativeApp,
+ final long portId,
+ final Message message,
+ final WebExtension.MessageSender sender) {
+ if (portId == -1) {
+ message.callback.sendError("Missing portId.");
+ return;
+ }
+
+ final WebExtension.Port port = new WebExtension.Port(nativeApp, portId, sender);
+
+ final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, message.callback);
+ if (delegate == null) {
+ mPendingMessages.add(
+ new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message);
+ return;
+ }
+
+ delegate.onConnect(port);
+ message.callback.sendSuccess(true);
+ }
+
+ private void message(
+ final String nativeApp, final Message message, final WebExtension.MessageSender sender) {
+ final EventCallback callback = message.callback;
+
+ final Object content;
+ try {
+ content = message.bundle.toJSONObject().get("data");
+ } catch (final JSONException ex) {
+ callback.sendError(ex.getMessage());
+ return;
+ }
+
+ final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, callback);
+ if (delegate == null) {
+ mPendingMessages.add(
+ new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message);
+ return;
+ }
+
+ final GeckoResult<Object> response = delegate.onMessage(nativeApp, content, sender);
+ if (response == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(response);
+ }
+
+ private GeckoResult<WebExtension> extensionFromBundle(final GeckoBundle message) {
+ final String extensionId = message.getString("extensionId");
+ return mExtensions.get(extensionId);
+ }
+
+ private void openPopup(
+ final Message message,
+ final WebExtension extension,
+ final @WebExtension.Action.ActionType int actionType) {
+ if (extension == null) {
+ return;
+ }
+
+ final WebExtension.Action action =
+ new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension);
+ final String popupUri = message.bundle.getString("popupUri");
+
+ final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final GeckoResult<GeckoSession> popup = delegate.onOpenPopup(extension, action);
+ action.openPopup(popup, popupUri);
+ }
+
+ private WebExtension.ActionDelegate actionDelegateFor(
+ final WebExtension extension, final GeckoSession session) {
+ if (session == null) {
+ return mListener.getActionDelegate(extension);
+ }
+
+ return session.getWebExtensionController().getActionDelegate(extension);
+ }
+
+ private void actionUpdate(
+ final Message message,
+ final WebExtension extension,
+ final @WebExtension.Action.ActionType int actionType) {
+ if (extension == null) {
+ return;
+ }
+
+ final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final WebExtension.Action action =
+ new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension);
+ if (actionType == WebExtension.Action.TYPE_BROWSER_ACTION) {
+ delegate.onBrowserAction(extension, message.session, action);
+ } else if (actionType == WebExtension.Action.TYPE_PAGE_ACTION) {
+ delegate.onPageAction(extension, message.session, action);
+ }
+ }
+
+ // TODO: implement bug 1595822
+ /* package */ static GeckoResult<List<WebExtension.Menu>> getMenu(
+ final GeckoBundle menuArrayBundle) {
+ return null;
+ }
+
+ @Nullable
+ @UiThread
+ public WebExtension.Download createDownload(final int id) {
+ if (mDownloads.indexOfKey(id) >= 0) {
+ throw new IllegalArgumentException("Download with this id already exists");
+ } else {
+ final WebExtension.Download download = new WebExtension.Download(id);
+ mDownloads.put(id, download);
+
+ return download;
+ }
+ }
+}