/* 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.graphics.Color; import android.util.Log; import androidx.annotation.AnyThread; import androidx.annotation.IntDef; import androidx.annotation.LongDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; 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.Locale; import org.json.JSONException; import org.json.JSONObject; import org.mozilla.gecko.EventDispatcher; import org.mozilla.gecko.util.BundleEventListener; import org.mozilla.gecko.util.EventCallback; import org.mozilla.gecko.util.GeckoBundle; /** Represents a WebExtension that may be used by GeckoView. */ public class WebExtension { /** * file: or resource: 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 resource://android alias. E.g. * *

   *      resource://android/assets/web_extensions/my_webextension/
   * 
* * Will point to folder /assets/web_extensions/my_webextension/ in the APK. */ public final @NonNull String location; /** Unique identifier for this WebExtension */ public final @NonNull String id; /** {@link Flags} for this WebExtension. */ public final @WebExtensionFlags long flags; /** Provides information about this {@link WebExtension}. */ public final @NonNull MetaData metaData; /** * Whether this extension is built-in. Built-in extension can be installed using {@link * WebExtensionController#installBuiltIn}. */ public final boolean isBuiltIn; /** * Called whenever a delegate is set or unset on this {@link WebExtension} instance. /* package */ interface DelegateController { void onMessageDelegate(final String nativeApp, final MessageDelegate delegate); void onActionDelegate(final ActionDelegate delegate); void onBrowsingDataDelegate(final BrowsingDataDelegate delegate); void onTabDelegate(final TabDelegate delegate); void onDownloadDelegate(final DownloadDelegate delegate); ActionDelegate getActionDelegate(); BrowsingDataDelegate getBrowsingDataDelegate(); TabDelegate getTabDelegate(); DownloadDelegate getDownloadDelegate(); } /* package */ interface DelegateControllerProvider { @NonNull DelegateController controllerFor(final WebExtension extension); } private final DelegateController mDelegateController; @Override public String toString() { return "WebExtension {" + "location=" + location + ", " + "id=" + id + ", " + "flags=" + flags + "}"; } private static final String LOGTAG = "WebExtension"; // Keep in sync with GeckoViewWebExtension.sys.mjs public static class Flags { /* * Default flags for this WebExtension. */ public static final long NONE = 0; /** * Set this flag if you want to enable content scripts messaging. To listen to such messages you * can use {@link SessionController#setMessageDelegate}. */ public static final long ALLOW_CONTENT_MESSAGING = 1 << 0; // Do not instantiate this class. protected Flags() {} } @Retention(RetentionPolicy.SOURCE) @LongDef( flag = true, value = {Flags.NONE, Flags.ALLOW_CONTENT_MESSAGING}) public @interface WebExtensionFlags {} /* package */ WebExtension(final DelegateControllerProvider provider, final GeckoBundle bundle) { location = bundle.getString("locationURI"); id = bundle.getString("webExtensionId"); flags = bundle.getInt("webExtensionFlags", 0); isBuiltIn = bundle.getBoolean("isBuiltIn", false); if (bundle.containsKey("metaData")) { metaData = new MetaData(bundle.getBundle("metaData")); } else { metaData = null; } mDelegateController = provider.controllerFor(this); } /** * Defines the message delegate for a Native App. * *

This message delegate will receive messages from the background script for the native app * specified in nativeApp. * *

For messages from content scripts, set a session-specific message delegate using {@link * SessionController#setMessageDelegate}. * *

See also * WebExtensions/Native_messaging * * @param messageDelegate handles messaging between the WebExtension and the app. To send a * message from the WebExtension use the runtime.sendNativeMessage WebExtension * API: E.g. *


   *        browser.runtime.sendNativeMessage(nativeApp,
   *              {message: "Hello from WebExtension!"});
   *      
* For bidirectional communication, use runtime.connectNative. E.g. in a content * script: *

   *          let port = browser.runtime.connectNative(nativeApp);
   *          port.onMessage.addListener(message => {
   *              console.log("Message received from app");
   *          });
   *          port.postMessage("Ping from WebExtension");
   *      
* The code above will trigger a {@link MessageDelegate#onConnect} call that will contain the * corresponding {@link Port} object that can be used to send messages to the WebExtension. * Note: the nativeApp specified in the WebExtension needs to match the * nativeApp parameter of this method. *

You can unset the message delegate by setting a null messageDelegate. * @param nativeApp which native app id this message delegate will handle messaging for. Needs to * match the application parameter of runtime.sendNativeMessage and * runtime.connectNative. * @see SessionController#setMessageDelegate */ @UiThread public void setMessageDelegate( final @Nullable MessageDelegate messageDelegate, final @NonNull String nativeApp) { mDelegateController.onMessageDelegate(nativeApp, messageDelegate); } @Retention(RetentionPolicy.SOURCE) @LongDef( value = { BrowsingDataDelegate.Type.CACHE, BrowsingDataDelegate.Type.COOKIES, BrowsingDataDelegate.Type.DOWNLOADS, BrowsingDataDelegate.Type.FORM_DATA, BrowsingDataDelegate.Type.HISTORY, BrowsingDataDelegate.Type.LOCAL_STORAGE, BrowsingDataDelegate.Type.PASSWORDS }, flag = true) public @interface BrowsingDataTypes {} /** * This delegate is used to handle calls from the |browsingData| WebExtension API. * *

See also: * WebExtensions/API/browsingData */ @UiThread public interface BrowsingDataDelegate { /** * This class represents the current default settings for the "Clear Data" functionality in the * browser. * *

See also: * WebExtensions/API/browsingData/settings */ @UiThread class Settings { /** * Currently selected setting in the browser's "Clear Data" UI for how far back in time to * remove data given in milliseconds since the UNIX epoch. */ public final int sinceUnixTimestamp; /** * Data types that can be toggled in the browser's "Clear Data" UI. One or more flags from * {@link Type}. */ public final @BrowsingDataTypes long toggleableTypes; /** * Data types currently selected in the browser's "Clear Data" UI. One or more flags from * {@link Type}. */ public final @BrowsingDataTypes long selectedTypes; /** * Creates an instance of Settings. * *

This class represents the current default settings for the "Clear Data" functionality in * the browser. * * @param since Currently selected setting in the browser's "Clear Data" UI for how far back * in time to remove data given in milliseconds since the UNIX epoch. * @param toggleableTypes Data types that can be toggled in the browser's "Clear Data" UI. One * or more flags from {@link Type}. * @param selectedTypes Data types currently selected in the browser's "Clear Data" UI. One or * more flags from {@link Type}. */ @UiThread public Settings( final int since, final @BrowsingDataTypes long toggleableTypes, final @BrowsingDataTypes long selectedTypes) { this.toggleableTypes = toggleableTypes; this.selectedTypes = selectedTypes; this.sinceUnixTimestamp = since; } private GeckoBundle fromBrowsingDataType(final @BrowsingDataTypes long types) { final GeckoBundle result = new GeckoBundle(7); result.putBoolean("cache", (types & Type.CACHE) != 0); result.putBoolean("cookies", (types & Type.COOKIES) != 0); result.putBoolean("downloads", (types & Type.DOWNLOADS) != 0); result.putBoolean("formData", (types & Type.FORM_DATA) != 0); result.putBoolean("history", (types & Type.HISTORY) != 0); result.putBoolean("localStorage", (types & Type.LOCAL_STORAGE) != 0); result.putBoolean("passwords", (types & Type.PASSWORDS) != 0); return result; } /* package */ GeckoBundle toGeckoBundle() { final GeckoBundle options = new GeckoBundle(1); options.putLong("since", sinceUnixTimestamp); final GeckoBundle result = new GeckoBundle(3); result.putBundle("options", options); result.putBundle("dataToRemove", fromBrowsingDataType(selectedTypes)); result.putBundle("dataRemovalPermitted", fromBrowsingDataType(toggleableTypes)); return result; } } /** Types of data that a browser "Clear Data" UI might have access to. */ class Type { protected Type() {} public static final long CACHE = 1 << 0; public static final long COOKIES = 1 << 1; public static final long DOWNLOADS = 1 << 2; public static final long FORM_DATA = 1 << 3; public static final long HISTORY = 1 << 4; public static final long LOCAL_STORAGE = 1 << 5; public static final long PASSWORDS = 1 << 6; } /** * Gets current settings for the browser's "Clear Data" UI. * * @return a {@link GeckoResult} that resolves to an instance of {@link Settings} that * represents the current state for the browser's "Clear Data" UI. * @see Settings */ @Nullable default GeckoResult onGetSettings() { return null; } /** * Clear form data created after the given timestamp. * * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch. * @return a {@link GeckoResult} that resolves when data has been cleared. */ @Nullable default GeckoResult onClearFormData(final long sinceUnixTimestamp) { return null; } /** * Clear passwords saved after the given timestamp. * * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch. * @return a {@link GeckoResult} that resolves when data has been cleared. */ @Nullable default GeckoResult onClearPasswords(final long sinceUnixTimestamp) { return null; } /** * Clear history saved after the given timestamp. * * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch. * @return a {@link GeckoResult} that resolves when data has been cleared. */ @Nullable default GeckoResult onClearHistory(final long sinceUnixTimestamp) { return null; } /** * Clear downloads created after the given timestamp. * * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch. * @return a {@link GeckoResult} that resolves when data has been cleared. */ @Nullable default GeckoResult onClearDownloads(final long sinceUnixTimestamp) { return null; } } /** Delegates that responds to messages sent from a WebExtension. */ @UiThread public interface MessageDelegate { /** * Called whenever the WebExtension sends a message to an app using * runtime.sendNativeMessage. * * @param nativeApp The application identifier of the MessageDelegate that sent this message. * @param message The message that was sent, either a primitive type or a {@link * org.json.JSONObject}. * @param sender The {@link MessageSender} corresponding to the frame that originated the * message. *

Note: all messages are to be considered untrusted and should be checked carefully for * validity. * @return A {@link GeckoResult} that resolves with a response to the message. */ @Nullable default GeckoResult onMessage( final @NonNull String nativeApp, final @NonNull Object message, final @NonNull MessageSender sender) { return null; } /** * Called whenever the WebExtension connects to an app using runtime.connectNative. * * @param port {@link Port} instance that can be used to send and receive messages from the * WebExtension. Use {@link Port#sender} to verify the origin of this connection request. */ @Nullable default void onConnect(final @NonNull Port port) {} } /** * Delegate that handles communication from a WebExtension on a specific {@link Port} instance. */ @UiThread public interface PortDelegate { /** * Called whenever a message is sent through the corresponding {@link Port} instance. * * @param message The message that was sent, either a primitive type or a {@link * org.json.JSONObject}. * @param port The {@link Port} instance that received this message. */ default void onPortMessage(final @NonNull Object message, final @NonNull Port port) {} /** * Called whenever the corresponding {@link Port} instance is disconnected or the corresponding * {@link GeckoSession} is destroyed. Any message sent from the port after this call will be * ignored. * * @param port The {@link Port} instance that was disconnected. */ @NonNull default void onDisconnect(final @NonNull Port port) {} } /** * Port object that can be used for bidirectional communication with a WebExtension. * *

See also: * WebExtensions/API/runtime/Port . * * @see MessageDelegate#onConnect */ @UiThread public static class Port { /* package */ final long id; /* package */ PortDelegate delegate; /* package */ boolean disconnected = false; /* package */ final EventDispatcher mEventDispatcher; /* package */ boolean mListenersRegistered = false; /** {@link MessageSender} corresponding to this port. */ public @NonNull final MessageSender sender; /** The application identifier of the MessageDelegate that opened this port. */ public @NonNull final String name; /** Override for tests. */ protected Port() { this.id = -1; this.delegate = null; this.sender = null; this.name = null; mEventDispatcher = null; } /* package */ Port(final String name, final long id, final MessageSender sender) { this.id = id; this.delegate = null; this.sender = sender; this.name = name; mEventDispatcher = EventDispatcher.byName("port:" + id); } private BundleEventListener mEventListener = new BundleEventListener() { @Override public void handleMessage( final String event, final GeckoBundle message, final EventCallback callback) { if ("GeckoView:WebExtension:Disconnect".equals(event)) { disconnectFromExtension(callback); } else if ("GeckoView:WebExtension:PortMessage".equals(event)) { portMessage(message, callback); } } }; private void disconnectFromExtension(final EventCallback callback) { delegate.onDisconnect(this); disconnected(); } private void portMessage(final GeckoBundle bundle, final EventCallback callback) { final Object content; try { content = bundle.toJSONObject().get("data"); } catch (final JSONException ex) { callback.sendError(ex); return; } delegate.onPortMessage(content, this); } /** * Post a message to the WebExtension connected to this {@link Port} instance. * * @param message {@link JSONObject} that will be sent to the WebExtension. */ public void postMessage(final @NonNull JSONObject message) { final GeckoBundle args = new GeckoBundle(1); try { args.putBundle("message", GeckoBundle.fromJSONObject(message)); } catch (final JSONException ex) { throw new RuntimeException(ex); } mEventDispatcher.dispatch("GeckoView:WebExtension:PortMessageFromApp", args); } /** Disconnects this port and notifies the other end. */ public void disconnect() { if (this.disconnected) { return; } final GeckoBundle args = new GeckoBundle(1); args.putLong("portId", id); mEventDispatcher.dispatch("GeckoView:WebExtension:PortDisconnect", args); disconnected(); } private void disconnected() { unregisterListeners(); mEventDispatcher.shutdown(); this.disconnected = true; } /** * Set a delegate for incoming messages through this {@link Port}. * * @param delegate Delegate that will receive messages sent through this {@link Port}. */ public void setDelegate(final @Nullable PortDelegate delegate) { this.delegate = delegate; if (delegate != null) { registerListeners(); } else { unregisterListeners(); } } private void unregisterListeners() { if (!mListenersRegistered) { return; } mEventDispatcher.unregisterUiThreadListener( mEventListener, "GeckoView:WebExtension:Disconnect", "GeckoView:WebExtension:PortMessage"); mListenersRegistered = false; } private void registerListeners() { if (mListenersRegistered) { return; } mEventDispatcher.registerUiThreadListener( mEventListener, "GeckoView:WebExtension:Disconnect", "GeckoView:WebExtension:PortMessage"); mListenersRegistered = true; } } /** * This delegate is invoked whenever an extension uses the `tabs` WebExtension API to modify the * state of a tab. See also WebExtensions/API/tabs. */ public interface SessionTabDelegate { /** * Called when tabs.remove is invoked, this method decides if WebExtension can close the tab. In * case WebExtension can close the tab, it should close passed GeckoSession and return * GeckoResult.ALLOW or GeckoResult.DENY in case tab cannot be closed. * *

See also: * WebExtensions/API/tabs/remove * * @param source An instance of {@link WebExtension} * @param session An instance of {@link GeckoSession} to be closed. * @return GeckoResult.ALLOW if the tab will be closed, GeckoResult.DENY otherwise */ @UiThread @NonNull default GeckoResult onCloseTab( @Nullable final WebExtension source, @NonNull final GeckoSession session) { return GeckoResult.deny(); } /** * Called when tabs.update is invoked. The uri is provided for informational purposes, there's * no need to call loadURI on it. The page will be loaded if this method returns * GeckoResult.ALLOW. * *

See also: * WebExtensions/API/tabs/update * * @param extension The extension that requested to update the tab. * @param session The {@link GeckoSession} instance that needs to be updated. * @param details {@link UpdateTabDetails} instance that describes what needs to be updated for * this tab. * @return GeckoResult.ALLOW if the tab will be updated, GeckoResult.DENY * otherwise. */ @UiThread @NonNull default GeckoResult onUpdateTab( final @NonNull WebExtension extension, final @NonNull GeckoSession session, final @NonNull UpdateTabDetails details) { return GeckoResult.deny(); } } /** * Provides details about upating a tab with tabs.update. * *

Whenever a field is not passed in by the extension that value will be null. * *

See also: * WebExtensions/API/tabs/update . */ public static class UpdateTabDetails { /** * Whether the tab should become active. If true, non-active highlighted tabs * should stop being highlighted. If false, does nothing. */ @Nullable public final Boolean active; /** Whether the tab should be discarded automatically by the app when resources are low. */ @Nullable public final Boolean autoDiscardable; /** If true and the tab is not highlighted, it should become active by default. */ @Nullable public final Boolean highlighted; /** Whether the tab should be muted. */ @Nullable public final Boolean muted; /** Whether the tab should be pinned. */ @Nullable public final Boolean pinned; /** * The url that the tab will be navigated to. This url is provided just for informational * purposes, there is no need to load the URL manually. The corresponding {@link GeckoSession} * will be navigated to the right URL after returning GeckoResult.ALLOW from {@link * SessionTabDelegate#onUpdateTab} */ @Nullable public final String url; /** For testing. */ protected UpdateTabDetails() { active = null; autoDiscardable = null; highlighted = null; muted = null; pinned = null; url = null; } /* package */ UpdateTabDetails(final GeckoBundle bundle) { active = bundle.getBooleanObject("active"); autoDiscardable = bundle.getBooleanObject("autoDiscardable"); highlighted = bundle.getBooleanObject("highlighted"); muted = bundle.getBooleanObject("muted"); pinned = bundle.getBooleanObject("pinned"); url = bundle.getString("url"); } } /** * Provides details about creating a tab with tabs.create. See also: * WebExtensions/API/tabs/create . * *

Whenever a field is not passed in by the extension that value will be null. */ public static class CreateTabDetails { /** * Whether the tab should become active. If true, non-active highlighted tabs * should stop being highlighted. If false, does nothing. */ @Nullable public final Boolean active; /** * The CookieStoreId used for the tab. This option is only available if the extension has the * "cookies" permission. */ @Nullable public final String cookieStoreId; /** * Whether the tab is created and made visible in the tab bar without any content loaded into * memory, a state known as discarded. The tab’s content should be loaded when the tab is * activated. */ @Nullable public final Boolean discarded; /** The position the tab should take in the window. */ @Nullable public final Integer index; /** If true, open this tab in Reader Mode. */ @Nullable public final Boolean openInReaderMode; /** Whether the tab should be pinned. */ @Nullable public final Boolean pinned; /** * The url that the tab will be navigated to. This url is provided just for informational * purposes, there is no need to load the URL manually. The corresponding {@link GeckoSession} * will be navigated to the right URL after returning GeckoResult.ALLOW from {@link * TabDelegate#onNewTab} */ @Nullable public final String url; /** For testing. */ protected CreateTabDetails() { active = null; cookieStoreId = null; discarded = null; index = null; openInReaderMode = null; pinned = null; url = null; } /* package */ CreateTabDetails(final GeckoBundle bundle) { active = bundle.getBooleanObject("active"); cookieStoreId = bundle.getString("cookieStoreId"); discarded = bundle.getBooleanObject("discarded"); index = bundle.getInteger("index"); openInReaderMode = bundle.getBooleanObject("openInReaderMode"); pinned = bundle.getBooleanObject("pinned"); url = bundle.getString("url"); } } /** * This delegate is invoked whenever an extension uses the `tabs` WebExtension API and the request * is not specific to an existing tab, e.g. when creating a new tab. See also WebExtensions/API/tabs. */ public interface TabDelegate { /** * Called when tabs.create is invoked, this method returns a *newly-created* session that * GeckoView will use to load the requested page on. If the returned value is null the page will * not be opened. * * @param source An instance of {@link WebExtension} * @param createDetails Information about this tab. * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which * case the request for a new tab by the extension will fail. The implementation of onNewTab * is responsible for maintaining a reference to the returned object, to prevent it from * being garbage collected. */ @UiThread @Nullable default GeckoResult onNewTab( @NonNull final WebExtension source, @NonNull final CreateTabDetails createDetails) { return null; } /** * Called when runtime.openOptionsPage is invoked with options_ui.open_in_tab = false. In this * case, GeckoView delegates options page handling to the app. With options_ui.open_in_tab = * true, {@link #onNewTab} is called instead. * * @param source An instance of {@link WebExtension}. */ @UiThread default void onOpenOptionsPage(@NonNull final WebExtension source) {} } /** * Get the tab delegate for this extension. * *

See also WebExtensions/API/tabs. * * @return The {@link TabDelegate} instance for this extension. */ @UiThread @Nullable public WebExtension.TabDelegate getTabDelegate() { return mDelegateController.getTabDelegate(); } /** * Set the tab delegate for this extension. This delegate will be invoked whenever this extension * tries to modify the tabs state using the `tabs` WebExtension API. * *

See also WebExtensions/API/tabs. * * @param delegate the {@link TabDelegate} instance for this extension. */ @UiThread public void setTabDelegate(final @Nullable TabDelegate delegate) { mDelegateController.onTabDelegate(delegate); } @UiThread @Nullable public BrowsingDataDelegate getBrowsingDataDelegate() { return mDelegateController.getBrowsingDataDelegate(); } @UiThread public void setBrowsingDataDelegate(final @Nullable BrowsingDataDelegate delegate) { mDelegateController.onBrowsingDataDelegate(delegate); } private static class Sender { public String webExtensionId; public String nativeApp; public Sender(final String webExtensionId, final String nativeApp) { this.webExtensionId = webExtensionId; this.nativeApp = nativeApp; } @Override public boolean equals(final Object other) { if (!(other instanceof Sender)) { return false; } final Sender o = (Sender) other; return webExtensionId.equals(o.webExtensionId) && nativeApp.equals(o.nativeApp); } @Override public int hashCode() { return Arrays.hashCode(new Object[] {webExtensionId, nativeApp}); } } // Public wrapper for Listener public static class SessionController { private final Listener mListener; /* package */ void setRuntime(final GeckoRuntime runtime) { mListener.runtime = runtime; } /* package */ SessionController(final GeckoSession session) { mListener = new Listener<>(session); } /** * Defines a message delegate for a Native App. * *

If a delegate is already present, this delegate will replace the existing one. * *

This message delegate will be responsible for handling messaging between a WebExtension * content script running on the {@link GeckoSession}. * *

Note: To receive messages from content scripts, the WebExtension needs to explicitely * allow it in {@link WebExtension#WebExtension} by setting {@link * Flags#ALLOW_CONTENT_MESSAGING}. * * @param webExtension {@link WebExtension} that this delegate receives messages from. * @param delegate {@link MessageDelegate} that will receive messages from this session. * @param nativeApp which native app id this message delegate will handle messaging for. * @see WebExtension#setMessageDelegate */ @AnyThread public void setMessageDelegate( final @NonNull WebExtension webExtension, final @Nullable WebExtension.MessageDelegate delegate, final @NonNull String nativeApp) { mListener.setMessageDelegate(webExtension, delegate, nativeApp); } /** * Get the message delegate for nativeApp. * * @param extension {@link WebExtension} that this delegate receives messages from. * @param nativeApp identifier for the native app * @return The {@link MessageDelegate} attached to the nativeApp. null * if no delegate is present. */ @AnyThread public @Nullable WebExtension.MessageDelegate getMessageDelegate( final @NonNull WebExtension extension, final @NonNull String nativeApp) { return mListener.getMessageDelegate(extension, nativeApp); } /** * Set the Action delegate for this session. * *

This delegate will receive page and browser action overrides specific to this session. The * default Action will be received by the delegate set by {@link * WebExtension#setActionDelegate}. * * @param extension the {@link WebExtension} object this delegate will receive updates for * @param delegate the {@link ActionDelegate} that will receive updates. * @see WebExtension.Action */ @AnyThread public void setActionDelegate( final @NonNull WebExtension extension, final @Nullable ActionDelegate delegate) { mListener.setActionDelegate(extension, delegate); } /** * Get the Action delegate for this session. * * @param extension {@link WebExtension} that this delegates receive updates for. * @return {@link ActionDelegate} for this session */ @AnyThread @Nullable public ActionDelegate getActionDelegate(final @NonNull WebExtension extension) { return mListener.getActionDelegate(extension); } /** * Set the TabDelegate for this session. * *

This delegate will receive messages specific for this session coming from the WebExtension * tabs API. * * @param extension the {@link WebExtension} this delegate will receive updates for * @param delegate the {@link TabDelegate} that will receive updates. * @see WebExtension#setTabDelegate */ @AnyThread public void setTabDelegate( final @NonNull WebExtension extension, final @Nullable SessionTabDelegate delegate) { mListener.setTabDelegate(extension, delegate); } /** * Get the TabDelegate for the given extension. * * @param extension the {@link WebExtension} this delegate refers to. * @return the current {@link SessionTabDelegate} instance */ @AnyThread @Nullable public SessionTabDelegate getTabDelegate(final @NonNull WebExtension extension) { return mListener.getTabDelegate(extension); } } /* package */ static final class Listener implements BundleEventListener { private final HashMap mMessageDelegates; private final HashMap mActionDelegates; private final HashMap mBrowsingDataDelegates; private final HashMap mTabDelegates; private final HashMap mDownloadDelegates; private final GeckoSession mSession; private final EventDispatcher mEventDispatcher; private boolean mActionDelegateRegistered = false; private boolean mBrowsingDataDelegateRegistered = false; private boolean mTabDelegateRegistered = false; public GeckoRuntime runtime; public Listener(final GeckoRuntime runtime) { this(null, runtime); } public Listener(final GeckoSession session) { this(session, null); // Close tab event is forwarded to the main listener so we need to listen // to it here. mEventDispatcher.registerUiThreadListener( this, "GeckoView:WebExtension:NewTab", "GeckoView:WebExtension:UpdateTab", "GeckoView:WebExtension:CloseTab", "GeckoView:WebExtension:OpenOptionsPage"); mTabDelegateRegistered = true; } private Listener(final GeckoSession session, final GeckoRuntime runtime) { mMessageDelegates = new HashMap<>(); mActionDelegates = new HashMap<>(); mBrowsingDataDelegates = new HashMap<>(); mTabDelegates = new HashMap<>(); mDownloadDelegates = new HashMap<>(); mEventDispatcher = session != null ? session.getEventDispatcher() : EventDispatcher.getInstance(); mSession = session; this.runtime = runtime; // We queue these messages if the delegate has not been attached yet, // so we need to start listening immediately. mEventDispatcher.registerUiThreadListener( this, "GeckoView:WebExtension:Message", "GeckoView:WebExtension:PortMessage", "GeckoView:WebExtension:Connect", "GeckoView:WebExtension:Disconnect", "GeckoView:BrowsingData:GetSettings", "GeckoView:BrowsingData:Clear", "GeckoView:WebExtension:Download"); } public void unregisterWebExtension(final WebExtension extension) { mMessageDelegates.remove(extension.id); mActionDelegates.remove(extension.id); mBrowsingDataDelegates.remove(extension.id); mTabDelegates.remove(extension.id); mDownloadDelegates.remove(extension.id); } public void setTabDelegate(final WebExtension webExtension, final TabDelegate delegate) { if (!mTabDelegateRegistered && delegate != null) { mEventDispatcher.registerUiThreadListener( this, "GeckoView:WebExtension:NewTab", "GeckoView:WebExtension:UpdateTab", "GeckoView:WebExtension:CloseTab", "GeckoView:WebExtension:OpenOptionsPage"); mTabDelegateRegistered = true; } mTabDelegates.put(webExtension.id, delegate); } public TabDelegate getTabDelegate(final WebExtension webExtension) { return mTabDelegates.get(webExtension.id); } public void setBrowsingDataDelegate( final WebExtension webExtension, final BrowsingDataDelegate delegate) { mBrowsingDataDelegates.put(webExtension.id, delegate); } public BrowsingDataDelegate getBrowsingDataDelegate(final WebExtension webExtension) { return mBrowsingDataDelegates.get(webExtension.id); } public void setActionDelegate( final WebExtension webExtension, final WebExtension.ActionDelegate delegate) { if (!mActionDelegateRegistered && delegate != null) { mEventDispatcher.registerUiThreadListener( this, "GeckoView:BrowserAction:Update", "GeckoView:BrowserAction:OpenPopup", "GeckoView:PageAction:Update", "GeckoView:PageAction:OpenPopup"); mActionDelegateRegistered = true; } mActionDelegates.put(webExtension.id, delegate); } public WebExtension.ActionDelegate getActionDelegate(final WebExtension webExtension) { return mActionDelegates.get(webExtension.id); } public void setMessageDelegate( final WebExtension webExtension, final WebExtension.MessageDelegate delegate, final String nativeApp) { mMessageDelegates.put(new Sender(webExtension.id, nativeApp), delegate); if (runtime != null && delegate != null) { runtime .getWebExtensionController() .releasePendingMessages(webExtension, nativeApp, mSession); } } public WebExtension.MessageDelegate getMessageDelegate( final WebExtension webExtension, final String nativeApp) { return mMessageDelegates.get(new Sender(webExtension.id, nativeApp)); } @Override public void handleMessage( final String event, final GeckoBundle message, final EventCallback callback) { if (runtime == null) { return; } runtime.getWebExtensionController().handleMessage(event, message, callback, mSession); } public void setDownloadDelegate( final @NonNull WebExtension extension, final @Nullable DownloadDelegate delegate) { mDownloadDelegates.put(extension.id, delegate); } public WebExtension.DownloadDelegate getDownloadDelegate(final WebExtension extension) { return mDownloadDelegates.get(extension.id); } } /** * Describes the sender of a message from a WebExtension. * *

See also: * WebExtensions/API/runtime/MessageSender */ @UiThread public static class MessageSender { /** {@link WebExtension} that sent this message. */ public final @NonNull WebExtension webExtension; /** * {@link GeckoSession} that sent this message. null if coming from a background * script. */ public final @Nullable GeckoSession session; @Retention(RetentionPolicy.SOURCE) @IntDef({ENV_TYPE_UNKNOWN, ENV_TYPE_EXTENSION, ENV_TYPE_CONTENT_SCRIPT}) public @interface EnvType {} /* package */ static final int ENV_TYPE_UNKNOWN = 0; /** This sender originated inside a privileged extension context like a background script. */ public static final int ENV_TYPE_EXTENSION = 1; /** This sender originated inside a content script. */ public static final int ENV_TYPE_CONTENT_SCRIPT = 2; /** * Type of environment that sent this message, either * *

*/ // TODO: Bug 1534640 do we need ENV_TYPE_EXTENSION_PAGE ? public final @EnvType int environmentType; /** * URL of the frame that sent this message. * *

Use this value together with {@link MessageSender#isTopLevel} to verify that the message * is coming from the expected page. Only top level frames can be trusted. */ public final @NonNull String url; /* package */ final boolean isTopLevel; /* package */ MessageSender( final @NonNull WebExtension webExtension, final @Nullable GeckoSession session, final @Nullable String url, final @EnvType int environmentType, final boolean isTopLevel) { this.webExtension = webExtension; this.session = session; this.isTopLevel = isTopLevel; this.url = url; this.environmentType = environmentType; } /** Override for testing. */ protected MessageSender() { this.webExtension = null; this.session = null; this.isTopLevel = false; this.url = null; this.environmentType = ENV_TYPE_UNKNOWN; } /** * Whether this MessageSender belongs to a top level frame. * * @return true if the MessageSender was sent from the top level frame, false otherwise. */ public boolean isTopLevel() { return this.isTopLevel; } } /* package */ static WebExtension fromBundle( final DelegateControllerProvider provider, final GeckoBundle bundle) { if (bundle == null) { return null; } return new WebExtension(provider, bundle.getBundle("extension")); } /** * Represents either a Browser Action or a Page Action from the WebExtension API. * *

Instances of this class may represent the default Action which applies to all * WebExtension tabs or a tab-specific override. To reconstruct the full Action * object, you can use {@link Action#withDefault}. * *

Tab specific overrides can be obtained by registering a delegate using {@link * SessionController#setActionDelegate}, while default values can be obtained by registering a * delegate using {@link #setActionDelegate}.
* See also * *

*/ @AnyThread public static class Action { /** * Title of this Action. * *

See also: * pageAction/getTitle, * browserAction/getTitle */ public final @Nullable String title; /** * Icon for this Action. * *

See also: * pageAction/setIcon, * browserAction/setIcon */ public final @Nullable Image icon; /** * Whether this action is enabled and should be visible. * *

Note: for page action, this is true when the extension calls * pageAction.show and false when the extension calls pageAction.hide * . * *

See also: * pageAction/show, * browserAction/enabled */ public final @Nullable Boolean enabled; /** * Badge text for this action. * *

See also: * browserAction/getBadgeText */ public final @Nullable String badgeText; /** * Background color for the badge for this Action. * *

This method will return an Android color int that can be used in {@link * android.widget.TextView#setBackgroundColor(int)} and similar methods. * *

See also: * browserAction/getBadgeBackgroundColor */ public final @Nullable Integer badgeBackgroundColor; /** * Text color for the badge for this Action. * *

This method will return an Android color int that can be used in {@link * android.widget.TextView#setTextColor(int)} and similar methods. * *

See also: * browserAction/getBadgeTextColor */ public final @Nullable Integer badgeTextColor; private final WebExtension mExtension; /* package */ static final int TYPE_BROWSER_ACTION = 1; /* package */ static final int TYPE_PAGE_ACTION = 2; @Retention(RetentionPolicy.SOURCE) @IntDef({TYPE_BROWSER_ACTION, TYPE_PAGE_ACTION}) public @interface ActionType {} /* package */ final @ActionType int type; /* package */ Action( final @ActionType int type, final GeckoBundle bundle, final WebExtension extension) { mExtension = extension; this.type = type; title = bundle.getString("title"); badgeText = bundle.getString("badgeText"); badgeBackgroundColor = colorFromRgbaArray(bundle.getDoubleArray("badgeBackgroundColor")); badgeTextColor = colorFromRgbaArray(bundle.getDoubleArray("badgeTextColor")); if (bundle.containsKey("icon")) { icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); } else { icon = null; } if (bundle.getBoolean("patternMatching", false)) { // This action was enabled by pattern matching enabled = true; } else if (bundle.containsKey("enabled")) { enabled = bundle.getBoolean("enabled"); } else { enabled = null; } } private Integer colorFromRgbaArray(final double[] c) { if (c == null) { return null; } return Color.argb((int) c[3], (int) c[0], (int) c[1], (int) c[2]); } @Override public String toString() { return "Action {\n" + "\ttitle: " + this.title + ",\n" + "\ticon: " + this.icon + ",\n" + "\tenabled: " + this.enabled + ",\n" + "\tbadgeText: " + this.badgeText + ",\n" + "\tbadgeTextColor: " + this.badgeTextColor + ",\n" + "\tbadgeBackgroundColor: " + this.badgeBackgroundColor + ",\n" + "}"; } // For testing protected Action() { type = TYPE_BROWSER_ACTION; mExtension = null; title = null; icon = null; enabled = null; badgeText = null; badgeTextColor = null; badgeBackgroundColor = null; } /** * Merges values from this Action with the default Action. * * @param defaultValue the default Action as received from {@link * ActionDelegate#onBrowserAction} or {@link ActionDelegate#onPageAction}. * @return an {@link Action} where all null values from this instance are replaced * with values from defaultValue. * @throws IllegalArgumentException if defaultValue is not of the same type, e.g. if this Action * is a Page Action and default value is a Browser Action. */ @NonNull public Action withDefault(final @NonNull Action defaultValue) { return new Action(this, defaultValue); } /** * @see Action#withDefault */ private Action(final Action source, final Action defaultValue) { if (source.type != defaultValue.type) { throw new IllegalArgumentException("defaultValue must be of the same type."); } type = source.type; mExtension = source.mExtension; title = source.title != null ? source.title : defaultValue.title; icon = source.icon != null ? source.icon : defaultValue.icon; enabled = source.enabled != null ? source.enabled : defaultValue.enabled; badgeText = source.badgeText != null ? source.badgeText : defaultValue.badgeText; badgeTextColor = source.badgeTextColor != null ? source.badgeTextColor : defaultValue.badgeTextColor; badgeBackgroundColor = source.badgeBackgroundColor != null ? source.badgeBackgroundColor : defaultValue.badgeBackgroundColor; } /** Notifies the extension that the user has clicked on this Action. */ @UiThread public void click() { final GeckoBundle bundle = new GeckoBundle(1); bundle.putString("extensionId", mExtension.id); // The click event will return the popup uri if we should open a popup in // response to clicking on the action button. final GeckoResult popupUri; if (type == TYPE_BROWSER_ACTION) { popupUri = EventDispatcher.getInstance().queryString("GeckoView:BrowserAction:Click", bundle); } else if (type == TYPE_PAGE_ACTION) { popupUri = EventDispatcher.getInstance().queryString("GeckoView:PageAction:Click", bundle); } else { throw new IllegalStateException("Unknown Action type"); } popupUri.accept( uri -> { if (uri == null || uri.isEmpty()) { return; } final ActionDelegate delegate = mExtension.mDelegateController.getActionDelegate(); if (delegate == null) { return; } // The .accept method will be called from the UIThread in this case because // the GeckoResult instance was created on the UIThread @SuppressLint("WrongThread") final GeckoResult popup = delegate.onTogglePopup(mExtension, this); openPopup(popup, uri); }); } /* package */ void openPopup(final GeckoResult popup, final String popupUri) { if (popup == null) { return; } popup.accept( session -> { if (session == null) { return; } session.getSettings().setIsPopup(true); session.loadUri(popupUri); }); } } /** * Receives updates whenever a Browser action or a Page action has been defined by an extension. * *

This delegate will receive the default action when registered with {@link * WebExtension#setActionDelegate}. To receive {@link GeckoSession}-specific overrides you can use * {@link SessionController#setActionDelegate}. */ public interface ActionDelegate { /** * Called whenever a browser action is defined or updated. * *

This method will be called whenever an extension that defines a browser action is * registered or the properties of the Action are updated. * *

See also * WebExtensions/API/browserAction , * WebExtensions/manifest.json/browser_action . * * @param extension The extension that defined this browser action. * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action * override applies. null if action is the new default value. * @param action {@link Action} containing the override values for this {@link GeckoSession} or * the default value if session is null. */ @UiThread default void onBrowserAction( final @NonNull WebExtension extension, final @Nullable GeckoSession session, final @NonNull Action action) {} /** * Called whenever a page action is defined or updated. * *

This method will be called whenever an extension that defines a page action is registered * or the properties of the Action are updated. * *

See also * WebExtensions/API/pageAction , * WebExtensions/manifest.json/page_action . * * @param extension The extension that defined this page action. * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action * override applies. null if action is the new default value. * @param action {@link Action} containing the override values for this {@link GeckoSession} or * the default value if session is null. */ @UiThread default void onPageAction( final @NonNull WebExtension extension, final @Nullable GeckoSession session, final @NonNull Action action) {} /** * Called whenever the action wants to toggle a popup view. * * @param extension The extension that wants to display a popup * @param action The action where the popup is defined * @return A GeckoSession that will be used to display the pop-up, null if no popup will be * displayed. */ @UiThread @Nullable default GeckoResult onTogglePopup( final @NonNull WebExtension extension, final @NonNull Action action) { return null; } /** * Called whenever the action wants to open a popup view. * * @param extension The extension that wants to display a popup * @param action The action where the popup is defined * @return A GeckoSession that will be used to display the pop-up, null if no popup will be * displayed. */ @UiThread @Nullable default GeckoResult onOpenPopup( final @NonNull WebExtension extension, final @NonNull Action action) { return null; } } /** Extension thrown when an error occurs during extension installation. */ public static class InstallException extends Exception { public static class ErrorCodes { /** The download failed due to network problems. */ public static final int ERROR_NETWORK_FAILURE = -1; /** The downloaded file did not match the provided hash. */ public static final int ERROR_INCORRECT_HASH = -2; /** The downloaded file seems to be corrupted in some way. */ public static final int ERROR_CORRUPT_FILE = -3; /** An error occurred trying to write to the filesystem. */ public static final int ERROR_FILE_ACCESS = -4; /** The extension must be signed and isn't. */ public static final int ERROR_SIGNEDSTATE_REQUIRED = -5; /** The downloaded extension had a different type than expected. */ public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6; /** The downloaded extension had a different version than expected. */ public static final int ERROR_UNEXPECTED_ADDON_VERSION = -9; /** The extension did not have the expected ID. */ public static final int ERROR_INCORRECT_ID = -7; /** The extension did not have the expected ID. */ public static final int ERROR_INVALID_DOMAIN = -8; /** The extension is blocklisted. */ public static final int ERROR_BLOCKLISTED = -10; /** The extension is incompatible. */ public static final int ERROR_INCOMPATIBLE = -11; /** The extension type is not supported by the platform. */ public static final int ERROR_UNSUPPORTED_ADDON_TYPE = -12; /** The extension install was canceled. */ public static final int ERROR_USER_CANCELED = -100; /** The extension install was postponed until restart. */ public static final int ERROR_POSTPONED = -101; /** For testing. */ protected ErrorCodes() {} } /** These states should match gecko's AddonManager.STATE_* constants. */ private static class StateCodes { public static final int STATE_POSTPONED = 7; public static final int STATE_CANCELED = 12; } /* package */ static Throwable fromQueryException(final Throwable exception) { final EventDispatcher.QueryException queryException = (EventDispatcher.QueryException) exception; final Object response = queryException.data; if (response instanceof GeckoBundle && ((GeckoBundle) response).containsKey("installError")) { final GeckoBundle bundle = (GeckoBundle) response; int errorCode = bundle.getInt("installError"); final int installState = bundle.getInt("state"); if (errorCode == 0 && installState == StateCodes.STATE_CANCELED && bundle.getBoolean("cancelledByUser")) { errorCode = ErrorCodes.ERROR_USER_CANCELED; } else if (errorCode == 0 && installState == StateCodes.STATE_POSTPONED) { errorCode = ErrorCodes.ERROR_POSTPONED; } return new WebExtension.InstallException(errorCode); } else { return new Exception(response.toString()); } } @Retention(RetentionPolicy.SOURCE) @IntDef( value = { ErrorCodes.ERROR_NETWORK_FAILURE, ErrorCodes.ERROR_INCORRECT_HASH, ErrorCodes.ERROR_CORRUPT_FILE, ErrorCodes.ERROR_FILE_ACCESS, ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED, ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE, ErrorCodes.ERROR_UNEXPECTED_ADDON_VERSION, ErrorCodes.ERROR_INCORRECT_ID, ErrorCodes.ERROR_INVALID_DOMAIN, ErrorCodes.ERROR_BLOCKLISTED, ErrorCodes.ERROR_INCOMPATIBLE, ErrorCodes.ERROR_USER_CANCELED, ErrorCodes.ERROR_POSTPONED, ErrorCodes.ERROR_UNSUPPORTED_ADDON_TYPE, }) public @interface Codes {} /** One of {@link ErrorCodes} that provides more information about this exception. */ public final @Codes int code; /** An optional name of the extension that caused the exception. */ public final @Nullable String extensionName; /** For testing */ protected InstallException() { this.code = ErrorCodes.ERROR_NETWORK_FAILURE; this.extensionName = null; } @Override public String toString() { return "InstallException: " + code; } /* package */ InstallException(final @Codes int code, final @Nullable String extensionName) { this.code = code; this.extensionName = extensionName; } /* package */ InstallException(final @Codes int code) { this.code = code; this.extensionName = null; } } /** * Set the Action delegate for this WebExtension. * *

This delegate will receive updates every time the default Action value changes. * *

To listen for {@link GeckoSession}-specific updates, use {@link * SessionController#setActionDelegate} * * @param delegate {@link ActionDelegate} that will receive updates. */ @AnyThread public void setActionDelegate(final @Nullable ActionDelegate delegate) { mDelegateController.onActionDelegate(delegate); final GeckoBundle bundle = new GeckoBundle(1); bundle.putString("extensionId", id); if (delegate != null) { EventDispatcher.getInstance().dispatch("GeckoView:ActionDelegate:Attached", bundle); } } /** * Describes the signed status for a {@link WebExtension}. * *

See Add-on signing * in Firefox. */ public static class SignedStateFlags { // Keep in sync with AddonManager.sys.mjs /** * This extension may be signed but by a certificate that doesn't chain to our our trusted * certificate. */ public static final int UNKNOWN = -1; /** This extension is unsigned. */ public static final int MISSING = 0; /** This extension has been preliminarily reviewed. */ public static final int PRELIMINARY = 1; /** This extension has been fully reviewed. */ public static final int SIGNED = 2; /** This extension is a system add-on. */ public static final int SYSTEM = 3; /** This extension is signed with a "Mozilla Extensions" certificate. */ public static final int PRIVILEGED = 4; /* package */ static final int LAST = PRIVILEGED; } @Retention(RetentionPolicy.SOURCE) @IntDef({ SignedStateFlags.UNKNOWN, SignedStateFlags.MISSING, SignedStateFlags.PRELIMINARY, SignedStateFlags.SIGNED, SignedStateFlags.SYSTEM, SignedStateFlags.PRIVILEGED }) public @interface SignedState {} /** * Describes the blocklist state for a {@link WebExtension}. See Add-ons that * cause stability or security issues are put on a blocklist . */ public static class BlocklistStateFlags { // Keep in sync with nsIBlocklistService.idl /** This extension does not appear in the blocklist. */ public static final int NOT_BLOCKED = 0; /** * This extension is in the blocklist but the problem is not severe enough to warant forcibly * blocking. */ public static final int SOFTBLOCKED = 1; /** This extension should be blocked and never used. */ public static final int BLOCKED = 2; /** This extension is considered outdated, and there is a known update available. */ public static final int OUTDATED = 3; /** This extension is vulnerable and there is an update. */ public static final int VULNERABLE_UPDATE_AVAILABLE = 4; /** This extension is vulnerable and there is no update. */ public static final int VULNERABLE_NO_UPDATE = 5; } @Retention(RetentionPolicy.SOURCE) @IntDef({ BlocklistStateFlags.NOT_BLOCKED, BlocklistStateFlags.SOFTBLOCKED, BlocklistStateFlags.BLOCKED, BlocklistStateFlags.OUTDATED, BlocklistStateFlags.VULNERABLE_UPDATE_AVAILABLE, BlocklistStateFlags.VULNERABLE_NO_UPDATE }) public @interface BlocklistState {} public static class DisabledFlags { /** The extension has been disabled by the user */ public static final int USER = 1 << 1; /** * The extension has been disabled by the blocklist. The details of why this extension was * blocked can be found in {@link MetaData#blocklistState}. */ public static final int BLOCKLIST = 1 << 2; /** * The extension has been disabled by the application. To enable the extension you can use * {@link WebExtensionController#enable} passing in {@link * WebExtensionController.EnableSource#APP} as source. */ public static final int APP = 1 << 3; /** The extension has been disabled because it is not correctly signed. */ public static final int SIGNATURE = 1 << 4; /** * The extension has been disabled because it is not compatible with the application version. */ public static final int APP_VERSION = 1 << 5; } @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, value = { DisabledFlags.USER, DisabledFlags.BLOCKLIST, DisabledFlags.APP, DisabledFlags.SIGNATURE, DisabledFlags.APP_VERSION, }) public @interface EnabledFlags {} /** Provides information about a {@link WebExtension}. */ public class MetaData { /** * Main {@link Image} branding for this {@link WebExtension}. Can be used when displaying * prompts. */ public final @NonNull Image icon; /** * API permissions requested or granted to this extension. * *

Permission identifiers match entries in the manifest, see * API permissions . */ public final @NonNull String[] permissions; /** * API optional * permissions requested or granted to this extension. * *

Permission identifiers match entries in the manifest, see * API permissions . */ public final @NonNull String[] optionalPermissions; /** * API optional * permissions granted to this extension. * *

Permission identifiers match entries in the manifest, see * API permissions . */ public final @NonNull String[] grantedOptionalPermissions; /** * API * optional origin permissions requested or granted to this extension. * *

Permission identifiers match entries in the manifest, see * API permissions . */ public final @NonNull String[] optionalOrigins; /** * API * optional origin permissions granted to this extension. * *

Permission identifiers match entries in the manifest, see * API permissions . */ public final @NonNull String[] grantedOptionalOrigins; /** * Host permissions requested or granted to this extension. * *

See * Host permissions . */ public final @NonNull String[] origins; /** * Branding name for this extension. * *

See * manifest.json/name */ public final @Nullable String name; /** * Branding description for this extension. This string will be localized using the current * GeckoView language setting. * *

See * manifest.json/description */ public final @Nullable String description; /** The full description of this extension. See: `AddonWrapper.fullDescription`. */ public final @Nullable String fullDescription; /** The average rating of this extension. See: `AddonWrapper.averageRating`. */ public final double averageRating; /** The review count for this extension. See: `AddonWrapper.reviewCount`. */ public final int reviewCount; /** The link to the review page for this extension. See `AddonWrapper.reviewURL`. */ public final @Nullable String reviewUrl; /** * The string representation of the date that this extension was most recently updated * (simplified ISO 8601 format). See `AddonWrapper.updateDate`. */ public final @Nullable String updateDate; /** The URL used to install this extension. See: `AddonInternal.sourceURI`. */ public final @Nullable String downloadUrl; /** * Version string for this extension. * *

See * manifest.json/version */ public final @NonNull String version; /** * Creator name as provided in the manifest. * *

See * manifest.json/developer */ public final @Nullable String creatorName; /** * Creator url as provided in the manifest. * *

See * manifest.json/developer */ public final @Nullable String creatorUrl; /** * Homepage url as provided in the manifest. * *

See * manifest.json/homepage_url */ public final @Nullable String homepageUrl; /** * Options page as provided in the manifest. * *

See * manifest.json/options_ui */ public final @Nullable String optionsPageUrl; /** * Whether the options page should be open in a Tab or not. * *

See * manifest.json/options_ui#Syntax */ public final boolean openOptionsPageInTab; /** * Whether or not this is a recommended extension. * *

See Recommended * Extensions program */ public final boolean isRecommended; /** * Blocklist status for this extension. * *

See * Add-ons that cause stability or security issues are put on a blocklist . */ public final @BlocklistState int blocklistState; /** * Signed status for this extension. * *

See Add-on * signing in Firefox. . */ public final @SignedState int signedState; /** * Disabled binary flags for this extension. * *

This will be either equal to 0 if the extension is enabled or will contain * one or more flags from {@link DisabledFlags}. * *

e.g. if the extension has been disabled by the user, the value in {@link * DisabledFlags#USER} will be equal to 1: * *


     *     boolean isUserDisabled = metaData.disabledFlags
     *          & DisabledFlags.USER > 0;
     * 
*/ public final @EnabledFlags int disabledFlags; /** * Root URL for this extension's pages. Can be used to determine if a given URL belongs to this * extension. */ public final @NonNull String baseUrl; /** * Whether this extension is allowed to run in private browsing or not. To modify this value use * {@link WebExtensionController#setAllowedInPrivateBrowsing}. */ public final boolean allowedInPrivateBrowsing; /** Whether this extension is enabled or not. */ public final boolean enabled; /** * Whether this extension is temporary or not. Temporary extensions are not retained and will be * uninstalled when the browser exits. */ public final boolean temporary; /** The link to the AMO detail page for this extension. See `AddonWrapper.amoListingURL`. */ public final @Nullable String amoListingUrl; /** * Indicates how the extension works with private browsing windows. * *

See * manifest.json/incognito */ public final @Nullable String incognito; /** Override for testing. */ protected MetaData() { icon = null; permissions = null; optionalPermissions = null; grantedOptionalPermissions = null; grantedOptionalOrigins = null; optionalOrigins = null; origins = null; name = null; description = null; version = null; creatorName = null; creatorUrl = null; homepageUrl = null; optionsPageUrl = null; openOptionsPageInTab = false; isRecommended = false; blocklistState = BlocklistStateFlags.NOT_BLOCKED; signedState = SignedStateFlags.UNKNOWN; disabledFlags = 0; enabled = true; temporary = false; baseUrl = null; allowedInPrivateBrowsing = false; fullDescription = null; averageRating = 0; reviewCount = 0; reviewUrl = null; updateDate = null; downloadUrl = null; amoListingUrl = null; incognito = null; } /* package */ MetaData(final GeckoBundle bundle) { // We only expose permissions that the embedder should prompt for permissions = bundle.getStringArray("promptPermissions"); optionalPermissions = bundle.getStringArray("optionalPermissions"); grantedOptionalPermissions = bundle.getStringArray("grantedOptionalPermissions"); optionalOrigins = bundle.getStringArray("optionalOrigins"); grantedOptionalOrigins = bundle.getStringArray("grantedOptionalOrigins"); origins = bundle.getStringArray("origins"); description = bundle.getString("description"); version = bundle.getString("version"); creatorName = bundle.getString("creatorName"); creatorUrl = bundle.getString("creatorURL"); homepageUrl = bundle.getString("homepageURL"); name = bundle.getString("name"); optionsPageUrl = bundle.getString("optionsPageURL"); openOptionsPageInTab = bundle.getBoolean("openOptionsPageInTab"); isRecommended = bundle.getBoolean("isRecommended"); blocklistState = bundle.getInt("blocklistState", BlocklistStateFlags.NOT_BLOCKED); enabled = bundle.getBoolean("enabled", false); temporary = bundle.getBoolean("temporary", false); baseUrl = bundle.getString("baseURL"); allowedInPrivateBrowsing = bundle.getBoolean("privateBrowsingAllowed", false); fullDescription = bundle.getString("fullDescription"); averageRating = bundle.getDouble("averageRating"); reviewCount = bundle.getInt("reviewCount"); reviewUrl = bundle.getString("reviewURL"); updateDate = bundle.getString("updateDate"); downloadUrl = bundle.getString("downloadUrl"); amoListingUrl = bundle.getString("amoListingURL"); incognito = bundle.getString("incognito"); final int signedState = bundle.getInt("signedState", SignedStateFlags.UNKNOWN); if (signedState <= SignedStateFlags.LAST) { this.signedState = signedState; } else { Log.e(LOGTAG, "Unrecognized signed state: " + signedState); this.signedState = SignedStateFlags.UNKNOWN; } int disabledFlags = 0; final String[] disabledFlagsString = bundle.getStringArray("disabledFlags"); for (final String flag : disabledFlagsString) { if (flag.equals("userDisabled")) { disabledFlags |= DisabledFlags.USER; } else if (flag.equals("blocklistDisabled")) { disabledFlags |= DisabledFlags.BLOCKLIST; } else if (flag.equals("appDisabled")) { disabledFlags |= DisabledFlags.APP; } else if (flag.equals("signatureDisabled")) { disabledFlags |= DisabledFlags.SIGNATURE; } else if (flag.equals("appVersionDisabled")) { disabledFlags |= DisabledFlags.APP_VERSION; } else { Log.e(LOGTAG, "Unrecognized disabledFlag state: " + flag); } } this.disabledFlags = disabledFlags; if (bundle.containsKey("icons")) { icon = Image.fromSizeSrcBundle(bundle.getBundle("icons")); } else { icon = null; } } } // TODO: make public bug 1595822 @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, value = { Context.NONE, Context.BOOKMARK, Context.BROWSER_ACTION, Context.PAGE_ACTION, Context.TAB, Context.TOOLS_MENU }) public @interface ContextFlags {} /** * Flags to determine which contexts a menu item should be shown in. See * menus.ContextType. */ static class Context { /** Shows the menu item in no contexts. */ static final int NONE = 0; /** * Shows the menu item when the user context-clicks an item on the bookmarks toolbar, bookmarks * menu, bookmarks sidebar, or Library window. */ static final int BOOKMARK = 1 << 1; /** Shows the menu item when the user context-clicks the extension's browser action. */ static final int BROWSER_ACTION = 1 << 2; /** Shows the menu item when the user context-clicks on the extension's page action. */ static final int PAGE_ACTION = 1 << 3; /** Shows when the user context-clicks on a tab (such as the element on the tab bar.) */ static final int TAB = 1 << 4; /** Adds the item to the browser's tools menu. */ static final int TOOLS_MENU = 1 << 5; } // TODO: make public bug 1595822 /** * Represents an addition to the context menu by an extension. * *

In the menus * API, all elements added by one extension should be collapsed under one header. This class * represents all of one extension's menu items, as well as the icon that should be used with that * header. */ static class Menu { /** List of menu items that belong to this extension. */ final @NonNull List items; /** Icon for this extension. */ final @Nullable Image icon; /** Title for the menu header. */ final @Nullable String title; /** The extension adding this Menu to the context menu. */ final @NonNull WebExtension extension; /* package */ Menu(final @NonNull WebExtension extension, final GeckoBundle bundle) { this.extension = extension; title = bundle.getString("title", ""); final GeckoBundle[] items = bundle.getBundleArray("items"); this.items = new ArrayList<>(); if (items != null) { for (final GeckoBundle item : items) { this.items.add(new MenuItem(this.extension, item)); } } if (bundle.containsKey("icon")) { icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); } else { icon = null; } } /** Notifies the extension that a user has opened the context menu. */ void show() { final GeckoBundle bundle = new GeckoBundle(1); bundle.putString("extensionId", extension.id); EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuShow", bundle); } /** Notifies the extension that a user has hidden the context menu. */ void hide() { final GeckoBundle bundle = new GeckoBundle(1); bundle.putString("extensionId", extension.id); EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuHide", bundle); } } // TODO: make public bug 1595822 /** * Represents an item in the menu. * *

If there is only one menu item in the list, the embedder should display that item as itself, * not under a header. */ static class MenuItem { @Retention(RetentionPolicy.SOURCE) @IntDef( flag = false, value = {MenuType.NORMAL, MenuType.CHECKBOX, MenuType.RADIO, MenuType.SEPARATOR}) public @interface Type {} /** A set of constants that represents the display type of this menu item. */ static class MenuType { /** * This represents a menu item that just displays a label. * *

See * menus.ItemType.normal */ static final int NORMAL = 0; /** * This represents a menu item that can be selected and deselected. * *

See * menus.ItemType.checkbox */ static final int CHECKBOX = 1; /** * This represents a menu item that is one of a group of choices. All menu items for an * extension that are of type radio are part of one radio group. * *

See * menus.ItemType.radio */ static final int RADIO = 2; /** * This represents a line separating elements. * *

See * menus.ItemType.separator */ static final int SEPARATOR = 3; } /** * Direct children for this menu item. These should be displayed as a sub-menu. * *

See * createProperties.parentId */ final @Nullable List children; /** One of the {@link Type} constants. Determines the type of the action. */ final @Type int type; /** * The id of this menu item. See * createProperties.id */ final @Nullable String id; /** Determines if the menu item should be currently displayed. */ final boolean visible; /** The title to be displayed for this menu item. */ final @Nullable String title; /** Whether or not the menu item is initially checked. Defaults to false. */ final boolean checked; /** Contexts that this menu item should be shown in. */ final @ContextFlags int contexts; /** Icon for this menu item. */ final @Nullable Image icon; final WebExtension mExtension; /** * Creates a new menu item using a bundle and a reference to the extension that this item * belongs to. * * @param extension WebExtension object. * @param bundle GeckoBundle containing the item information. */ /* package */ MenuItem(final WebExtension extension, final GeckoBundle bundle) { title = bundle.getString("title"); mExtension = extension; checked = bundle.getBoolean("checked", false); visible = bundle.getBoolean("visible", true); id = bundle.getString("id"); contexts = bundle.getInt("contexts"); type = bundle.getInt("type"); children = new ArrayList<>(); if (bundle.containsKey("icon")) { icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); } else { icon = null; } } /** Notifies the extension that the user has clicked on this menu item. */ void click() { final GeckoBundle bundle = new GeckoBundle(2); bundle.putString("menuId", this.id); bundle.putString("extensionId", mExtension.id); EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuClick", bundle); } } public interface DownloadDelegate { /** * Method that is called when Web Extension requests a download (when downloads.download() is * called in Web Extension) * * @param source - Web Extension that requested the download * @param request - contains the {@link WebRequest} and additional parameters for the request * @return {@link DownloadInitData} instance */ @AnyThread @Nullable default GeckoResult onDownload( @NonNull final WebExtension source, @NonNull final DownloadRequest request) { return null; } } /** * Set the download delegate for this extension. This delegate will be invoked whenever this * extension tries to use the `downloads` WebExtension API. * *

See also WebExtensions/API/downloads. * * @param delegate the {@link DownloadDelegate} instance for this extension. */ @UiThread public void setDownloadDelegate(final @Nullable DownloadDelegate delegate) { mDelegateController.onDownloadDelegate(delegate); } /** * Get the download delegate for this extension. * *

See also WebExtensions * downloads API. * * @return The {@link DownloadDelegate} instance for this extension. */ @UiThread @Nullable public DownloadDelegate getDownloadDelegate() { return mDelegateController.getDownloadDelegate(); } /** * Represents a download for downloads * API Instantiate using {@link WebExtensionController#createDownload} */ public static class Download { /** * Represents a unique identifier for the downloaded item that is persistent across browser * sessions */ public final int id; /** * For testing. * * @param id - integer id for the download item */ protected Download(final int id) { this.id = id; } /* package */ void setDelegate(final Delegate delegate) {} /** * Updates the download state. This will trigger a call to downloads.onChanged * event to the corresponding `DownloadItem` on the extension side. * * @param data - current metadata associated with the download. {@link Download.Info} * implementation instance * @return GeckoResult with nothing or error inside */ @Nullable @UiThread public GeckoResult update(final @NonNull Download.Info data) { final GeckoBundle bundle = new GeckoBundle(12); bundle.putInt("downloadItemId", this.id); bundle.putString("filename", data.filename()); bundle.putString("mime", data.mime()); bundle.putString("startTime", String.valueOf(data.startTime())); bundle.putString("endTime", data.endTime() == null ? null : String.valueOf(data.endTime())); bundle.putInt("state", data.state()); bundle.putBoolean("canResume", data.canResume()); bundle.putBoolean("paused", data.paused()); final Integer error = data.error(); if (error != null) { bundle.putInt("error", error); } bundle.putLong("totalBytes", data.totalBytes()); bundle.putLong("fileSize", data.fileSize()); bundle.putBoolean("exists", data.fileExists()); return EventDispatcher.getInstance() .queryVoid("GeckoView:WebExtension:DownloadChanged", bundle) .map( null, e -> { if (e instanceof EventDispatcher.QueryException) { final EventDispatcher.QueryException queryException = (EventDispatcher.QueryException) e; if (queryException.data instanceof String) { return new IllegalArgumentException((String) queryException.data); } } return e; }); } /* package */ interface Delegate { default GeckoResult onPause( final WebExtension source, final WebExtension.Download download) { return null; } default GeckoResult onResume( final WebExtension source, final WebExtension.Download download) { return null; } default GeckoResult onCancel( final WebExtension source, final WebExtension.Download download) { return null; } default GeckoResult onErase( final WebExtension source, final WebExtension.Download download) { return null; } default GeckoResult onOpen( final WebExtension source, final WebExtension.Download download) { return null; } default GeckoResult onRemoveFile( final WebExtension source, final WebExtension.Download download) { return null; } } /** * Represents a download in progress where the app is currently receiving data from the server. * See also {@link Info#state()}. */ @Retention(RetentionPolicy.SOURCE) @IntDef({STATE_IN_PROGRESS, STATE_INTERRUPTED, STATE_COMPLETE}) public @interface DownloadState {} /** Download is in progress. Default state */ public static final int STATE_IN_PROGRESS = 0; /** An error broke the connection with the server. */ public static final int STATE_INTERRUPTED = 1; /** The download completed successfully. */ public static final int STATE_COMPLETE = 2; /** * Represents a possible reason why a download was interrupted. See also {@link Info#error()}. */ @Retention(RetentionPolicy.SOURCE) @IntDef({ INTERRUPT_REASON_NO_INTERRUPT, INTERRUPT_REASON_FILE_FAILED, INTERRUPT_REASON_FILE_ACCESS_DENIED, INTERRUPT_REASON_FILE_NO_SPACE, INTERRUPT_REASON_FILE_NAME_TOO_LONG, INTERRUPT_REASON_FILE_TOO_LARGE, INTERRUPT_REASON_FILE_VIRUS_INFECTED, INTERRUPT_REASON_FILE_TRANSIENT_ERROR, INTERRUPT_REASON_FILE_BLOCKED, INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED, INTERRUPT_REASON_FILE_TOO_SHORT, INTERRUPT_REASON_NETWORK_FAILED, INTERRUPT_REASON_NETWORK_TIMEOUT, INTERRUPT_REASON_NETWORK_DISCONNECTED, INTERRUPT_REASON_NETWORK_SERVER_DOWN, INTERRUPT_REASON_NETWORK_INVALID_REQUEST, INTERRUPT_REASON_SERVER_FAILED, INTERRUPT_REASON_SERVER_NO_RANGE, INTERRUPT_REASON_SERVER_BAD_CONTENT, INTERRUPT_REASON_SERVER_UNAUTHORIZED, INTERRUPT_REASON_SERVER_CERT_PROBLEM, INTERRUPT_REASON_SERVER_FORBIDDEN, INTERRUPT_REASON_USER_CANCELED, INTERRUPT_REASON_USER_SHUTDOWN, INTERRUPT_REASON_CRASH }) public @interface DownloadInterruptReason {} // File-related errors public static final int INTERRUPT_REASON_NO_INTERRUPT = 0; public static final int INTERRUPT_REASON_FILE_FAILED = 1; public static final int INTERRUPT_REASON_FILE_ACCESS_DENIED = 2; public static final int INTERRUPT_REASON_FILE_NO_SPACE = 3; public static final int INTERRUPT_REASON_FILE_NAME_TOO_LONG = 4; public static final int INTERRUPT_REASON_FILE_TOO_LARGE = 5; public static final int INTERRUPT_REASON_FILE_VIRUS_INFECTED = 6; public static final int INTERRUPT_REASON_FILE_TRANSIENT_ERROR = 7; public static final int INTERRUPT_REASON_FILE_BLOCKED = 8; public static final int INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED = 9; public static final int INTERRUPT_REASON_FILE_TOO_SHORT = 10; // Network-related errors public static final int INTERRUPT_REASON_NETWORK_FAILED = 11; public static final int INTERRUPT_REASON_NETWORK_TIMEOUT = 12; public static final int INTERRUPT_REASON_NETWORK_DISCONNECTED = 13; public static final int INTERRUPT_REASON_NETWORK_SERVER_DOWN = 14; public static final int INTERRUPT_REASON_NETWORK_INVALID_REQUEST = 15; // Server-related errors public static final int INTERRUPT_REASON_SERVER_FAILED = 16; public static final int INTERRUPT_REASON_SERVER_NO_RANGE = 17; public static final int INTERRUPT_REASON_SERVER_BAD_CONTENT = 18; public static final int INTERRUPT_REASON_SERVER_UNAUTHORIZED = 19; public static final int INTERRUPT_REASON_SERVER_CERT_PROBLEM = 20; public static final int INTERRUPT_REASON_SERVER_FORBIDDEN = 21; // User-related errors public static final int INTERRUPT_REASON_USER_CANCELED = 22; public static final int INTERRUPT_REASON_USER_SHUTDOWN = 23; // Miscellaneous public static final int INTERRUPT_REASON_CRASH = 24; /** * Interface for communicating the state of downloads to Web Extensions. See also WebExtensions/API/downloads/DownloadItem */ public interface Info { /** * @return A number representing the number of bytes received so far from the host during the * download This does not take file compression into consideration */ @UiThread default long bytesReceived() { return 0; } /** * @return boolean indicating whether a currently-interrupted (e.g. paused) download can be * resumed from the point where it was interrupted */ @UiThread default boolean canResume() { return false; } /** * @return A number representing the time when this download ended. This is null if the * download has not yet finished. */ @Nullable @UiThread default Long endTime() { return null; } /** * @return One of Interrupt * Reason constants denoting the error reason. */ @Nullable @UiThread default @DownloadInterruptReason Integer error() { return null; } /** * @return the estimated number of milliseconds between the UNIX epoch and when this download * is estimated to be completed. This is null if it is not known. */ @Nullable @UiThread default Long estimatedEndTime() { return null; } /** * @return boolean indicating whether a downloaded file still exists */ @UiThread default boolean fileExists() { return false; } /** * @return the filename. */ @NonNull @UiThread default String filename() { return ""; } /** * @return the total number of bytes in the whole file, after decompression. A value of -1 * means that the total file size is unknown. */ @UiThread default long fileSize() { return -1; } /** * @return the downloaded file's MIME type */ @NonNull @UiThread default String mime() { return ""; } /** * @return boolean indicating whether the download is paused i.e. if the download has stopped * reading data from the host but has kept the connection open */ @UiThread default boolean paused() { return false; } /** * @return String representing the downloaded file's referrer */ @NonNull @UiThread default String referrer() { return ""; } /** * @return the number of milliseconds between the UNIX epoch and when this download began */ @UiThread default long startTime() { return -1; } /** * @return a new state; one of the state constants to indicate whether the download is in * progress, interrupted or complete */ @UiThread default @DownloadState int state() { return STATE_IN_PROGRESS; } /** * @return total number of bytes in the file being downloaded. This does not take file * compression into consideration. A value of -1 here means that the total number of bytes * is unknown */ @UiThread default long totalBytes() { return -1; } } @NonNull /* package */ static GeckoBundle downloadInfoToBundle(final @NonNull Info data) { final GeckoBundle dataBundle = new GeckoBundle(); dataBundle.putLong("bytesReceived", data.bytesReceived()); dataBundle.putBoolean("canResume", data.canResume()); dataBundle.putBoolean("exists", data.fileExists()); dataBundle.putString("filename", data.filename()); dataBundle.putLong("fileSize", data.fileSize()); dataBundle.putString("mime", data.mime()); dataBundle.putBoolean("paused", data.paused()); dataBundle.putString("referrer", data.referrer()); dataBundle.putString("startTime", String.valueOf(data.startTime())); dataBundle.putInt("state", data.state()); dataBundle.putLong("totalBytes", data.totalBytes()); final Long endTime = data.endTime(); if (endTime != null) { dataBundle.putString("endTime", endTime.toString()); } final Integer error = data.error(); if (error != null) { dataBundle.putInt("error", error); } final Long estimatedEndTime = data.estimatedEndTime(); if (estimatedEndTime != null) { dataBundle.putString("estimatedEndTime", estimatedEndTime.toString()); } return dataBundle; } } /** Represents Web Extension API specific download request */ public static class DownloadRequest { /** Regular GeckoView {@link WebRequest} object */ public final @NonNull WebRequest request; /** Optional fetch flags for {@link GeckoWebExecutor} */ public final @GeckoWebExecutor.FetchFlags int downloadFlags; /** A file path relative to the default downloads directory */ public final @Nullable String filename; /** * The action you want taken if there is a filename conflict, as defined here */ public final @ConflictActionFlags int conflictActionFlag; /** * Specifies whether to provide a file chooser dialog to allow the user to select a filename * (true), or not (false) */ public final boolean saveAs; /** * Flag that enables downloads to continue even if they encounter HTTP errors. When false, the * download is canceled when it encounters an HTTP error. When true, the download continues when * an HTTP error is encountered and the HTTP server error is not reported. However, if the * download fails due to file-related, network-related, user-related, or other error, that error * is reported. */ public final boolean allowHttpErrors; @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, value = {CONFLICT_ACTION_UNIQUIFY, CONFLICT_ACTION_OVERWRITE, CONFLICT_ACTION_PROMPT}) public @interface ConflictActionFlags {} /** The app should modify the filename to make it unique */ public static final int CONFLICT_ACTION_UNIQUIFY = 0; /** The app should overwrite the old file with the newly-downloaded file */ public static final int CONFLICT_ACTION_OVERWRITE = 1; /** The app should prompt the user, asking them to choose whether to uniquify or overwrite */ public static final int CONFLICT_ACTION_PROMPT = 1 << 1; protected DownloadRequest(final DownloadRequest.Builder builder) { this.request = builder.mRequest; this.downloadFlags = builder.mDownloadFlags; this.filename = builder.mFilename; this.conflictActionFlag = builder.mConflictActionFlag; this.saveAs = builder.mSaveAs; this.allowHttpErrors = builder.mAllowHttpErrors; } /** * Convenience method to convert a GeckoBundle to a DownloadRequest. * * @param optionsBundle - in the shape of the options object browser.downloads.download() * accepts * @return request - a DownloadRequest instance */ /* package */ static DownloadRequest fromBundle(final GeckoBundle optionsBundle) { final String uri = optionsBundle.getString("url"); final WebRequest.Builder mainRequestBuilder = new WebRequest.Builder(uri); final String method = optionsBundle.getString("method"); if (method != null) { mainRequestBuilder.method(method); if (method.equals("POST")) { final String body = optionsBundle.getString("body"); mainRequestBuilder.body(body); } } final GeckoBundle[] headers = optionsBundle.getBundleArray("headers"); if (headers != null) { for (final GeckoBundle header : headers) { String value = header.getString("value"); if (value == null) { value = header.getString("binaryValue"); } mainRequestBuilder.addHeader(header.getString("name"), value); } } final WebRequest mainRequest = mainRequestBuilder.build(); int downloadFlags = GeckoWebExecutor.FETCH_FLAGS_NONE; final boolean incognito = optionsBundle.getBoolean("incognito"); if (incognito) { downloadFlags |= GeckoWebExecutor.FETCH_FLAGS_PRIVATE; } final boolean allowHttpErrors = optionsBundle.getBoolean("allowHttpErrors"); int conflictActionFlags = CONFLICT_ACTION_UNIQUIFY; final String conflictActionString = optionsBundle.getString("conflictAction"); if (conflictActionString != null) { switch (conflictActionString.toLowerCase(Locale.ROOT)) { case "overwrite": conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_OVERWRITE; break; case "prompt": conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_PROMPT; break; } } final boolean saveAs = optionsBundle.getBoolean("saveAs"); return new Builder(mainRequest) .filename(optionsBundle.getString("filename")) .downloadFlags(downloadFlags) .conflictAction(conflictActionFlags) .saveAs(saveAs) .allowHttpErrors(allowHttpErrors) .build(); } /* package */ static class Builder { private final WebRequest mRequest; private @GeckoWebExecutor.FetchFlags int mDownloadFlags = 0; private String mFilename = null; private @ConflictActionFlags int mConflictActionFlag = CONFLICT_ACTION_UNIQUIFY; private boolean mSaveAs = false; private boolean mAllowHttpErrors = false; /* package */ Builder(final WebRequest request) { this.mRequest = request; } /* package */ Builder downloadFlags(final @GeckoWebExecutor.FetchFlags int flags) { this.mDownloadFlags = flags; return this; } /* package */ Builder filename(final String filename) { this.mFilename = filename; return this; } /* package */ Builder conflictAction(final @ConflictActionFlags int conflictActionFlag) { this.mConflictActionFlag = conflictActionFlag; return this; } /* package */ Builder saveAs(final boolean saveAs) { this.mSaveAs = saveAs; return this; } /* package */ Builder allowHttpErrors(final boolean allowHttpErrors) { this.mAllowHttpErrors = allowHttpErrors; return this; } /* package */ DownloadRequest build() { return new DownloadRequest(this); } } } /** Represents initial information on a download provided to Web Extension */ public static class DownloadInitData { @NonNull public final WebExtension.Download download; @NonNull public final Download.Info initData; public DownloadInitData(final Download download, final Download.Info initData) { this.download = download; this.initData = initData; } } }