diff options
Diffstat (limited to '')
-rw-r--r-- | mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java | 2806 |
1 files changed, 2806 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java new file mode 100644 index 0000000000..da5573b3c7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java @@ -0,0 +1,2806 @@ +/* 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 { + /** + * <code>file:</code> or <code>resource:</code> URI that points to the install location of this + * WebExtension. When the WebExtension is included with the APK the file can be specified using + * the <code>resource://android</code> alias. E.g. + * + * <pre><code> + * resource://android/assets/web_extensions/my_webextension/ + * </code></pre> + * + * Will point to folder <code>/assets/web_extensions/my_webextension/</code> in the APK. + */ + public final @NonNull String location; + + /** Unique identifier for this WebExtension */ + public final @NonNull String id; + + /** {@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. + * + * <p>This message delegate will receive messages from the background script for the native app + * specified in <code>nativeApp</code>. + * + * <p>For messages from content scripts, set a session-specific message delegate using {@link + * SessionController#setMessageDelegate}. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging"> + * WebExtensions/Native_messaging </a> + * + * @param messageDelegate handles messaging between the WebExtension and the app. To send a + * message from the WebExtension use the <code>runtime.sendNativeMessage</code> WebExtension + * API: E.g. + * <pre><code> + * browser.runtime.sendNativeMessage(nativeApp, + * {message: "Hello from WebExtension!"}); + * </code></pre> + * For bidirectional communication, use <code>runtime.connectNative</code>. E.g. in a content + * script: + * <pre><code> + * let port = browser.runtime.connectNative(nativeApp); + * port.onMessage.addListener(message => { + * console.log("Message received from app"); + * }); + * port.postMessage("Ping from WebExtension"); + * </code></pre> + * 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 <code>nativeApp</code> specified in the WebExtension needs to match the <code> + * nativeApp</code> parameter of this method. + * <p>You can unset the message delegate by setting a <code>null</code> messageDelegate. + * @param nativeApp which native app id this message delegate will handle messaging for. Needs to + * match the <code>application</code> parameter of <code>runtime.sendNativeMessage</code> and + * <code>runtime.connectNative</code>. + * @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. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData"> + * WebExtensions/API/browsingData </a> + */ + @UiThread + public interface BrowsingDataDelegate { + /** + * This class represents the current default settings for the "Clear Data" functionality in the + * browser. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/settings"> + * WebExtensions/API/browsingData/settings </a> + */ + @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. + * + * <p>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<Settings> 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<Void> 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<Void> 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<Void> 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<Void> 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 <code> + * runtime.sendNativeMessage</code>. + * + * @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. + * <p>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<Object> onMessage( + final @NonNull String nativeApp, + final @NonNull Object message, + final @NonNull MessageSender sender) { + return null; + } + + /** + * Called whenever the WebExtension connects to an app using <code>runtime.connectNative</code>. + * + * @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. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port"> + * WebExtensions/API/runtime/Port </a>. + * + * @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 <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>. + */ + 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. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove"> + * WebExtensions/API/tabs/remove</a> + * + * @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<AllowOrDeny> 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 <code>loadURI</code> on it. The page will be loaded if this method returns + * GeckoResult.ALLOW. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update"> + * WebExtensions/API/tabs/update</a> + * + * @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 <code>GeckoResult.ALLOW</code> if the tab will be updated, <code>GeckoResult.DENY + * </code> otherwise. + */ + @UiThread + @NonNull + default GeckoResult<AllowOrDeny> onUpdateTab( + final @NonNull WebExtension extension, + final @NonNull GeckoSession session, + final @NonNull UpdateTabDetails details) { + return GeckoResult.deny(); + } + } + + /** + * Provides details about upating a tab with <code>tabs.update</code>. + * + * <p>Whenever a field is not passed in by the extension that value will be <code>null</code>. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update"> + * WebExtensions/API/tabs/update </a>. + */ + public static class UpdateTabDetails { + /** + * Whether the tab should become active. If <code>true</code>, non-active highlighted tabs + * should stop being highlighted. If <code>false</code>, 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 <code>true</code> 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 <code>GeckoResult.ALLOW</code> 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 <code>tabs.create</code>. See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create"> + * WebExtensions/API/tabs/create </a>. + * + * <p>Whenever a field is not passed in by the extension that value will be <code>null</code>. + */ + public static class CreateTabDetails { + /** + * Whether the tab should become active. If <code>true</code>, non-active highlighted tabs + * should stop being highlighted. If <code>false</code>, 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 <code>GeckoResult.ALLOW</code> 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 <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>. + */ + 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<GeckoSession> 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. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>. + * + * @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. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>. + * + * @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<SessionTabDelegate> 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. + * + * <p>If a delegate is already present, this delegate will replace the existing one. + * + * <p>This message delegate will be responsible for handling messaging between a WebExtension + * content script running on the {@link GeckoSession}. + * + * <p>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 <code>nativeApp</code>. + * + * @param extension {@link WebExtension} that this delegate receives messages from. + * @param nativeApp identifier for the native app + * @return The {@link MessageDelegate} attached to the <code>nativeApp</code>. <code>null</code> + * 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. + * + * <p>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. + * + * <p>This delegate will receive messages specific for this session coming from the WebExtension + * <code>tabs</code> 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<TabDelegate> implements BundleEventListener { + private final HashMap<Sender, MessageDelegate> mMessageDelegates; + private final HashMap<String, ActionDelegate> mActionDelegates; + private final HashMap<String, BrowsingDataDelegate> mBrowsingDataDelegates; + private final HashMap<String, TabDelegate> mTabDelegates; + private final HashMap<String, DownloadDelegate> 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. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender"> + * WebExtensions/API/runtime/MessageSender</a> + */ + @UiThread + public static class MessageSender { + /** {@link WebExtension} that sent this message. */ + public final @NonNull WebExtension webExtension; + + /** + * {@link GeckoSession} that sent this message. <code>null</code> 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 + * + * <ul> + * <li>{@link MessageSender#ENV_TYPE_EXTENSION} if the message was sent from a background page + * <li>{@link MessageSender#ENV_TYPE_CONTENT_SCRIPT} if the message was sent from a content + * script + * </ul> + */ + // TODO: Bug 1534640 do we need ENV_TYPE_EXTENSION_PAGE ? + public final @EnvType int environmentType; + + /** + * URL of the frame that sent this message. + * + * <p>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. + * + * <p>Instances of this class may represent the default <code>Action</code> which applies to all + * WebExtension tabs or a tab-specific override. To reconstruct the full <code>Action</code> + * object, you can use {@link Action#withDefault}. + * + * <p>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}. <br> + * See also + * + * <ul> + * <li><a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction"> + * WebExtensions/API/browserAction </a> + * <li><a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction"> + * WebExtensions/API/pageAction </a> + * </ul> + */ + @AnyThread + public static class Action { + /** + * Title of this Action. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/getTitle"> + * pageAction/getTitle</a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getTitle"> + * browserAction/getTitle</a> + */ + public final @Nullable String title; + + /** + * Icon for this Action. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/setIcon"> + * pageAction/setIcon</a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/setIcon"> + * browserAction/setIcon</a> + */ + public final @Nullable Image icon; + + /** + * Whether this action is enabled and should be visible. + * + * <p>Note: for page action, this is <code>true</code> when the extension calls <code> + * pageAction.show</code> and <code>false</code> when the extension calls <code>pageAction.hide + * </code>. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/show"> + * pageAction/show</a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/enabled"> + * browserAction/enabled</a> + */ + public final @Nullable Boolean enabled; + + /** + * Badge text for this action. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeText"> + * browserAction/getBadgeText</a> + */ + public final @Nullable String badgeText; + + /** + * Background color for the badge for this Action. + * + * <p>This method will return an Android color int that can be used in {@link + * android.widget.TextView#setBackgroundColor(int)} and similar methods. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeBackgroundColor"> + * browserAction/getBadgeBackgroundColor</a> + */ + public final @Nullable Integer badgeBackgroundColor; + + /** + * Text color for the badge for this Action. + * + * <p>This method will return an Android color int that can be used in {@link + * android.widget.TextView#setTextColor(int)} and similar methods. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeTextColor"> + * browserAction/getBadgeTextColor</a> + */ + 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 <code>null</code> values from this instance are replaced + * with values from <code>defaultValue</code>. + * @throws IllegalArgumentException if defaultValue is not of the same type, e.g. if this Action + * is a Page Action and default value is a Browser Action. + */ + @NonNull + public Action withDefault(final @NonNull Action defaultValue) { + return new Action(this, defaultValue); + } + + /** + * @see Action#withDefault + */ + private Action(final Action source, final Action defaultValue) { + if (source.type != defaultValue.type) { + throw new IllegalArgumentException("defaultValue must be of the same type."); + } + + type = source.type; + mExtension = source.mExtension; + + title = source.title != null ? source.title : defaultValue.title; + icon = source.icon != null ? source.icon : defaultValue.icon; + 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<String> 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<GeckoSession> popup = delegate.onTogglePopup(mExtension, this); + openPopup(popup, uri); + }); + } + + /* package */ void openPopup(final GeckoResult<GeckoSession> 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. + * + * <p>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. + * + * <p>This method will be called whenever an extension that defines a browser action is + * registered or the properties of the Action are updated. + * + * <p>See also <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction"> + * WebExtensions/API/browserAction </a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_action"> + * WebExtensions/manifest.json/browser_action </a>. + * + * @param extension The extension that defined this browser action. + * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action + * override applies. <code>null</code> if <code>action</code> is the new default value. + * @param action {@link Action} containing the override values for this {@link GeckoSession} or + * the default value if <code>session</code> is <code>null</code>. + */ + @UiThread + default void onBrowserAction( + final @NonNull WebExtension extension, + final @Nullable GeckoSession session, + final @NonNull Action action) {} + + /** + * Called whenever a page action is defined or updated. + * + * <p>This method will be called whenever an extension that defines a page action is registered + * or the properties of the Action are updated. + * + * <p>See also <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction"> + * WebExtensions/API/pageAction </a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/page_action"> + * WebExtensions/manifest.json/page_action </a>. + * + * @param extension The extension that defined this page action. + * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action + * override applies. <code>null</code> if <code>action</code> is the new default value. + * @param action {@link Action} containing the override values for this {@link GeckoSession} or + * the default value if <code>session</code> is <code>null</code>. + */ + @UiThread + default void onPageAction( + final @NonNull WebExtension extension, + final @Nullable GeckoSession session, + final @NonNull Action action) {} + + /** + * Called whenever the action wants to toggle a popup view. + * + * @param extension The extension that wants to display a popup + * @param action The action where the popup is defined + * @return A GeckoSession that will be used to display the pop-up, null if no popup will be + * displayed. + */ + @UiThread + @Nullable + default GeckoResult<GeckoSession> onTogglePopup( + final @NonNull WebExtension extension, final @NonNull Action action) { + return null; + } + + /** + * Called whenever the action wants to open a popup view. + * + * @param extension The extension that wants to display a popup + * @param action The action where the popup is defined + * @return A GeckoSession that will be used to display the pop-up, null if no popup will be + * displayed. + */ + @UiThread + @Nullable + default GeckoResult<GeckoSession> onOpenPopup( + final @NonNull WebExtension extension, final @NonNull Action action) { + return null; + } + } + + /** 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 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) { + 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_USER_CANCELED, + ErrorCodes.ERROR_POSTPONED, + }) + public @interface Codes {} + + /** One of {@link ErrorCodes} that provides more information about this exception. */ + public final @Codes int code; + + /** For testing */ + protected InstallException() { + this.code = ErrorCodes.ERROR_NETWORK_FAILURE; + } + + @Override + public String toString() { + return "InstallException: " + code; + } + + /* package */ InstallException(final @Codes int code) { + this.code = code; + } + } + + /** + * Set the Action delegate for this WebExtension. + * + * <p>This delegate will receive updates every time the default Action value changes. + * + * <p>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}. + * + * <p>See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">Add-on signing + * in Firefox. </a> + */ + public static class SignedStateFlags { + // Keep in sync with AddonManager.jsm + /** + * 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 <a + * href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist">Add-ons that + * cause stability or security issues are put on a blocklist </a>. + */ + 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 <code>source</code>. + */ + public static final int APP = 1 << 3; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {DisabledFlags.USER, DisabledFlags.BLOCKLIST, DisabledFlags.APP}) + 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. + * + * <p>Permission identifiers match entries in the manifest, see <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#API_permissions"> + * API permissions </a>. + */ + public final @NonNull String[] permissions; + + /** + * Host permissions requested or granted to this extension. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#Host_permissions"> + * Host permissions </a>. + */ + public final @NonNull String[] origins; + + /** + * Branding name for this extension. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/name"> + * manifest.json/name </a> + */ + public final @Nullable String name; + + /** + * Branding description for this extension. This string will be localized using the current + * GeckoView language setting. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/description"> + * manifest.json/description </a> + */ + public final @Nullable String description; + + /** + * Version string for this extension. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version"> + * manifest.json/version </a> + */ + public final @NonNull String version; + + /** + * Creator name as provided in the manifest. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer"> + * manifest.json/developer </a> + */ + public final @Nullable String creatorName; + + /** + * Creator url as provided in the manifest. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer"> + * manifest.json/developer </a> + */ + public final @Nullable String creatorUrl; + + /** + * Homepage url as provided in the manifest. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/homepage_url"> + * manifest.json/homepage_url </a> + */ + public final @Nullable String homepageUrl; + + /** + * Options page as provided in the manifest. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui"> + * manifest.json/options_ui </a> + */ + public final @Nullable String optionsPageUrl; + + /** + * Whether the options page should be open in a Tab or not. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui#Syntax"> + * manifest.json/options_ui#Syntax </a> + */ + public final boolean openOptionsPageInTab; + + /** + * Whether or not this is a recommended extension. + * + * <p>See <a href="https://blog.mozilla.org/firefox/firefox-recommended-extensions/">Recommended + * Extensions program </a> + */ + public final boolean isRecommended; + + /** + * Blocklist status for this extension. + * + * <p>See <a href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist"> + * Add-ons that cause stability or security issues are put on a blocklist </a>. + */ + public final @BlocklistState int blocklistState; + + /** + * Signed status for this extension. + * + * <p>See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">Add-on + * signing in Firefox. </a>. + */ + public final @SignedState int signedState; + + /** + * Disabled binary flags for this extension. + * + * <p>This will be either equal to <code>0</code> if the extension is enabled or will contain + * one or more flags from {@link DisabledFlags}. + * + * <p>e.g. if the extension has been disabled by the user, the value in {@link + * DisabledFlags#USER} will be equal to <code>1</code>: + * + * <pre><code> + * boolean isUserDisabled = metaData.disabledFlags + * & DisabledFlags.USER > 0; + * </code></pre> + */ + 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; + + /** Override for testing. */ + protected MetaData() { + icon = null; + permissions = 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; + } + + /* package */ MetaData(final GeckoBundle bundle) { + // We only expose permissions that the embedder should prompt for + permissions = bundle.getStringArray("promptPermissions"); + 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); + + 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 { + 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 <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ContextType> + * menus.ContextType</a>. + */ + 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. + * + * <p>In the <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus>menus</a> + * 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<MenuItem> 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. + * + * <p>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. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType> + * menus.ItemType.normal</a> + */ + static final int NORMAL = 0; + + /** + * This represents a menu item that can be selected and deselected. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType> + * menus.ItemType.checkbox</a> + */ + 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. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType> + * menus.ItemType.radio</a> + */ + static final int RADIO = 2; + + /** + * This represents a line separating elements. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType> + * menus.ItemType.separator</a> + */ + static final int SEPARATOR = 3; + } + + /** + * Direct children for this menu item. These should be displayed as a sub-menu. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create> + * createProperties.parentId</a> + */ + final @Nullable List<MenuItem> children; + + /** One of the {@link Type} constants. Determines the type of the action. */ + final @Type int type; + + /** + * The id of this menu item. See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create> + * createProperties.id</a> + */ + 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<WebExtension.DownloadInitData> 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. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">WebExtensions/API/downloads</a>. + * + * @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. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">WebExtensions + * downloads API</a>. + * + * @return The {@link DownloadDelegate} instance for this extension. + */ + @UiThread + @Nullable + public DownloadDelegate getDownloadDelegate() { + return mDelegateController.getDownloadDelegate(); + } + + /** + * Represents a download for <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">downloads + * API</a> 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 <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged">downloads.onChanged</a> + * 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<Void> 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<Void> onPause( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onResume( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onCancel( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onErase( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onOpen( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> 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 <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/DownloadItem">WebExtensions/API/downloads/DownloadItem</a> + */ + 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 <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/InterruptReason">Interrupt + * Reason</a> 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 <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/FilenameConflictAction">here</a> + */ + 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"); + + final WebExtension.DownloadRequest request = + new WebExtension.DownloadRequest.Builder(mainRequest) + .filename(optionsBundle.getString("filename")) + .downloadFlags(downloadFlags) + .conflictAction(conflictActionFlags) + .saveAs(saveAs) + .allowHttpErrors(allowHttpErrors) + .build(); + + return request; + } + + /* 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; + } + } +} |