summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main/java/org/mozilla/geckoview
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/geckoview')
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java16
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java1445
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java1234
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java685
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java15
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java133
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java1975
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java214
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentInputStream.java149
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashHandler.java587
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java385
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java36
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ExperimentDelegate.java168
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java528
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java2613
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java172
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java819
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java226
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java1072
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java1057
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java1729
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java8425
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java106
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java732
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java42
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java1246
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewInputStream.java163
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java233
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java189
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java54
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java645
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java60
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java246
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java982
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java19
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java182
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java746
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java331
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java171
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java164
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java884
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java131
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java99
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java461
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java405
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TranslationsController.java1358
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java598
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java2894
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java1752
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java117
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java233
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java29
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java165
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java62
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java180
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java248
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java380
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java227
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md1522
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java40
62 files changed, 41819 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java
new file mode 100644
index 0000000000..f8342cbfa7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java
@@ -0,0 +1,16 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+
+/** This represents a decision to allow or deny a request. */
+@AnyThread
+public enum AllowOrDeny {
+ ALLOW,
+ DENY;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java
new file mode 100644
index 0000000000..48ef71b6d6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java
@@ -0,0 +1,1445 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * The Autocomplete API provides a way to leverage Gecko's input form handling for autocompletion.
+ *
+ * <p>The API is split into two parts: 1. Storage-level delegates. 2. User-prompt delegates.
+ *
+ * <p>The storage-level delegates connect Gecko mechanics to the app's storage, e.g., retrieving and
+ * storing of login entries.
+ *
+ * <p>The user-prompt delegates propagate decisions to the app that could require user choice, e.g.,
+ * saving or updating of login entries or the selection of a login entry out of multiple options.
+ *
+ * <p>Throughout the documentation, we will refer to the filling out of input forms using two terms:
+ * 1. Autofill: automatic filling without user interaction. 2. Autocomplete: semi-automatic filling
+ * that requires user prompting for the selection.
+ *
+ * <h2>Examples</h2>
+ *
+ * <h3>Autocomplete/Fetch API</h3>
+ *
+ * <p>GeckoView loads <code>https://example.com</code> which contains (for the purpose of this
+ * example) elements resembling a login form, e.g.,
+ *
+ * <pre><code>
+ * &lt;form&gt;
+ * &lt;input type=&quot;text&quot; placeholder=&quot;username&quot;&gt;
+ * &lt;input type=&quot;password&quot; placeholder=&quot;password&quot;&gt;
+ * &lt;input type=&quot;submit&quot; value=&quot;submit&quot;&gt;
+ * &lt;/form&gt;
+ * </code></pre>
+ *
+ * <p>With the document parsed and the login input fields identified, GeckoView dispatches a <code>
+ * StorageDelegate.onLoginFetch(&quot;example.com&quot;)</code> request to fetch logins for the
+ * given domain.
+ *
+ * <p>Based on the provided login entries, GeckoView will attempt to autofill the login input
+ * fields, if there is only one suitable login entry option.
+ *
+ * <p>In the case of multiple valid login entry options, GeckoView dispatches a <code>
+ * GeckoSession.PromptDelegate.onLoginSelect</code> request, which allows for user-choice
+ * delegation.
+ *
+ * <p>Based on the returned login entries, GeckoView will attempt to autofill/autocomplete the login
+ * input fields.
+ *
+ * <h3>Update API</h3>
+ *
+ * <p>When the user submits some login input fields, GeckoView dispatches another <code>
+ * StorageDelegate.onLoginFetch(&quot;example.com&quot;)</code> request to check whether the
+ * submitted login exists or whether it's a new or updated login entry.
+ *
+ * <p>If the submitted login is already contained as-is in the collection returned by <code>
+ * onLoginFetch</code>, then GeckoView dispatches <code>StorageDelegate.onLoginUsed</code> with the
+ * submitted login entry.
+ *
+ * <p>If the submitted login is a new or updated entry, GeckoView dispatches a sequence of requests
+ * to save/update the login entry, see the Save API example.
+ *
+ * <h3>Save API</h3>
+ *
+ * <p>The user enters new or updated (password) login credentials in some login input fields and
+ * submits explicitely (submit action) or by navigation. GeckoView identifies the entered
+ * credentials and dispatches a <code>GeckoSession.PromptDelegate.onLoginSave(session, request)
+ * </code> with the provided credentials.
+ *
+ * <p>The app may dismiss the prompt request via <code>
+ * return GeckoResult.fromValue(prompt.dismiss())</code> which terminates this saving request, or
+ * confirm it via <code>return GeckoResult.fromValue(prompt.confirm(login))</code> where <code>login
+ * </code> either holds the credentials originally provided by the prompt request (<code>
+ * prompt.logins[0]</code>) or a new or modified login entry.
+ *
+ * <p>The login entry returned in a confirmed save prompt is used to request for saving in the
+ * runtime delegate via <code>StorageDelegate.onLoginSave(login)</code>. If the app has already
+ * stored the entry during the prompt request handling, it may ignore this storage saving request.
+ * <br>
+ *
+ * @see GeckoRuntime#setAutocompleteStorageDelegate <br>
+ * @see GeckoSession#setPromptDelegate <br>
+ * @see GeckoSession.PromptDelegate#onLoginSave <br>
+ * @see GeckoSession.PromptDelegate#onLoginSelect
+ */
+public class Autocomplete {
+ private static final String LOGTAG = "Autocomplete";
+ private static final boolean DEBUG = false;
+
+ protected Autocomplete() {}
+
+ /** Holds credit card information for a specific entry. */
+ public static class CreditCard {
+ private static final String GUID_KEY = "guid";
+ private static final String NAME_KEY = "name";
+ private static final String NUMBER_KEY = "number";
+ private static final String EXP_MONTH_KEY = "expMonth";
+ private static final String EXP_YEAR_KEY = "expYear";
+
+ /** The unique identifier for this login entry. */
+ public final @Nullable String guid;
+
+ /** The full name as it appears on the credit card. */
+ public final @NonNull String name;
+
+ /** The credit card number. */
+ public final @NonNull String number;
+
+ /** The expiration month. */
+ public final @NonNull String expirationMonth;
+
+ /** The expiration year. */
+ public final @NonNull String expirationYear;
+
+ // For tests only.
+ @AnyThread
+ protected CreditCard() {
+ guid = null;
+ name = "";
+ number = "";
+ expirationMonth = "";
+ expirationYear = "";
+ }
+
+ @AnyThread
+ /* package */ CreditCard(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ name = bundle.getString(NAME_KEY, "");
+ number = bundle.getString(NUMBER_KEY, "");
+ expirationMonth = bundle.getString(EXP_MONTH_KEY, "");
+ expirationYear = bundle.getString(EXP_YEAR_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("CreditCard {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", name=")
+ .append(name)
+ .append(", number=")
+ .append(number)
+ .append(", expirationMonth=")
+ .append(expirationMonth)
+ .append(", expirationYear=")
+ .append(expirationYear)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(7);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(NAME_KEY, name);
+ bundle.putString(NUMBER_KEY, number);
+ if (expirationMonth != null) {
+ bundle.putString(EXP_MONTH_KEY, expirationMonth);
+ }
+ if (expirationYear != null) {
+ bundle.putString(EXP_YEAR_KEY, expirationYear);
+ }
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(7);
+ }
+
+ /**
+ * Finalize the {@link CreditCard} instance.
+ *
+ * @return The {@link CreditCard} instance.
+ */
+ @AnyThread
+ public @NonNull CreditCard build() {
+ return new CreditCard(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this credit card entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the name for this credit card entry.
+ *
+ * @param name The full name as it appears on the credit card.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder name(final @Nullable String name) {
+ mBundle.putString(NAME_KEY, name);
+ return this;
+ }
+
+ /**
+ * Set the number for this credit card entry.
+ *
+ * @param number The credit card number string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder number(final @Nullable String number) {
+ mBundle.putString(NUMBER_KEY, number);
+ return this;
+ }
+
+ /**
+ * Set the expiration month for this credit card entry.
+ *
+ * @param expMonth The expiration month string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder expirationMonth(final @Nullable String expMonth) {
+ mBundle.putString(EXP_MONTH_KEY, expMonth);
+ return this;
+ }
+
+ /**
+ * Set the expiration year for this credit card entry.
+ *
+ * @param expYear The expiration year string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder expirationYear(final @Nullable String expYear) {
+ mBundle.putString(EXP_YEAR_KEY, expYear);
+ return this;
+ }
+ }
+ }
+
+ /** Holds address information for a specific entry. */
+ public static class Address {
+ private static final String GUID_KEY = "guid";
+ private static final String NAME_KEY = "name";
+ private static final String GIVEN_NAME_KEY = "givenName";
+ private static final String ADDITIONAL_NAME_KEY = "additionalName";
+ private static final String FAMILY_NAME_KEY = "familyName";
+ private static final String ORGANIZATION_KEY = "organization";
+ private static final String STREET_ADDRESS_KEY = "streetAddress";
+ private static final String ADDRESS_LEVEL1_KEY = "addressLevel1";
+ private static final String ADDRESS_LEVEL2_KEY = "addressLevel2";
+ private static final String ADDRESS_LEVEL3_KEY = "addressLevel3";
+ private static final String POSTAL_CODE_KEY = "postalCode";
+ private static final String COUNTRY_KEY = "country";
+ private static final String TEL_KEY = "tel";
+ private static final String EMAIL_KEY = "email";
+ private static final byte bundleCapacity = 14;
+
+ /** The unique identifier for this address entry. */
+ public final @Nullable String guid;
+
+ /** The full name. */
+ public final @NonNull String name;
+
+ /** The given (first) name. */
+ public final @NonNull String givenName;
+
+ /** An additional name, if available. */
+ public final @NonNull String additionalName;
+
+ /** The family name. */
+ public final @NonNull String familyName;
+
+ /** The name of the company, if applicable. */
+ public final @NonNull String organization;
+
+ /** The (multiline) street address. */
+ public final @NonNull String streetAddress;
+
+ /** The level 1 (province) address. Note: Only use if streetAddress is not provided. */
+ public final @NonNull String addressLevel1;
+
+ /** The level 2 (city/town) address. Note: Only use if streetAddress is not provided. */
+ public final @NonNull String addressLevel2;
+
+ /**
+ * The level 3 (suburb/sublocality) address. Note: Only use if streetAddress is not provided.
+ */
+ public final @NonNull String addressLevel3;
+
+ /** The postal code. */
+ public final @NonNull String postalCode;
+
+ /** The country string in ISO 3166. */
+ public final @NonNull String country;
+
+ /** The telephone number string. */
+ public final @NonNull String tel;
+
+ /** The email address. */
+ public final @NonNull String email;
+
+ // For tests only.
+ @AnyThread
+ protected Address() {
+ guid = null;
+ name = "";
+ givenName = "";
+ additionalName = "";
+ familyName = "";
+ organization = "";
+ streetAddress = "";
+ addressLevel1 = "";
+ addressLevel2 = "";
+ addressLevel3 = "";
+ postalCode = "";
+ country = "";
+ tel = "";
+ email = "";
+ }
+
+ @AnyThread
+ /* package */ Address(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ name = bundle.getString(NAME_KEY, "");
+ givenName = bundle.getString(GIVEN_NAME_KEY, "");
+ additionalName = bundle.getString(ADDITIONAL_NAME_KEY, "");
+ familyName = bundle.getString(FAMILY_NAME_KEY, "");
+ organization = bundle.getString(ORGANIZATION_KEY, "");
+ streetAddress = bundle.getString(STREET_ADDRESS_KEY, "");
+ addressLevel1 = bundle.getString(ADDRESS_LEVEL1_KEY, "");
+ addressLevel2 = bundle.getString(ADDRESS_LEVEL2_KEY, "");
+ addressLevel3 = bundle.getString(ADDRESS_LEVEL3_KEY, "");
+ postalCode = bundle.getString(POSTAL_CODE_KEY, "");
+ country = bundle.getString(COUNTRY_KEY, "");
+ tel = bundle.getString(TEL_KEY, "");
+ email = bundle.getString(EMAIL_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Address {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", givenName=")
+ .append(givenName)
+ .append(", additionalName=")
+ .append(additionalName)
+ .append(", familyName=")
+ .append(familyName)
+ .append(", organization=")
+ .append(organization)
+ .append(", streetAddress=")
+ .append(streetAddress)
+ .append(", addressLevel1=")
+ .append(addressLevel1)
+ .append(", addressLevel2=")
+ .append(addressLevel2)
+ .append(", addressLevel3=")
+ .append(addressLevel3)
+ .append(", postalCode=")
+ .append(postalCode)
+ .append(", country=")
+ .append(country)
+ .append(", tel=")
+ .append(tel)
+ .append(", email=")
+ .append(email)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(bundleCapacity);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(NAME_KEY, name);
+ bundle.putString(GIVEN_NAME_KEY, givenName);
+ bundle.putString(ADDITIONAL_NAME_KEY, additionalName);
+ bundle.putString(FAMILY_NAME_KEY, familyName);
+ bundle.putString(ORGANIZATION_KEY, organization);
+ bundle.putString(STREET_ADDRESS_KEY, streetAddress);
+ bundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1);
+ bundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2);
+ bundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3);
+ bundle.putString(POSTAL_CODE_KEY, postalCode);
+ bundle.putString(COUNTRY_KEY, country);
+ bundle.putString(TEL_KEY, tel);
+ bundle.putString(EMAIL_KEY, email);
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(bundleCapacity);
+ }
+
+ /**
+ * Finalize the {@link Address} instance.
+ *
+ * @return The {@link Address} instance.
+ */
+ @AnyThread
+ public @NonNull Address build() {
+ return new Address(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this address entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the full name for this address entry.
+ *
+ * @param name The full name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder name(final @Nullable String name) {
+ mBundle.putString(NAME_KEY, name);
+ return this;
+ }
+
+ /**
+ * Set the given name for this address entry.
+ *
+ * @param givenName The given name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder givenName(final @Nullable String givenName) {
+ mBundle.putString(GIVEN_NAME_KEY, givenName);
+ return this;
+ }
+
+ /**
+ * Set the additional name for this address entry.
+ *
+ * @param additionalName The additional name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder additionalName(final @Nullable String additionalName) {
+ mBundle.putString(ADDITIONAL_NAME_KEY, additionalName);
+ return this;
+ }
+
+ /**
+ * Set the family name for this address entry.
+ *
+ * @param familyName The family name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder familyName(final @Nullable String familyName) {
+ mBundle.putString(FAMILY_NAME_KEY, familyName);
+ return this;
+ }
+
+ /**
+ * Set the company name for this address entry.
+ *
+ * @param organization The company name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder organization(final @Nullable String organization) {
+ mBundle.putString(ORGANIZATION_KEY, organization);
+ return this;
+ }
+
+ /**
+ * Set the street address for this address entry.
+ *
+ * @param streetAddress The street address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder streetAddress(final @Nullable String streetAddress) {
+ mBundle.putString(STREET_ADDRESS_KEY, streetAddress);
+ return this;
+ }
+
+ /**
+ * Set the level 1 address for this address entry.
+ *
+ * @param addressLevel1 The level 1 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel1(final @Nullable String addressLevel1) {
+ mBundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1);
+ return this;
+ }
+
+ /**
+ * Set the level 2 address for this address entry.
+ *
+ * @param addressLevel2 The level 2 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel2(final @Nullable String addressLevel2) {
+ mBundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2);
+ return this;
+ }
+
+ /**
+ * Set the level 3 address for this address entry.
+ *
+ * @param addressLevel3 The level 3 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel3(final @Nullable String addressLevel3) {
+ mBundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3);
+ return this;
+ }
+
+ /**
+ * Set the postal code for this address entry.
+ *
+ * @param postalCode The postal code string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder postalCode(final @Nullable String postalCode) {
+ mBundle.putString(POSTAL_CODE_KEY, postalCode);
+ return this;
+ }
+
+ /**
+ * Set the country code for this address entry.
+ *
+ * @param country The country string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder country(final @Nullable String country) {
+ mBundle.putString(COUNTRY_KEY, country);
+ return this;
+ }
+
+ /**
+ * Set the telephone number for this address entry.
+ *
+ * @param tel The telephone number string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder tel(final @Nullable String tel) {
+ mBundle.putString(TEL_KEY, tel);
+ return this;
+ }
+
+ /**
+ * Set the email address for this address entry.
+ *
+ * @param email The email address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder email(final @Nullable String email) {
+ mBundle.putString(EMAIL_KEY, email);
+ return this;
+ }
+ }
+ }
+
+ /** Holds login information for a specific entry. */
+ public static class LoginEntry {
+ private static final String GUID_KEY = "guid";
+ private static final String ORIGIN_KEY = "origin";
+ private static final String FORM_ACTION_ORIGIN_KEY = "formActionOrigin";
+ private static final String HTTP_REALM_KEY = "httpRealm";
+ private static final String USERNAME_KEY = "username";
+ private static final String PASSWORD_KEY = "password";
+
+ /** The unique identifier for this login entry. */
+ public final @Nullable String guid;
+
+ /** The origin this login entry applies to. */
+ public final @NonNull String origin;
+
+ /**
+ * The origin this login entry was submitted to. This only applies to form-based login entries.
+ * It's derived from the action attribute set on the form element.
+ */
+ public final @Nullable String formActionOrigin;
+
+ /**
+ * The HTTP realm this login entry was requested for. This only applies to non-form-based login
+ * entries. It's derived from the WWW-Authenticate header set in a HTTP 401 response, see
+ * RFC2617 for details.
+ */
+ public final @Nullable String httpRealm;
+
+ /** The username for this login entry. */
+ public final @NonNull String username;
+
+ /** The password for this login entry. */
+ public final @NonNull String password;
+
+ // For tests only.
+ @AnyThread
+ protected LoginEntry() {
+ guid = null;
+ origin = "";
+ formActionOrigin = null;
+ httpRealm = null;
+ username = "";
+ password = "";
+ }
+
+ @AnyThread
+ /* package */ LoginEntry(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ origin = bundle.getString(ORIGIN_KEY, "");
+ formActionOrigin = bundle.getString(FORM_ACTION_ORIGIN_KEY);
+ httpRealm = bundle.getString(HTTP_REALM_KEY);
+ username = bundle.getString(USERNAME_KEY, "");
+ password = bundle.getString(PASSWORD_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("LoginEntry {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", origin=")
+ .append(origin)
+ .append(", formActionOrigin=")
+ .append(formActionOrigin)
+ .append(", httpRealm=")
+ .append(httpRealm)
+ .append(", username=")
+ .append(username)
+ .append(", password=")
+ .append(password)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(6);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(ORIGIN_KEY, origin);
+ bundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin);
+ bundle.putString(HTTP_REALM_KEY, httpRealm);
+ bundle.putString(USERNAME_KEY, username);
+ bundle.putString(PASSWORD_KEY, password);
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(6);
+ }
+
+ /**
+ * Finalize the {@link LoginEntry} instance.
+ *
+ * @return The {@link LoginEntry} instance.
+ */
+ @AnyThread
+ public @NonNull LoginEntry build() {
+ return new LoginEntry(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this login entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the origin this login entry applies to.
+ *
+ * @param origin The origin string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder origin(final @NonNull String origin) {
+ mBundle.putString(ORIGIN_KEY, origin);
+ return this;
+ }
+
+ /**
+ * Set the origin this login entry was submitted to.
+ *
+ * @param formActionOrigin The form action origin string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder formActionOrigin(final @Nullable String formActionOrigin) {
+ mBundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin);
+ return this;
+ }
+
+ /**
+ * Set the HTTP realm this login entry was requested for.
+ *
+ * @param httpRealm The HTTP realm string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder httpRealm(final @Nullable String httpRealm) {
+ mBundle.putString(HTTP_REALM_KEY, httpRealm);
+ return this;
+ }
+
+ /**
+ * Set the username for this login entry.
+ *
+ * @param username The username string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder username(final @NonNull String username) {
+ mBundle.putString(USERNAME_KEY, username);
+ return this;
+ }
+
+ /**
+ * Set the password for this login entry.
+ *
+ * @param password The password string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder password(final @NonNull String password) {
+ mBundle.putString(PASSWORD_KEY, password);
+ return this;
+ }
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {UsedField.PASSWORD})
+ public @interface LSUsedField {}
+
+ // Sync with UsedField in GeckoViewAutocomplete.sys.mjs.
+ /** Possible login entry field types for {@link StorageDelegate#onLoginUsed}. */
+ public static class UsedField {
+ /** The password field of a login entry. */
+ public static final int PASSWORD = 1;
+
+ protected UsedField() {}
+ }
+
+ /**
+ * Implement this interface to handle runtime login storage requests. Login storage events include
+ * login entry requests for autofill and autocompletion of login input fields. This delegate is
+ * attached to the runtime via {@link GeckoRuntime#setAutocompleteStorageDelegate}.
+ */
+ public interface StorageDelegate {
+ /**
+ * Request login entries for a given domain. While processing the web document, we have
+ * identified elements resembling login input fields suitable for autofill. We will attempt to
+ * match the provided login information to the identified input fields.
+ *
+ * @param domain The domain string for the requested logins.
+ * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing
+ * the existing logins for the given domain.
+ */
+ @UiThread
+ default @Nullable GeckoResult<LoginEntry[]> onLoginFetch(@NonNull final String domain) {
+ return null;
+ }
+
+ /**
+ * Request login entries for all domains.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing
+ * the existing logins.
+ */
+ @UiThread
+ default @Nullable GeckoResult<LoginEntry[]> onLoginFetch() {
+ return null;
+ }
+
+ /**
+ * Request credit card entries. While processing the web document, we have identified elements
+ * resembling credit card input fields suitable for autofill. We will attempt to match the
+ * provided credit card information to the identified input fields.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link CreditCard} containing
+ * the existing credit cards.
+ */
+ @UiThread
+ default @Nullable GeckoResult<CreditCard[]> onCreditCardFetch() {
+ return null;
+ }
+
+ /**
+ * Request address entries. While processing the web document, we have identified elements
+ * resembling address input fields suitable for autofill. We will attempt to match the provided
+ * address information to the identified input fields.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link Address} containing the
+ * existing addresses.
+ */
+ @UiThread
+ default @Nullable GeckoResult<Address[]> onAddressFetch() {
+ return null;
+ }
+
+ /**
+ * Request saving or updating of the given login entry. This is triggered by confirming a {@link
+ * GeckoSession.PromptDelegate#onLoginSave onLoginSave} request.
+ *
+ * @param login The {@link LoginEntry} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onLoginSave(@NonNull final LoginEntry login) {}
+
+ /**
+ * Request saving or updating of the given credit card entry. This is triggered by confirming a
+ * {@link GeckoSession.PromptDelegate#onCreditCardSave onCreditCardSave} request.
+ *
+ * @param creditCard The {@link CreditCard} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onCreditCardSave(@NonNull CreditCard creditCard) {}
+
+ /**
+ * Request saving or updating of the given address entry. This is triggered by confirming a
+ * {@link GeckoSession.PromptDelegate#onAddressSave onAddressSave} request.
+ *
+ * @param address The {@link Address} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onAddressSave(@NonNull Address address) {}
+
+ /**
+ * Notify that the given login was used to autofill login input fields. This is triggered by
+ * autofilling elements with unmodified login entries as provided via {@link #onLoginFetch}.
+ *
+ * @param login The {@link LoginEntry} that was used for the autofilling.
+ * @param usedFields The login entry fields used for autofilling. A combination of {@link
+ * UsedField}.
+ */
+ @UiThread
+ default void onLoginUsed(@NonNull final LoginEntry login, @LSUsedField final int usedFields) {}
+ }
+
+ /**
+ * Abstract base class for Autocomplete options. Extended by {@link Autocomplete.SaveOption} and
+ * {@link Autocomplete.SelectOption}.
+ */
+ public abstract static class Option<T> {
+ /* package */ static final String VALUE_KEY = "value";
+ /* package */ static final String HINT_KEY = "hint";
+
+ public final @NonNull T value;
+ public final int hint;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Option(final @NonNull T value, final int hint) {
+ this.value = value;
+ this.hint = hint;
+ }
+
+ @AnyThread
+ /* package */ abstract @NonNull GeckoBundle toBundle();
+ }
+
+ /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSaveOption}. */
+ public abstract static class SaveOption<T> extends Option<T> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.GENERATED, Hint.LOW_CONFIDENCE})
+ public @interface SaveOptionHint {}
+
+ /** Hint types for login saving requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /** Auto-generated password. Notify but do not prompt the user for saving. */
+ public static final int GENERATED = 1 << 0;
+
+ /**
+ * Potentially non-login data. The form data entered may be not login credentials but other
+ * forms of input like credit card numbers. Note that this could be valid login data in same
+ * cases, e.g., some banks may expect credit card numbers in the username field.
+ */
+ public static final int LOW_CONFIDENCE = 1 << 1;
+
+ protected Hint() {}
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SaveOption(final @NonNull T value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+ }
+
+ /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSelectOption}. */
+ public abstract static class SelectOption<T> extends Option<T> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Hint.NONE,
+ Hint.GENERATED,
+ Hint.INSECURE_FORM,
+ Hint.DUPLICATE_USERNAME,
+ Hint.MATCHING_ORIGIN
+ })
+ public @interface SelectOptionHint {}
+
+ /** Hint types for selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Auto-generated password. A new password-only login entry containing a secure generated
+ * password.
+ */
+ public static final int GENERATED = 1 << 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+
+ /**
+ * The username is shared with another login entry. There are multiple login entries in the
+ * options that share the same username. You may have to disambiguate the login entry, e.g.,
+ * using the last date of modification and its origin.
+ */
+ public static final int DUPLICATE_USERNAME = 1 << 2;
+
+ /**
+ * The login entry's origin matches the login form origin. The login was saved from the same
+ * origin it is being requested for, rather than for a subdomain.
+ */
+ public static final int MATCHING_ORIGIN = 1 << 3;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SelectOption(final @NonNull T value, final @SelectOptionHint int hint) {
+ super(value, hint);
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("SelectOption {");
+ builder.append("value=").append(value).append(", ").append("hint=").append(hint).append("}");
+ return builder.toString();
+ }
+ }
+
+ /** Holds information required to process login saving requests. */
+ public static class LoginSaveOption extends SaveOption<LoginEntry> {
+ /**
+ * Construct a login save option.
+ *
+ * @param value The {@link LoginEntry} login entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ LoginSaveOption(final @NonNull LoginEntry value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a login save option.
+ *
+ * @param value The {@link LoginEntry} login entry to be saved.
+ */
+ public LoginSaveOption(final @NonNull LoginEntry value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process address saving requests. */
+ public static class AddressSaveOption extends SaveOption<Address> {
+ /**
+ * Construct a address save option.
+ *
+ * @param value The {@link Address} address entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ AddressSaveOption(final @NonNull Address value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct an address save option.
+ *
+ * @param value The {@link Address} address entry to be saved.
+ */
+ public AddressSaveOption(final @NonNull Address value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process credit card saving requests. */
+ public static class CreditCardSaveOption extends SaveOption<CreditCard> {
+ /**
+ * Construct a credit card save option.
+ *
+ * @param value The {@link CreditCard} credit card entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ CreditCardSaveOption(
+ final @NonNull CreditCard value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a credit card save option.
+ *
+ * @param value The {@link CreditCard} credit card entry to be saved.
+ */
+ public CreditCardSaveOption(final @NonNull CreditCard value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process login selection requests. */
+ public static class LoginSelectOption extends SelectOption<LoginEntry> {
+ /**
+ * Construct a login select option.
+ *
+ * @param value The {@link LoginEntry} login entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ LoginSelectOption(
+ final @NonNull LoginEntry value, final @SelectOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a login select option.
+ *
+ * @param value The {@link LoginEntry} login entry selection option.
+ */
+ public LoginSelectOption(final @NonNull LoginEntry value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull LoginSelectOption fromBundle(final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final LoginEntry value = new LoginEntry(bundle.getBundle("value"));
+
+ return new LoginSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process credit card selection requests. */
+ public static class CreditCardSelectOption extends SelectOption<CreditCard> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.INSECURE_FORM})
+ public @interface CreditCardSelectHint {}
+
+ /** Hint types for credit card selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link LoginEntry} credit card entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ CreditCardSelectOption(
+ final @NonNull CreditCard value, final @CreditCardSelectHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link CreditCard} credit card entry selection option.
+ */
+ public CreditCardSelectOption(final @NonNull CreditCard value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull CreditCardSelectOption fromBundle(
+ final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final CreditCard value = new CreditCard(bundle.getBundle("value"));
+
+ return new CreditCardSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process address selection requests. */
+ public static class AddressSelectOption extends SelectOption<Address> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.INSECURE_FORM})
+ public @interface AddressSelectHint {}
+
+ /** Hint types for credit card selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link LoginEntry} credit card entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ AddressSelectOption(
+ final @NonNull Address value, final @AddressSelectHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a address select option.
+ *
+ * @param value The {@link Address} address entry selection option.
+ */
+ public AddressSelectOption(final @NonNull Address value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull AddressSelectOption fromBundle(
+ final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final Address value = new Address(bundle.getBundle("value"));
+
+ return new AddressSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /* package */ static final class StorageProxy implements BundleEventListener {
+ private static final String FETCH_LOGIN_EVENT = "GeckoView:Autocomplete:Fetch:Login";
+ private static final String FETCH_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Fetch:CreditCard";
+ private static final String FETCH_ADDRESS_EVENT = "GeckoView:Autocomplete:Fetch:Address";
+ private static final String SAVE_LOGIN_EVENT = "GeckoView:Autocomplete:Save:Login";
+ private static final String SAVE_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Save:CreditCard";
+ private static final String SAVE_ADDRESS_EVENT = "GeckoView:Autocomplete:Save:Address";
+ private static final String USED_LOGIN_EVENT = "GeckoView:Autocomplete:Used:Login";
+
+ private @Nullable StorageDelegate mDelegate;
+
+ public StorageProxy() {}
+
+ private void registerListener() {
+ EventDispatcher.getInstance().dispatch("GeckoView:StorageDelegate:Attached", null);
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ this,
+ FETCH_LOGIN_EVENT,
+ FETCH_CREDIT_CARD_EVENT,
+ FETCH_ADDRESS_EVENT,
+ SAVE_LOGIN_EVENT,
+ SAVE_CREDIT_CARD_EVENT,
+ SAVE_ADDRESS_EVENT,
+ USED_LOGIN_EVENT);
+ }
+
+ private void unregisterListener() {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ this,
+ FETCH_LOGIN_EVENT,
+ FETCH_CREDIT_CARD_EVENT,
+ FETCH_ADDRESS_EVENT,
+ SAVE_LOGIN_EVENT,
+ SAVE_CREDIT_CARD_EVENT,
+ SAVE_ADDRESS_EVENT,
+ USED_LOGIN_EVENT);
+ }
+
+ public synchronized void setDelegate(final @Nullable StorageDelegate delegate) {
+ if (mDelegate == delegate) {
+ return;
+ }
+ if (mDelegate != null) {
+ unregisterListener();
+ }
+
+ mDelegate = delegate;
+
+ if (mDelegate != null) {
+ registerListener();
+ }
+ }
+
+ public synchronized @Nullable StorageDelegate getDelegate() {
+ return mDelegate;
+ }
+
+ @Override // BundleEventListener
+ public synchronized void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ }
+
+ if (mDelegate == null) {
+ if (callback != null) {
+ callback.sendError("No StorageDelegate attached");
+ }
+ return;
+ }
+
+ if (FETCH_LOGIN_EVENT.equals(event)) {
+ final String domain = message.getString("domain");
+ final GeckoResult<Autocomplete.LoginEntry[]> result =
+ domain != null ? mDelegate.onLoginFetch(domain) : mDelegate.onLoginFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ logins -> {
+ if (logins == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] loginBundles = new GeckoBundle[logins.length];
+ for (int i = 0; i < logins.length; ++i) {
+ loginBundles[i] = logins[i].toBundle();
+ }
+
+ return loginBundles;
+ }));
+ } else if (FETCH_CREDIT_CARD_EVENT.equals(event)) {
+ final GeckoResult<Autocomplete.CreditCard[]> result = mDelegate.onCreditCardFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ creditCards -> {
+ if (creditCards == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] creditCardBundles = new GeckoBundle[creditCards.length];
+ for (int i = 0; i < creditCards.length; ++i) {
+ creditCardBundles[i] = creditCards[i].toBundle();
+ }
+
+ return creditCardBundles;
+ }));
+ } else if (FETCH_ADDRESS_EVENT.equals(event)) {
+ final GeckoResult<Autocomplete.Address[]> result = mDelegate.onAddressFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ addresses -> {
+ if (addresses == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] addressBundles = new GeckoBundle[addresses.length];
+ for (int i = 0; i < addresses.length; ++i) {
+ addressBundles[i] = addresses[i].toBundle();
+ }
+
+ return addressBundles;
+ }));
+ } else if (SAVE_LOGIN_EVENT.equals(event)) {
+ final GeckoBundle loginBundle = message.getBundle("login");
+ final LoginEntry login = new LoginEntry(loginBundle);
+
+ mDelegate.onLoginSave(login);
+ } else if (SAVE_CREDIT_CARD_EVENT.equals(event)) {
+ final GeckoBundle creditCardBundle = message.getBundle("creditCard");
+ final CreditCard creditCard = new CreditCard(creditCardBundle);
+
+ mDelegate.onCreditCardSave(creditCard);
+ } else if (SAVE_ADDRESS_EVENT.equals(event)) {
+ final GeckoBundle addressBundle = message.getBundle("address");
+ final Address address = new Address(addressBundle);
+
+ mDelegate.onAddressSave(address);
+ } else if (USED_LOGIN_EVENT.equals(event)) {
+ final GeckoBundle loginBundle = message.getBundle("login");
+ final LoginEntry login = new LoginEntry(loginBundle);
+ final int fields = message.getInt("usedFields");
+
+ mDelegate.onLoginUsed(login, fields);
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java
new file mode 100644
index 0000000000..5a4488f4fa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java
@@ -0,0 +1,1234 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.TargetApi;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.os.Build;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.ViewStructure;
+import android.view.autofill.AutofillValue;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.collection.ArrayMap;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class Autofill {
+ private static final boolean DEBUG = false;
+
+ public @interface AutofillNotify {}
+
+ public static final class Hint {
+ private Hint() {}
+
+ /** Hint indicating that no special handling is required. */
+ public static final int NONE = -1;
+
+ /** Hint indicating that a node represents an email address. */
+ public static final int EMAIL_ADDRESS = 0;
+
+ /** Hint indicating that a node represents a password. */
+ public static final int PASSWORD = 1;
+
+ /** Hint indicating that a node represents an URI. */
+ public static final int URI = 2;
+
+ /** Hint indicating that a node represents a username. */
+ public static final int USERNAME = 3;
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public static @Nullable String toString(final @AutofillHint int hint) {
+ final int idx = hint + 1;
+ final String[] map = new String[] {"NONE", "EMAIL", "PASSWORD", "URI", "USERNAME"};
+
+ if (idx < 0 || idx >= map.length) {
+ return null;
+ }
+ return map[idx];
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Hint.NONE, Hint.EMAIL_ADDRESS, Hint.PASSWORD, Hint.URI, Hint.USERNAME})
+ public @interface AutofillHint {}
+
+ public static final class InputType {
+ private InputType() {}
+
+ /** Indicates that a node is not a known input type. */
+ public static final int NONE = -1;
+
+ /** Indicates that a node is a text input type. Example: {@code <input type="text">} */
+ public static final int TEXT = 0;
+
+ /** Indicates that a node is a number input type. Example: {@code <input type="number">} */
+ public static final int NUMBER = 1;
+
+ /** Indicates that a node is a phone input type. Example: {@code <input type="tel">} */
+ public static final int PHONE = 2;
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public static @Nullable String toString(final @AutofillInputType int type) {
+ final int idx = type + 1;
+ final String[] map = new String[] {"NONE", "TEXT", "NUMBER", "PHONE"};
+
+ if (idx < 0 || idx >= map.length) {
+ return null;
+ }
+ return map[idx];
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({InputType.NONE, InputType.TEXT, InputType.NUMBER, InputType.PHONE})
+ public @interface AutofillInputType {}
+
+ /** Represents autofill data associated to a {@link Node}. */
+ public static class NodeData {
+ /** Autofill id for this node. */
+ final int id;
+
+ String value;
+ Node node;
+ EventCallback callback;
+
+ NodeData(final int id, final Node node) {
+ this.id = id;
+ this.node = node;
+ }
+
+ /**
+ * Gets the value for this node.
+ *
+ * @return a String representing the value for this node.
+ */
+ @AnyThread
+ public @Nullable String getValue() {
+ return value;
+ }
+
+ /**
+ * Returns the autofill id for this node.
+ *
+ * @return an int representing the id for this node.
+ */
+ @AnyThread
+ public int getId() {
+ return id;
+ }
+ }
+
+ /** Represents an autofill session. A session holds the autofill nodes and state of a page. */
+ public static final class Session {
+ private static final String LOGTAG = "AutofillSession";
+
+ private @NonNull final GeckoSession mGeckoSession;
+ private Node mRoot;
+ private HashMap<String, NodeData> mUuidToNodeData;
+ private SparseArray<Node> mIdToNode;
+ private int mCurrentIndex = 0;
+ private String mId = null;
+
+ // We can't store the Node directly because it might be updated by subsequent NodeAdd calls.
+ private String mFocusedUuid = null;
+
+ /* package */ Session(@NonNull final GeckoSession geckoSession) {
+ mGeckoSession = geckoSession;
+ // Dummy session until a real one gets created
+ clear(UUID.randomUUID().toString());
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull Rect getDefaultDimensions() {
+ final Rect rect = new Rect();
+ mGeckoSession.getSurfaceBounds(rect);
+ return rect;
+ }
+
+ /* package */ void clear(final String newSessionId) {
+ mId = newSessionId;
+ mFocusedUuid = null;
+ mRoot = Node.newDummyRoot(getDefaultDimensions(), newSessionId);
+ mIdToNode = new SparseArray<>();
+ mUuidToNodeData = new HashMap<>();
+ addNode(mRoot);
+ }
+
+ /* package */ boolean isEmpty() {
+ // Root data is always there
+ return mUuidToNodeData.size() == 1;
+ }
+
+ /**
+ * Get data for the given node.
+ *
+ * @param node the {@link Node} get data for.
+ * @return the {@link NodeData} for the given node.
+ */
+ @UiThread
+ public @NonNull NodeData dataFor(final @NonNull Node node) {
+ final NodeData data = mUuidToNodeData.get(node.getUuid());
+ Objects.requireNonNull(data);
+ return data;
+ }
+
+ /**
+ * Perform auto-fill using the specified values.
+ *
+ * @param values Map of auto-fill IDs to values.
+ */
+ @UiThread
+ public void autofill(@NonNull final SparseArray<CharSequence> values) {
+ ThreadUtils.assertOnUiThread();
+
+ if (isEmpty()) {
+ return;
+ }
+
+ final HashMap<Node, GeckoBundle> valueBundles = new HashMap<>();
+
+ for (int i = 0; i < values.size(); i++) {
+ final int id = values.keyAt(i);
+ final Node node = getNode(id);
+ if (node == null) {
+ Log.w(LOGTAG, "Could not find node id=" + id);
+ continue;
+ }
+
+ final CharSequence value = values.valueAt(i);
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "Process autofill for id=" + id + ", value=" + value);
+ }
+
+ if (node == getRoot()) {
+ // We cannot autofill the session root as it does not correspond to a
+ // real element on the page.
+ Log.w(LOGTAG, "Ignoring autofill on session root.");
+ continue;
+ }
+
+ final Node root = node.getRoot();
+ if (!valueBundles.containsKey(root)) {
+ valueBundles.put(root, new GeckoBundle());
+ }
+ valueBundles.get(root).putString(node.getUuid(), String.valueOf(value));
+ }
+
+ for (final Node root : valueBundles.keySet()) {
+ final NodeData data = dataFor(root);
+ Objects.requireNonNull(data);
+ final EventCallback callback = data.callback;
+ callback.sendSuccess(valueBundles.get(root));
+ }
+ }
+
+ /* package */ void addRoot(@NonNull final Node node, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "addRoot: " + node);
+ }
+
+ mRoot.addChild(node);
+ addNode(node);
+ dataFor(node).callback = callback;
+ }
+
+ /* package */ void addNode(@NonNull final Node node) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "addNode: " + node);
+ }
+
+ NodeData data = mUuidToNodeData.get(node.getUuid());
+ if (data == null) {
+ final int nodeId = mCurrentIndex++;
+ data = new NodeData(nodeId, node);
+ mUuidToNodeData.put(node.getUuid(), data);
+ } else {
+ data.node = node;
+ }
+
+ mIdToNode.put(data.id, node);
+ for (final Node child : node.getChildren()) {
+ addNode(child);
+ }
+ }
+
+ /**
+ * Returns true if the node is currently visible in the page.
+ *
+ * @param node the {@link Node} instance
+ * @return true if the node is visible, false otherwise.
+ */
+ @UiThread
+ public boolean isVisible(final @NonNull Node node) {
+ if (!Objects.equals(node.mSessionId, mId)) {
+ Log.w(LOGTAG, "Requesting visibility for older session " + node.mSessionId);
+ return false;
+ }
+ if (mRoot == node) {
+ // The root is always visible
+ return true;
+ }
+ final Node focused = getFocused();
+ if (focused == null) {
+ return false;
+ }
+ final Node focusedRoot = focused.getRoot();
+ final Node focusedParent = focused.getParent();
+
+ final String parentUuid = node.getParent() != null ? node.getParent().getUuid() : null;
+ final String rootUuid = node.getRoot() != null ? node.getRoot().getUuid() : null;
+
+ return (focusedParent != null && focusedParent.getUuid().equals(parentUuid))
+ || (focusedRoot != null && focusedRoot.getUuid().equals(rootUuid));
+ }
+
+ /**
+ * Returns the currently focused node.
+ *
+ * @return a reference to the {@link Node} that is currently focused or null if no node is
+ * currently focused.
+ */
+ @UiThread
+ public @Nullable Node getFocused() {
+ return getNode(mFocusedUuid);
+ }
+
+ /* package */ void setFocus(final Node node) {
+ mFocusedUuid = node != null ? node.getUuid() : null;
+ }
+
+ /**
+ * Returns the currently focused node data.
+ *
+ * @return a refernce to {@link NodeData} or null if no node is focused.
+ */
+ @UiThread
+ public @Nullable NodeData getFocusedData() {
+ final Node focused = getFocused();
+ return focused != null ? dataFor(focused) : null;
+ }
+
+ /* package */ @Nullable
+ Node getNode(final String uuid) {
+ if (uuid == null) {
+ return null;
+ }
+ final NodeData nodeData = mUuidToNodeData.get(uuid);
+ if (nodeData == null) {
+ return null;
+ }
+ return nodeData.node;
+ }
+
+ /* package */ Node getNode(final int id) {
+ return mIdToNode.get(id);
+ }
+
+ /**
+ * Get the root node of the session tree. Each session is managed in a tree with a virtual root
+ * node for the document.
+ *
+ * @return The root {@link Node} for this session.
+ */
+ @AnyThread
+ public @NonNull Node getRoot() {
+ return mRoot;
+ }
+
+ /* package */ String getId() {
+ return mId;
+ }
+
+ @Override
+ @UiThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Session {");
+ final Node focused = getFocused();
+ builder
+ .append("id=")
+ .append(mId)
+ .append(", focused=")
+ .append(mFocusedUuid)
+ .append(", focusedRoot=")
+ .append(
+ (focused != null && focused.getRoot() != null) ? focused.getRoot().getUuid() : null)
+ .append(", root=")
+ .append(getRoot())
+ .append("}");
+ return builder.toString();
+ }
+
+ @TargetApi(23)
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void fillViewStructure(
+ @NonNull final View view, @NonNull final ViewStructure structure, final int flags) {
+ ThreadUtils.assertOnUiThread();
+ fillViewStructure(getRoot(), view, structure, flags);
+ }
+
+ @TargetApi(23)
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void fillViewStructure(
+ final @NonNull Node node,
+ @NonNull final View view,
+ @NonNull final ViewStructure structure,
+ final int flags) {
+ ThreadUtils.assertOnUiThread();
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "fillViewStructure");
+ }
+
+ final NodeData data = dataFor(node);
+ if (data == null) {
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ structure.setAutofillId(view.getAutofillId(), data.id);
+ structure.setWebDomain(node.getDomain());
+ structure.setAutofillValue(AutofillValue.forText(data.value));
+ }
+
+ structure.setId(data.id, null, null, null);
+ // This dimensions doesn't seem to used for autofill service.
+ structure.setDimens(0, 0, 0, 0, node.getDimensions().width(), node.getDimensions().height());
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ final ViewStructure.HtmlInfo.Builder htmlBuilder =
+ structure.newHtmlInfoBuilder(node.getTag());
+ for (final String key : node.getAttributes().keySet()) {
+ htmlBuilder.addAttribute(key, String.valueOf(node.getAttribute(key)));
+ }
+
+ structure.setHtmlInfo(htmlBuilder.build());
+ }
+
+ structure.setChildCount(node.getChildren().size());
+ int childCount = 0;
+
+ for (final Node child : node.getChildren()) {
+ final ViewStructure childStructure = structure.newChild(childCount);
+ fillViewStructure(child, view, childStructure, flags);
+ childCount++;
+ }
+
+ switch (node.getTag()) {
+ case "input":
+ case "textarea":
+ structure.setClassName("android.widget.EditText");
+ structure.setEnabled(node.getEnabled());
+ structure.setFocusable(node.getFocusable());
+ structure.setFocused(node.equals(getFocused()));
+ structure.setVisibility(isVisible(node) ? View.VISIBLE : View.INVISIBLE);
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ structure.setAutofillType(View.AUTOFILL_TYPE_TEXT);
+ }
+ break;
+ default:
+ if (childCount > 0) {
+ structure.setClassName("android.view.ViewGroup");
+ } else {
+ structure.setClassName("android.view.View");
+ }
+ break;
+ }
+
+ if (Build.VERSION.SDK_INT < 26 || !"input".equals(node.getTag())) {
+ return;
+ }
+ // LastPass will fill password to the field where setAutofillHints
+ // is unset and setInputType is set.
+ switch (node.getHint()) {
+ case Hint.EMAIL_ADDRESS:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_EMAIL_ADDRESS});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
+ break;
+ }
+ case Hint.PASSWORD:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PASSWORD});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD);
+ break;
+ }
+ case Hint.URI:
+ {
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_URI);
+ break;
+ }
+ case Hint.USERNAME:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_USERNAME});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
+ break;
+ }
+ case Hint.NONE:
+ {
+ // Nothing to do.
+ break;
+ }
+ }
+
+ switch (node.getInputType()) {
+ case InputType.NUMBER:
+ {
+ structure.setInputType(android.text.InputType.TYPE_CLASS_NUMBER);
+ break;
+ }
+ case InputType.PHONE:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PHONE});
+ structure.setInputType(android.text.InputType.TYPE_CLASS_PHONE);
+ break;
+ }
+ case InputType.TEXT:
+ case InputType.NONE:
+ // Nothing to do.
+ break;
+ }
+ }
+ }
+
+ /**
+ * Represents an autofill node. A node is an input element and may contain child nodes forming a
+ * tree.
+ */
+ public static final class Node {
+ private final String mUuid;
+ private final Node mRoot;
+ private final Node mParent;
+ private final @NonNull Rect mDimens;
+ private final @NonNull Rect mScreenRect;
+ private final @NonNull Map<String, Node> mChildren;
+ private final @NonNull Map<String, String> mAttributes;
+ private final boolean mEnabled;
+ private final boolean mFocusable;
+ private final @AutofillHint int mHint;
+ private final @AutofillInputType int mInputType;
+ private final @NonNull String mTag;
+ private final @NonNull String mDomain;
+ private final String mSessionId;
+
+ /* package */
+ @NonNull
+ String getUuid() {
+ return mUuid;
+ }
+
+ /* package */
+ @Nullable
+ Node getRoot() {
+ return mRoot;
+ }
+
+ /* package */
+ @Nullable
+ Node getParent() {
+ return mParent;
+ }
+
+ /**
+ * Get the dimensions of this node in CSS coordinates. Note: Invisible nodes will report their
+ * proper dimensions.
+ *
+ * @return The dimensions of this node.
+ */
+ @AnyThread
+ /* package */ @NonNull
+ Rect getDimensions() {
+ return mDimens;
+ }
+
+ /**
+ * Get the dimensions of this node in screen coordinates. This is valid when this node has an
+ * focus.
+ *
+ * @return The dimensions of this node.
+ */
+ @AnyThread
+ public @NonNull Rect getScreenRect() {
+ return mScreenRect;
+ }
+
+ /**
+ * Set the dimensions of this node in screen coordinates.
+ *
+ * @param screenRect The dimensions of this node.
+ */
+ /* package */ void setScreenRect(final @NonNull RectF screenRectF) {
+ screenRectF.roundOut(mScreenRect);
+ }
+
+ /**
+ * Get the child nodes for this node.
+ *
+ * @return The collection of child nodes for this node.
+ */
+ @AnyThread
+ public @NonNull Collection<Node> getChildren() {
+ return mChildren.values();
+ }
+
+ /* package */
+ @NonNull
+ Node addChild(@NonNull final Node child) {
+ mChildren.put(child.getUuid(), child);
+ return this;
+ }
+
+ /**
+ * Get HTML attributes for this node.
+ *
+ * @return The HTML attributes for this node.
+ */
+ @AnyThread
+ public @NonNull Map<String, String> getAttributes() {
+ return mAttributes;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable String getAttribute(@NonNull final String key) {
+ return mAttributes.get(key);
+ }
+
+ /**
+ * Get whether or not this node is enabled.
+ *
+ * @return True if the node is enabled, false otherwise.
+ */
+ @AnyThread
+ public boolean getEnabled() {
+ return mEnabled;
+ }
+
+ /**
+ * Get whether or not this node is focusable.
+ *
+ * @return True if the node is focusable, false otherwise.
+ */
+ @AnyThread
+ public boolean getFocusable() {
+ return mFocusable;
+ }
+
+ /**
+ * Get the hint for the type of data contained in this node.
+ *
+ * @return The input data hint for this node, one of {@link Hint}.
+ */
+ @AnyThread
+ public @AutofillHint int getHint() {
+ return mHint;
+ }
+
+ /**
+ * Get the input type of this node.
+ *
+ * @return The input type of this node, one of {@link InputType}.
+ */
+ @AnyThread
+ public @AutofillInputType int getInputType() {
+ return mInputType;
+ }
+
+ /**
+ * Get the HTML tag of this node.
+ *
+ * @return The HTML tag of this node.
+ */
+ @AnyThread
+ public @NonNull String getTag() {
+ return mTag;
+ }
+
+ /**
+ * Get web domain of this node.
+ *
+ * @return The domain of this node.
+ */
+ @AnyThread
+ public @NonNull String getDomain() {
+ return mDomain;
+ }
+
+ /* package */
+ static Node newDummyRoot(final Rect dimensions, final String sessionId) {
+ return new Node(dimensions, sessionId);
+ }
+
+ /* package */ Node(final Rect dimensions, final String sessionId) {
+ mRoot = null;
+ mParent = null;
+ mUuid = UUID.randomUUID().toString();
+ mDimens = dimensions;
+ mScreenRect = new Rect();
+ mSessionId = sessionId;
+ mAttributes = new ArrayMap<>();
+ mEnabled = false;
+ mFocusable = false;
+ mHint = Hint.NONE;
+ mInputType = InputType.NONE;
+ mTag = "";
+ mDomain = "";
+ mChildren = new HashMap<>();
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Node {");
+ builder
+ .append("uuid=")
+ .append(mUuid)
+ .append(", sessionId=")
+ .append(mSessionId)
+ .append(", parent=")
+ .append(mParent != null ? mParent.getUuid() : null)
+ .append(", root=")
+ .append(mRoot != null ? mRoot.getUuid() : null)
+ .append(", dims=")
+ .append(getDimensions().toShortString())
+ .append(", screenRect=")
+ .append(getScreenRect().toShortString())
+ .append(", children=[");
+
+ for (final Node child : mChildren.values()) {
+ builder.append(child.getUuid()).append(", ");
+ }
+
+ builder
+ .append("]")
+ .append(", attrs=")
+ .append(mAttributes)
+ .append(", enabled=")
+ .append(mEnabled)
+ .append(", focusable=")
+ .append(mFocusable)
+ .append(", hint=")
+ .append(Hint.toString(mHint))
+ .append(", type=")
+ .append(InputType.toString(mInputType))
+ .append(", tag=")
+ .append(mTag)
+ .append(", domain=")
+ .append(mDomain)
+ .append("}");
+
+ return builder.toString();
+ }
+
+ /* package */ Node(
+ @NonNull final GeckoBundle bundle, final Rect defaultDimensions, final String sessionId) {
+ this(bundle, /* root */ null, /* parent */ null, defaultDimensions, sessionId);
+ }
+
+ /* package */ Node(
+ @NonNull final GeckoBundle bundle,
+ final Node root,
+ final Node parent,
+ final Rect defaultDimensions,
+ final String sessionId) {
+ final GeckoBundle bounds = bundle.getBundle("bounds");
+
+ mSessionId = sessionId;
+ mUuid = bundle.getString("uuid");
+ mDomain = bundle.getString("origin", "");
+ final Rect dimens =
+ new Rect(
+ bounds.getInt("left"),
+ bounds.getInt("top"),
+ bounds.getInt("right"),
+ bounds.getInt("bottom"));
+ if (dimens.isEmpty()) {
+ // Some nodes like <html> will have null-dimensions,
+ // we need to set them to the virtual documents dimensions.
+ mDimens = defaultDimensions;
+ } else {
+ mDimens = dimens;
+ }
+ mScreenRect = new Rect();
+
+ mParent = parent;
+ // If the root is null, then this object is the root itself
+ mRoot = root != null ? root : this;
+
+ final GeckoBundle[] children = bundle.getBundleArray("children");
+ final Map<String, Node> childrenMap = new HashMap<>(children != null ? children.length : 0);
+
+ if (children != null) {
+ for (final GeckoBundle childBundle : children) {
+ final Node child = new Node(childBundle, mRoot, this, defaultDimensions, sessionId);
+ childrenMap.put(child.getUuid(), child);
+ }
+ }
+
+ mChildren = childrenMap;
+
+ mTag = bundle.getString("tag", "").toLowerCase(Locale.ROOT);
+
+ final GeckoBundle attrs = bundle.getBundle("attributes");
+ final Map<String, String> attributes = new HashMap<>();
+
+ for (final String key : attrs.keys()) {
+ attributes.put(key, String.valueOf(attrs.get(key)));
+ }
+
+ mAttributes = attributes;
+
+ mEnabled =
+ enabledFromBundle(
+ mTag, bundle.getBoolean("editable", false), bundle.getBoolean("disabled", false));
+ mFocusable = mEnabled;
+
+ final String type = bundle.getString("type", "text").toLowerCase(Locale.ROOT);
+ final String hint = bundle.getString("autofillhint", "").toLowerCase(Locale.ROOT);
+ mInputType = typeFromBundle(type, hint);
+ mHint = hintFromBundle(type, hint);
+ }
+
+ private boolean enabledFromBundle(
+ final String tag, final boolean editable, final boolean disabled) {
+ switch (tag) {
+ case "input":
+ {
+ if (!editable) {
+ // Don't process non-editable inputs (e.g., type="button").
+ return false;
+ }
+ return !disabled;
+ }
+ case "textarea":
+ return !disabled;
+ default:
+ return false;
+ }
+ }
+
+ private @AutofillHint int hintFromBundle(final String type, final String hint) {
+ switch (type) {
+ case "email":
+ return Hint.EMAIL_ADDRESS;
+ case "password":
+ return Hint.PASSWORD;
+ case "url":
+ return Hint.URI;
+ case "text":
+ {
+ if (hint.equals("username")) {
+ return Hint.USERNAME;
+ }
+ break;
+ }
+ }
+
+ return Hint.NONE;
+ }
+
+ private @AutofillInputType int typeFromBundle(final String type, final String hint) {
+ switch (type) {
+ case "password":
+ case "url":
+ case "email":
+ return InputType.TEXT;
+ case "number":
+ return InputType.NUMBER;
+ case "tel":
+ return InputType.PHONE;
+ case "text":
+ {
+ if (hint.equals("username")) {
+ return InputType.TEXT;
+ }
+ break;
+ }
+ }
+
+ return InputType.NONE;
+ }
+ }
+
+ public interface Delegate {
+
+ /**
+ * An autofill session has started. Usually triggered by page load.
+ *
+ * @param session The {@link GeckoSession} instance.
+ */
+ @UiThread
+ default void onSessionStart(@NonNull final GeckoSession session) {}
+
+ /**
+ * An autofill session has been committed. Triggered by form submission or navigation.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node the node that is being committed.
+ * @param data the node data associated to the node being committed.
+ */
+ @UiThread
+ default void onSessionCommit(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+
+ /**
+ * An autofill session has been canceled. Triggered by page unload.
+ *
+ * @param session The {@link GeckoSession} instance.
+ */
+ @UiThread
+ default void onSessionCancel(@NonNull final GeckoSession session) {}
+
+ /**
+ * A node within the autofill session has been added.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was added.
+ * @param data The {@link NodeData} associated to the note that was added.
+ */
+ @UiThread
+ default void onNodeAdd(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+
+ /**
+ * A node within the autofill session has been removed.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was removed.
+ * @param data The {@link NodeData} associated to the note that was removed.
+ */
+ @UiThread
+ default void onNodeRemove(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+
+ /**
+ * A node within the autofill session has been updated.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was updated.
+ * @param data The {@link NodeData} associated to the note that was updated.
+ */
+ @UiThread
+ default void onNodeUpdate(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+
+ /**
+ * A node within the autofill session has gained focus.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param focused The {@link Node} that is now focused.
+ * @param data The {@link NodeData} associated to the note that is now focused.
+ */
+ @UiThread
+ default void onNodeFocus(
+ @NonNull final GeckoSession session,
+ @NonNull final Node focused,
+ @NonNull final NodeData data) {}
+
+ /**
+ * A node within the autofill session has lost focus.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param prev The {@link Node} that lost focus.
+ * @param data The {@link NodeData} associated to the note that lost focus.
+ */
+ @UiThread
+ default void onNodeBlur(
+ @NonNull final GeckoSession session,
+ @NonNull final Node prev,
+ @NonNull final NodeData data) {}
+ }
+
+ /* package */ static final class Support implements BundleEventListener {
+ private static final String LOGTAG = "AutofillSupport";
+
+ private @NonNull final GeckoSession mGeckoSession;
+ private @NonNull final Session mAutofillSession;
+ private Delegate mDelegate;
+
+ public Support(@NonNull final GeckoSession geckoSession) {
+ mGeckoSession = geckoSession;
+ mAutofillSession = new Session(mGeckoSession);
+ }
+
+ public void registerListeners() {
+ mGeckoSession
+ .getEventDispatcher()
+ .registerUiThreadListener(
+ this,
+ "GeckoView:StartAutofill",
+ "GeckoView:AddAutofill",
+ "GeckoView:ClearAutofill",
+ "GeckoView:CommitAutofill",
+ "GeckoView:OnAutofillFocus",
+ "GeckoView:UpdateAutofill");
+ }
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ if ("GeckoView:AddAutofill".equals(event)) {
+ addNode(message.getBundle("node"), callback);
+ } else if ("GeckoView:StartAutofill".equals(event)) {
+ start(message.getString("sessionId"));
+ } else if ("GeckoView:ClearAutofill".equals(event)) {
+ clear();
+ } else if ("GeckoView:OnAutofillFocus".equals(event)) {
+ onFocusChanged(message.getBundle("node"));
+ } else if ("GeckoView:CommitAutofill".equals(event)) {
+ commit(message.getBundle("node"));
+ } else if ("GeckoView:UpdateAutofill".equals(event)) {
+ update(message.getBundle("node"));
+ }
+ }
+
+ @UiThread
+ public void setDelegate(final @Nullable Delegate delegate) {
+ ThreadUtils.assertOnUiThread();
+
+ mDelegate = delegate;
+ }
+
+ @UiThread
+ public @Nullable Delegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+
+ return mDelegate;
+ }
+
+ @UiThread
+ public @NonNull Session getAutofillSession() {
+ ThreadUtils.assertOnUiThread();
+
+ return mAutofillSession;
+ }
+
+ /* package */ void addNode(
+ @NonNull final GeckoBundle message, @NonNull final EventCallback callback) {
+ final Session session = getAutofillSession();
+ final Node node = new Node(message, session.getDefaultDimensions(), session.getId());
+
+ session.addRoot(node, callback);
+ addValues(message);
+
+ if (mDelegate != null) {
+ mDelegate.onNodeAdd(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ private void addValues(final GeckoBundle message) {
+ final String uuid = message.getString("uuid");
+ if (uuid == null) {
+ return;
+ }
+
+ final String value = message.getString("value");
+ final Node node = getAutofillSession().getNode(uuid);
+ if (node == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+ Objects.requireNonNull(node);
+ final NodeData data = getAutofillSession().dataFor(node);
+ Objects.requireNonNull(data);
+ data.value = value;
+
+ final GeckoBundle[] children = message.getBundleArray("children");
+ if (children != null) {
+ for (final GeckoBundle child : children) {
+ addValues(child);
+ }
+ }
+ }
+
+ /* package */ void start(@Nullable final String sessionId) {
+ // Make sure we start with a clean session
+ getAutofillSession().clear(sessionId);
+ if (mDelegate != null) {
+ mDelegate.onSessionStart(mGeckoSession);
+ }
+ }
+
+ /* package */ void commit(@Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty() || message == null) {
+ return;
+ }
+
+ final String uuid = message.getString("uuid");
+ final Node node = getAutofillSession().getNode(uuid);
+ if (node == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "commit(" + uuid + ")");
+ }
+
+ if (mDelegate != null) {
+ mDelegate.onSessionCommit(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ /* package */ void update(@Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty() || message == null) {
+ return;
+ }
+
+ final String uuid = message.getString("uuid");
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "update(" + uuid + ")");
+ }
+
+ final Node node = getAutofillSession().getNode(uuid);
+ final String value = message.getString("value", "");
+
+ if (node == null) {
+ Log.d(LOGTAG, "could not find node " + uuid);
+ return;
+ }
+
+ if (DEBUG) {
+ final NodeData data = getAutofillSession().dataFor(node);
+ Log.d(
+ LOGTAG,
+ "updating node " + uuid + " value from " + data != null
+ ? data.value
+ : null + " to " + value);
+ }
+
+ getAutofillSession().dataFor(node).value = value;
+
+ if (mDelegate != null) {
+ mDelegate.onNodeUpdate(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ /* package */ void clear() {
+ if (getAutofillSession().isEmpty()) {
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "clear()");
+ }
+
+ getAutofillSession().clear(null);
+ if (mDelegate != null) {
+ mDelegate.onSessionCancel(mGeckoSession);
+ }
+ }
+
+ /* package */ void onFocusChanged(@Nullable final GeckoBundle message) {
+ final Session session = getAutofillSession();
+ if (session.isEmpty()) {
+ return;
+ }
+
+ final Node prev = getAutofillSession().getFocused();
+ final String prevUuid = prev != null ? prev.getUuid() : null;
+ final String uuid = message != null ? message.getString("uuid") : null;
+
+ final Node focused;
+ if (uuid == null) {
+ focused = null;
+ } else {
+ focused = session.getNode(uuid);
+ if (focused == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+ if (message != null) {
+ final RectF screenRectF = message.getRectF("screenRect");
+ focused.setScreenRect(screenRectF);
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "onFocusChanged(" + (prev != null ? prev.getUuid() : null) + " -> " + uuid + ')');
+ }
+
+ if (Objects.equals(uuid, prevUuid)) {
+ // Nothing changed, nothing to do.
+ return;
+ }
+
+ session.setFocus(focused);
+
+ if (mDelegate != null) {
+ if (prev != null) {
+ mDelegate.onNodeBlur(mGeckoSession, prev, getAutofillSession().dataFor(prev));
+ }
+ if (uuid != null) {
+ mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ }
+ }
+ }
+
+ @UiThread
+ public void onActiveChanged(final boolean active) {
+ ThreadUtils.assertOnUiThread();
+
+ final Node focused = getAutofillSession().getFocused();
+
+ if (focused == null) {
+ return;
+ }
+
+ if (mDelegate != null) {
+ if (active) {
+ mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ } else {
+ mDelegate.onNodeBlur(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
new file mode 100644
index 0000000000..d135194afa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * This class exposes the Base64 URL encode/decode functions from Gecko. They are different from
+ * android.util.Base64 in that they always use URL encoding, no padding, and are constant time. The
+ * last bit is important when dealing with values that might be secret as we do with Web Push.
+ */
+/* package */ class Base64Utils {
+ @WrapForJNI
+ public static native byte[] decode(final String data);
+
+ @WrapForJNI
+ public static native String encode(final byte[] data);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java
new file mode 100644
index 0000000000..f2e10e50a4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java
@@ -0,0 +1,685 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.graphics.Matrix;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.TransactionTooLargeException;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * Class that implements a basic SelectionActionDelegate. This class is used by GeckoView by default
+ * if the consumer does not explicitly set a SelectionActionDelegate.
+ *
+ * <p>To provide custom actions, extend this class and override the following methods,
+ *
+ * <p>1) Override {@link #getAllActions} to include custom action IDs in the returned array. This
+ * array must include all actions, available or not, and must not change over the class lifetime.
+ *
+ * <p>2) Override {@link #isActionAvailable} to return whether a custom action is currently
+ * available.
+ *
+ * <p>3) Override {@link #prepareAction} to set custom title and/or icon for a custom action.
+ *
+ * <p>4) Override {@link #performAction} to perform a custom action when used.
+ */
+@UiThread
+public class BasicSelectionActionDelegate
+ implements ActionMode.Callback, GeckoSession.SelectionActionDelegate {
+ private static final String LOGTAG = "BasicSelectionAction";
+
+ protected static final String ACTION_PROCESS_TEXT = Intent.ACTION_PROCESS_TEXT;
+
+ private static final String[] FLOATING_TOOLBAR_ACTIONS =
+ new String[] {
+ ACTION_CUT,
+ ACTION_COPY,
+ ACTION_PASTE,
+ ACTION_SELECT_ALL,
+ ACTION_PASTE_AS_PLAIN_TEXT,
+ ACTION_PROCESS_TEXT
+ };
+ private static final String[] FIXED_TOOLBAR_ACTIONS =
+ new String[] {ACTION_SELECT_ALL, ACTION_CUT, ACTION_COPY, ACTION_PASTE};
+
+ // This is limitation of intent text.
+ private static final int MAX_INTENT_TEXT_LENGTH = 100000;
+
+ protected final @NonNull Activity mActivity;
+ protected final boolean mUseFloatingToolbar;
+
+ private boolean mExternalActionsEnabled;
+
+ protected @Nullable ActionMode mActionMode;
+ protected @Nullable GeckoSession mSession;
+ protected @Nullable Selection mSelection;
+ protected boolean mRepopulatedMenu;
+
+ private @Nullable ActionMode mActionModeForClipboardPermission;
+
+ @TargetApi(Build.VERSION_CODES.M)
+ private class Callback2Wrapper extends ActionMode.Callback2 {
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onCreateActionMode(actionMode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onPrepareActionMode(actionMode, menu);
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ return BasicSelectionActionDelegate.this.onActionItemClicked(actionMode, menuItem);
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ BasicSelectionActionDelegate.this.onDestroyActionMode(actionMode);
+ }
+
+ @Override
+ public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) {
+ super.onGetContentRect(mode, view, outRect);
+ BasicSelectionActionDelegate.this.onGetContentRect(mode, view, outRect);
+ }
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BasicSelectionActionDelegate(final @NonNull Activity activity) {
+ this(activity, Build.VERSION.SDK_INT >= 23);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BasicSelectionActionDelegate(
+ final @NonNull Activity activity, final boolean useFloatingToolbar) {
+ mActivity = activity;
+ mUseFloatingToolbar = useFloatingToolbar;
+ mExternalActionsEnabled = true;
+ }
+
+ /**
+ * Set whether to include text actions from other apps in the floating toolbar.
+ *
+ * @param enable True if external actions should be enabled.
+ */
+ public void enableExternalActions(final boolean enable) {
+ ThreadUtils.assertOnUiThread();
+ mExternalActionsEnabled = enable;
+
+ if (mActionMode != null) {
+ mActionMode.invalidate();
+ }
+ }
+
+ /**
+ * Get whether text actions from other apps are enabled.
+ *
+ * @return True if external actions are enabled.
+ */
+ public boolean areExternalActionsEnabled() {
+ return mExternalActionsEnabled;
+ }
+
+ /**
+ * Return list of all actions in proper order, regardless of their availability at present.
+ * Override to add to or remove from the default set.
+ *
+ * @return Array of action IDs in proper order.
+ */
+ protected @NonNull String[] getAllActions() {
+ return mUseFloatingToolbar ? FLOATING_TOOLBAR_ACTIONS : FIXED_TOOLBAR_ACTIONS;
+ }
+
+ /**
+ * Return whether an action is presently available. Override to indicate availability for custom
+ * actions.
+ *
+ * @param id Action ID.
+ * @return True if the action is presently available.
+ */
+ protected boolean isActionAvailable(final @NonNull String id) {
+ if (mSelection == null) {
+ return false;
+ }
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && ACTION_PASTE_AS_PLAIN_TEXT.equals(id)) {
+ return false;
+ }
+
+ if (mExternalActionsEnabled && !mSelection.text.isEmpty() && ACTION_PROCESS_TEXT.equals(id)) {
+ return !getProcessTextExportedActivities().isEmpty();
+ }
+
+ return mSelection.isActionAvailable(id);
+ }
+
+ /**
+ * Get exported activities for {@link BasicSelectionActionDelegate#ACTION_PROCESS_TEXT} when text
+ * is selected.
+ *
+ * @return list of exported activities
+ */
+ private @NonNull List<ResolveInfo> getProcessTextExportedActivities() {
+ final PackageManager pm = mActivity.getPackageManager();
+ final List<ResolveInfo> resolvedList =
+ pm.queryIntentActivityOptions(
+ null, null, getProcessTextIntent(null), PackageManager.MATCH_DEFAULT_ONLY);
+ final ArrayList<ResolveInfo> exportedList = new ArrayList<>();
+ for (final ResolveInfo info : resolvedList) {
+ if (info.activityInfo.exported) {
+ exportedList.add(info);
+ }
+ }
+
+ return exportedList;
+ }
+
+ /**
+ * Provides access to whether there are text selection actions available. Override to indicate
+ * availability for custom actions.
+ *
+ * @return True if there are text selection actions available.
+ */
+ public boolean isActionAvailable() {
+ if (mSelection == null) {
+ return false;
+ }
+
+ return isActionAvailable(ACTION_PROCESS_TEXT) || !mSelection.availableActions.isEmpty();
+ }
+
+ /**
+ * Prepare a menu item corresponding to a certain action. Override to prepare menu item for custom
+ * action.
+ *
+ * @param id Action ID.
+ * @param item New menu item to prepare.
+ */
+ protected void prepareAction(final @NonNull String id, final @NonNull MenuItem item) {
+ switch (id) {
+ case ACTION_CUT:
+ item.setTitle(android.R.string.cut);
+ break;
+ case ACTION_COPY:
+ item.setTitle(android.R.string.copy);
+ break;
+ case ACTION_PASTE:
+ item.setTitle(android.R.string.paste);
+ break;
+ case ACTION_PASTE_AS_PLAIN_TEXT:
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ throw new IllegalStateException("Unexpected version for action");
+ }
+ item.setTitle(android.R.string.paste_as_plain_text);
+ break;
+ case ACTION_SELECT_ALL:
+ item.setTitle(android.R.string.selectAll);
+ break;
+ case ACTION_PROCESS_TEXT:
+ throw new IllegalStateException("Unexpected action");
+ }
+ }
+
+ /**
+ * Perform the specified action. Override to perform custom actions.
+ *
+ * @param id Action ID.
+ * @param item Nenu item for the action.
+ * @return True if the action was performed.
+ */
+ protected boolean performAction(final @NonNull String id, final @NonNull MenuItem item) {
+ if (ACTION_PROCESS_TEXT.equals(id)) {
+ try {
+ mActivity.startActivity(item.getIntent());
+ } catch (final ActivityNotFoundException e) {
+ Log.e(LOGTAG, "Cannot perform action", e);
+ return false;
+ }
+ return true;
+ }
+
+ if (mSelection == null) {
+ return false;
+ }
+ mSelection.execute(id);
+
+ // Android behavior is to clear selection on copy.
+ if (ACTION_COPY.equals(id)) {
+ if (mUseFloatingToolbar) {
+ clearSelection();
+ } else {
+ mActionMode.finish();
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get the current selection object. This object should not be stored as it does not update when
+ * the selection becomes invalid. Stale actions are ignored.
+ *
+ * @return The {@link GeckoSession.SelectionActionDelegate.Selection} attached to the current
+ * action menu. <code>null</code> if no action menu is active.
+ */
+ public @Nullable Selection getSelection() {
+ return mSelection;
+ }
+
+ /** Clear the current selection, if possible. */
+ public void clearSelection() {
+ if (mSelection == null) {
+ return;
+ }
+
+ if (isActionAvailable(ACTION_COLLAPSE_TO_END)) {
+ mSelection.collapseToEnd();
+ } else if (isActionAvailable(ACTION_UNSELECT)) {
+ mSelection.unselect();
+ } else {
+ mSelection.hide();
+ }
+ }
+
+ private String getSelectedText(final int maxLength) {
+ if (mSelection == null) {
+ return "";
+ }
+
+ if (TextUtils.isEmpty(mSelection.text) || mSelection.text.length() < maxLength) {
+ return mSelection.text;
+ }
+
+ return mSelection.text.substring(0, maxLength);
+ }
+
+ private Intent getProcessTextIntent(@Nullable final ResolveInfo resolveInfo) {
+ final Intent intent = new Intent(Intent.ACTION_PROCESS_TEXT);
+ if (resolveInfo != null) {
+ intent.setComponent(
+ new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name));
+ }
+ intent.addCategory(Intent.CATEGORY_DEFAULT);
+ intent.setType("text/plain");
+ // If using large text, anything intent may throw RemoteException.
+ intent.putExtra(Intent.EXTRA_PROCESS_TEXT, getSelectedText(MAX_INTENT_TEXT_LENGTH));
+ // TODO: implement ability to replace text in Gecko for editable selection (bug 1453137).
+ intent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true);
+ return intent;
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ ThreadUtils.assertOnUiThread();
+ final String[] allActions = getAllActions();
+ for (final String actionId : allActions) {
+ if (isActionAvailable(actionId)) {
+ if (!mUseFloatingToolbar && (Build.VERSION.SDK_INT == 22 || Build.VERSION.SDK_INT == 23)) {
+ // Android bug where onPrepareActionMode is not called initially.
+ onPrepareActionMode(actionMode, menu);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ ThreadUtils.assertOnUiThread();
+ final String[] allActions = getAllActions();
+ boolean changed = false;
+
+ // Whether we are repopulating an existing menu.
+ mRepopulatedMenu = menu.size() != 0;
+
+ // For each action, see if it's available at present, and if necessary,
+ // add to or remove from menu.
+ for (int i = 0; i < allActions.length; i++) {
+ final String actionId = allActions[i];
+ final int menuId = i + Menu.FIRST;
+
+ if (ACTION_PROCESS_TEXT.equals(actionId)) {
+ if (mExternalActionsEnabled && mSelection != null && !mSelection.text.isEmpty()) {
+ final List<ResolveInfo> exportedPackageInfo = getProcessTextExportedActivities();
+ if (!exportedPackageInfo.isEmpty()) {
+ for (final ResolveInfo info : exportedPackageInfo) {
+ final boolean isMenuItemAdded = addProcessTextMenuItem(menu, menuId, info);
+ if (isMenuItemAdded) {
+ changed = true;
+ }
+ }
+ }
+ } else if (menu.findItem(menuId) != null) {
+ menu.removeGroup(menuId);
+ changed = true;
+ }
+ continue;
+ }
+
+ if (isActionAvailable(actionId)) {
+ if (menu.findItem(menuId) == null) {
+ prepareAction(actionId, menu.add(/* group */ Menu.NONE, menuId, menuId, /* title */ ""));
+ changed = true;
+ }
+ } else if (menu.findItem(menuId) != null) {
+ menu.removeItem(menuId);
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
+ private boolean addProcessTextMenuItem(
+ final Menu menu, final int menuId, final ResolveInfo info) {
+ boolean isMenuItemAdded = false;
+ try {
+ menu.addIntentOptions(
+ menuId,
+ menuId,
+ menuId,
+ mActivity.getComponentName(),
+ /* specifiec */ null,
+ getProcessTextIntent(info),
+ /* flags */ Menu.FLAG_APPEND_TO_GROUP, /* items */
+ null);
+ isMenuItemAdded = true;
+ } catch (final RuntimeException e) {
+ if (e.getCause() instanceof TransactionTooLargeException) {
+ // Binder size error. MAX_INTENT_TEXT_LENGTH is still large?
+ Log.e(LOGTAG, "Cannot add intent option", e);
+ } else {
+ throw e;
+ }
+ }
+ return isMenuItemAdded;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ ThreadUtils.assertOnUiThread();
+ MenuItem realMenuItem = null;
+ if (mRepopulatedMenu) {
+ // When we repopulate an existing menu, Android can sometimes give us an old,
+ // deleted MenuItem. Find the current MenuItem that corresponds to the old one.
+ final Menu menu = actionMode.getMenu();
+ final int size = menu.size();
+ for (int i = 0; i < size; i++) {
+ final MenuItem item = menu.getItem(i);
+ if (item == menuItem
+ || (item.getItemId() == menuItem.getItemId()
+ && item.getTitle().equals(menuItem.getTitle()))) {
+ realMenuItem = item;
+ break;
+ }
+ }
+ } else {
+ realMenuItem = menuItem;
+ }
+
+ if (realMenuItem == null) {
+ return false;
+ }
+ final String[] allActions = getAllActions();
+ return performAction(allActions[realMenuItem.getItemId() - Menu.FIRST], realMenuItem);
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ ThreadUtils.assertOnUiThread();
+ if (!mUseFloatingToolbar) {
+ clearSelection();
+ }
+ mSession = null;
+ mSelection = null;
+ mActionMode = null;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void onGetContentRect(
+ final @Nullable ActionMode mode, final @Nullable View view, final @NonNull Rect outRect) {
+ ThreadUtils.assertOnUiThread();
+ if (mSelection == null || mSelection.screenRect == null) {
+ return;
+ }
+
+ // outRect has to convert to current window coordinate.
+ final Matrix matrix = new Matrix();
+ mSession.getScreenToWindowManagerOffsetMatrix(matrix);
+ final RectF transformedRect = new RectF();
+ matrix.mapRect(transformedRect, mSelection.screenRect);
+ transformedRect.roundOut(outRect);
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ @Override
+ public void onShowActionRequest(final GeckoSession session, final Selection selection) {
+ ThreadUtils.assertOnUiThread();
+ mSession = session;
+ mSelection = selection;
+
+ if (mActionMode != null) {
+ if (isActionAvailable()) {
+ mActionMode.invalidate();
+ } else {
+ mActionMode.finish();
+ }
+ return;
+ }
+
+ if (mActionModeForClipboardPermission != null) {
+ mActionModeForClipboardPermission.finish();
+ return;
+ }
+
+ if (mUseFloatingToolbar) {
+ mActionMode = mActivity.startActionMode(new Callback2Wrapper(), ActionMode.TYPE_FLOATING);
+ } else {
+ mActionMode = mActivity.startActionMode(this);
+ }
+ }
+
+ @Override
+ public void onHideAction(final GeckoSession session, final int reason) {
+ ThreadUtils.assertOnUiThread();
+ if (mActionMode == null) {
+ return;
+ }
+
+ switch (reason) {
+ case HIDE_REASON_ACTIVE_SCROLL:
+ case HIDE_REASON_ACTIVE_SELECTION:
+ case HIDE_REASON_INVISIBLE_SELECTION:
+ if (mUseFloatingToolbar) {
+ // Hide the floating toolbar when scrolling/selecting.
+ mActionMode.finish();
+ }
+ break;
+
+ case HIDE_REASON_NO_SELECTION:
+ mActionMode.finish();
+ break;
+ }
+ }
+
+ /** Callback class of clipboard permission. This is used on pre-M only */
+ private class ClipboardPermissionCallback implements ActionMode.Callback {
+ private GeckoResult<AllowOrDeny> mResult;
+
+ public ClipboardPermissionCallback(final GeckoResult<AllowOrDeny> result) {
+ mResult = result;
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission(
+ actionMode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ mResult.complete(AllowOrDeny.ALLOW);
+ mResult = null;
+ actionMode.finish();
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ if (mResult != null) {
+ mResult.complete(AllowOrDeny.DENY);
+ }
+ BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode);
+ }
+ }
+
+ /** Callback class of clipboard permission for Android M+ */
+ @TargetApi(Build.VERSION_CODES.M)
+ private class ClipboardPermissionCallbackM extends ActionMode.Callback2 {
+ private @Nullable GeckoResult<AllowOrDeny> mResult;
+ private final @NonNull GeckoSession mSession;
+ private final @Nullable Point mPoint;
+
+ public ClipboardPermissionCallbackM(
+ final @NonNull GeckoSession session,
+ final @Nullable Point screenPoint,
+ final @NonNull GeckoResult<AllowOrDeny> result) {
+ mSession = session;
+ mPoint = screenPoint;
+ mResult = result;
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission(
+ actionMode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ mResult.complete(AllowOrDeny.ALLOW);
+ mResult = null;
+ actionMode.finish();
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ if (mResult != null) {
+ mResult.complete(AllowOrDeny.DENY);
+ }
+ BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode);
+ }
+
+ @Override
+ public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) {
+ super.onGetContentRect(mode, view, outRect);
+
+ if (mPoint == null) {
+ return;
+ }
+
+ outRect.set(mPoint.x, mPoint.y, mPoint.x + 1, mPoint.y + 1);
+ }
+ }
+
+ /**
+ * Show action mode bar to request clipboard permission
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param permission An {@link ClipboardPermission} describing the permission being requested.
+ * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the
+ * permission request for this site.
+ */
+ @TargetApi(Build.VERSION_CODES.M)
+ @Override
+ public GeckoResult<AllowOrDeny> onShowClipboardPermissionRequest(
+ final GeckoSession session, final ClipboardPermission permission) {
+ ThreadUtils.assertOnUiThread();
+
+ final GeckoResult<AllowOrDeny> result = new GeckoResult<>();
+
+ if (mActionMode != null) {
+ mActionMode.finish();
+ mActionMode = null;
+ }
+ if (mActionModeForClipboardPermission != null) {
+ mActionModeForClipboardPermission.finish();
+ mActionModeForClipboardPermission = null;
+ }
+
+ if (mUseFloatingToolbar) {
+ mActionModeForClipboardPermission =
+ mActivity.startActionMode(
+ new ClipboardPermissionCallbackM(session, permission.screenPoint, result),
+ ActionMode.TYPE_FLOATING);
+ } else {
+ mActionModeForClipboardPermission =
+ mActivity.startActionMode(new ClipboardPermissionCallback(result));
+ }
+
+ return result;
+ }
+
+ /**
+ * Dismiss action mode for requesting clipboard permission popup or model.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @Override
+ public void onDismissClipboardPermissionRequest(final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mActionModeForClipboardPermission != null) {
+ mActionModeForClipboardPermission.finish();
+ mActionModeForClipboardPermission = null;
+ }
+ }
+
+ /* package */ boolean onCreateActionModeForClipboardPermission(
+ final ActionMode actionMode, final Menu menu) {
+ final MenuItem item = menu.add(/* group */ Menu.NONE, Menu.FIRST, Menu.FIRST, /* title */ "");
+ item.setTitle(android.R.string.paste);
+ return true;
+ }
+
+ /* package */ void onDestroyActionModeForClipboardPermission(final ActionMode actionMode) {
+ mActionModeForClipboardPermission = null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java
new file mode 100644
index 0000000000..9162566666
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.util.EventCallback;
+
+/* package */ abstract class CallbackResult<T> extends GeckoResult<T> implements EventCallback {
+ @Override
+ public void sendError(final Object response) {
+ completeExceptionally(
+ response != null ? new Exception(response.toString()) : new UnknownError());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java
new file mode 100644
index 0000000000..77bca329c4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java
@@ -0,0 +1,133 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.Color;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public final class CompositorController {
+ private final GeckoSession.Compositor mCompositor;
+
+ private List<Runnable> mDrawCallbacks;
+ private int mDefaultClearColor = Color.WHITE;
+ private Runnable mFirstPaintCallback;
+
+ /* package */ CompositorController(final GeckoSession session) {
+ mCompositor = session.mCompositor;
+ }
+
+ /* package */ void onCompositorReady() {
+ mCompositor.setDefaultClearColor(mDefaultClearColor);
+ mCompositor.enableLayerUpdateNotifications(mDrawCallbacks != null && !mDrawCallbacks.isEmpty());
+ }
+
+ /* package */ void onCompositorDetached() {
+ if (mDrawCallbacks != null) {
+ mDrawCallbacks.clear();
+ }
+ }
+
+ /* package */ void notifyDrawCallbacks() {
+ if (mDrawCallbacks != null) {
+ for (final Runnable callback : mDrawCallbacks) {
+ callback.run();
+ }
+ }
+ }
+
+ /**
+ * Add a callback to run when drawing (layer update) occurs.
+ *
+ * @param callback Callback to add.
+ */
+ @RobocopTarget
+ public void addDrawCallback(final @NonNull Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDrawCallbacks == null) {
+ mDrawCallbacks = new ArrayList<Runnable>(2);
+ }
+
+ if (mDrawCallbacks.add(callback) && mDrawCallbacks.size() == 1 && mCompositor.isReady()) {
+ mCompositor.enableLayerUpdateNotifications(true);
+ }
+ }
+
+ /**
+ * Remove a previous draw callback.
+ *
+ * @param callback Callback to remove.
+ */
+ @RobocopTarget
+ public void removeDrawCallback(final @NonNull Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDrawCallbacks == null) {
+ return;
+ }
+
+ if (mDrawCallbacks.remove(callback) && mDrawCallbacks.isEmpty() && mCompositor.isReady()) {
+ mCompositor.enableLayerUpdateNotifications(false);
+ }
+ }
+
+ /**
+ * Get the current clear color when drawing.
+ *
+ * @return Curent clear color.
+ */
+ public int getClearColor() {
+ ThreadUtils.assertOnUiThread();
+ return mDefaultClearColor;
+ }
+
+ /**
+ * Set the clear color when drawing. Default is Color.WHITE.
+ *
+ * @param color Clear color.
+ */
+ public void setClearColor(final int color) {
+ ThreadUtils.assertOnUiThread();
+
+ mDefaultClearColor = color;
+ if (mCompositor.isReady()) {
+ mCompositor.setDefaultClearColor(mDefaultClearColor);
+ }
+ }
+
+ /**
+ * Get the current first paint callback.
+ *
+ * @return Current first paint callback or null if not set.
+ */
+ public @Nullable Runnable getFirstPaintCallback() {
+ ThreadUtils.assertOnUiThread();
+ return mFirstPaintCallback;
+ }
+
+ /**
+ * Set a callback to run when a document is first drawn.
+ *
+ * @param callback First paint callback.
+ */
+ public void setFirstPaintCallback(final @Nullable Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+ mFirstPaintCallback = callback;
+ }
+
+ /* package */ void onFirstPaint() {
+ if (mFirstPaintCallback != null) {
+ mFirstPaintCallback.run();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java
new file mode 100644
index 0000000000..6135c17d95
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java
@@ -0,0 +1,1975 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+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.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/** Content Blocking API to hold and control anti-tracking, cookie and Safe Browsing settings. */
+@AnyThread
+public class ContentBlocking {
+ /** {@link SafeBrowsingProvider} configuration for Google's legacy SafeBrowsing server. */
+ public static final SafeBrowsingProvider GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER =
+ SafeBrowsingProvider.withName("google")
+ .version("2.2")
+ .lists(
+ "goog-badbinurl-shavar",
+ "goog-downloadwhite-digest256",
+ "goog-phish-shavar",
+ "googpub-phish-shavar",
+ "goog-malware-shavar",
+ "goog-unwanted-shavar")
+ .updateUrl(
+ "https://safebrowsing.google.com/safebrowsing/downloads?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2&key=%GOOGLE_SAFEBROWSING_API_KEY%")
+ .getHashUrl(
+ "https://safebrowsing.google.com/safebrowsing/gethash?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2")
+ .reportUrl("https://safebrowsing.google.com/safebrowsing/diagnostic?site=")
+ .reportPhishingMistakeUrl("https://%LOCALE%.phish-error.mozilla.com/?url=")
+ .reportMalwareMistakeUrl("https://%LOCALE%.malware-error.mozilla.com/?url=")
+ .advisoryUrl("https://developers.google.com/safe-browsing/v4/advisory")
+ .advisoryName("Google Safe Browsing")
+ .build();
+
+ /** {@link SafeBrowsingProvider} configuration for Google's SafeBrowsing server. */
+ public static final SafeBrowsingProvider GOOGLE_SAFE_BROWSING_PROVIDER =
+ SafeBrowsingProvider.withName("google4")
+ .version("4")
+ .lists(
+ "goog-badbinurl-proto",
+ "goog-downloadwhite-proto",
+ "goog-phish-proto",
+ "googpub-phish-proto",
+ "goog-malware-proto",
+ "goog-unwanted-proto",
+ "goog-harmful-proto")
+ .updateUrl(
+ "https://safebrowsing.googleapis.com/v4/threatListUpdates:fetch?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST")
+ .getHashUrl(
+ "https://safebrowsing.googleapis.com/v4/fullHashes:find?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST")
+ .reportUrl("https://safebrowsing.google.com/safebrowsing/diagnostic?site=")
+ .reportPhishingMistakeUrl("https://%LOCALE%.phish-error.mozilla.com/?url=")
+ .reportMalwareMistakeUrl("https://%LOCALE%.malware-error.mozilla.com/?url=")
+ .advisoryUrl("https://developers.google.com/safe-browsing/v4/advisory")
+ .advisoryName("Google Safe Browsing")
+ .dataSharingUrl(
+ "https://safebrowsing.googleapis.com/v4/threatHits?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST")
+ .dataSharingEnabled(false)
+ .build();
+
+ // This class shouldn't be instantiated
+ protected ContentBlocking() {}
+
+ @AnyThread
+ public static class Settings extends RuntimeSettings {
+ private final Map<String, SafeBrowsingProvider> mSafeBrowsingProviders = new HashMap<>();
+
+ private static final SafeBrowsingProvider[] DEFAULT_PROVIDERS = {
+ ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER,
+ ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER
+ };
+
+ @AnyThread
+ public static class Builder extends RuntimeSettings.Builder<Settings> {
+ @Override
+ protected @NonNull Settings newSettings(final @Nullable Settings settings) {
+ return new Settings(settings);
+ }
+
+ /**
+ * Set custom safe browsing providers.
+ *
+ * @param providers one or more custom providers.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingProviders(
+ final @NonNull SafeBrowsingProvider... providers) {
+ getSettings().setSafeBrowsingProviders(providers);
+ return this;
+ }
+
+ /**
+ * Set the safe browsing table for phishing threats.
+ *
+ * @param safeBrowsingPhishingTable one or more lists for safe browsing phishing.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingPhishingTable(
+ final @NonNull String[] safeBrowsingPhishingTable) {
+ getSettings().setSafeBrowsingPhishingTable(safeBrowsingPhishingTable);
+ return this;
+ }
+
+ /**
+ * Set the safe browsing table for malware threats.
+ *
+ * @param safeBrowsingMalwareTable one or more lists for safe browsing malware.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingMalwareTable(
+ final @NonNull String[] safeBrowsingMalwareTable) {
+ getSettings().setSafeBrowsingMalwareTable(safeBrowsingMalwareTable);
+ return this;
+ }
+
+ /**
+ * Set anti-tracking categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the
+ * {@link ContentBlocking.AntiTracking} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder antiTracking(final @CBAntiTracking int cat) {
+ getSettings().setAntiTracking(cat);
+ return this;
+ }
+
+ /**
+ * Set safe browsing categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the
+ * {@link ContentBlocking.SafeBrowsing} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder safeBrowsing(final @CBSafeBrowsing int cat) {
+ getSettings().setSafeBrowsing(cat);
+ return this;
+ }
+
+ /**
+ * Set cookie storage behavior.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBehavior(final @CBCookieBehavior int behavior) {
+ getSettings().setCookieBehavior(behavior);
+ return this;
+ }
+
+ /**
+ * Set cookie storage behavior in private browsing mode.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) {
+ getSettings().setCookieBehaviorPrivateMode(behavior);
+ return this;
+ }
+
+ /**
+ * Set the ETP behavior level.
+ *
+ * @param level The level of ETP blocking to use. Only takes effect if cookie behavior is set
+ * to {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder enhancedTrackingProtectionLevel(final @CBEtpLevel int level) {
+ getSettings().setEnhancedTrackingProtectionLevel(level);
+ return this;
+ }
+
+ /**
+ * Set whether or not email tracker blocking is enabled in private mode.
+ *
+ * @param enabled A boolean indicating whether or not email tracker blocking should be enabled
+ * in private mode.
+ * @return The builder instance.
+ */
+ public @NonNull Builder emailTrackerBlockingPrivateMode(final boolean enabled) {
+ getSettings().setEmailTrackerBlockingPrivateBrowsing(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not strict social tracking protection is enabled. This will block resources
+ * from loading if they are on the social tracking protection list, rather than just blocking
+ * cookies as with normal social tracking protection.
+ *
+ * @param enabled A boolean indicating whether or not strict social tracking protection should
+ * be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder strictSocialTrackingProtection(final boolean enabled) {
+ getSettings().setStrictSocialTrackingProtection(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not to automatically purge tracking cookies. This will purge cookies from
+ * tracking sites that do not have recent user interaction provided that the cookie behavior
+ * is set to either {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ *
+ * @param enabled A boolean indicating whether or not cookie purging should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder cookiePurging(final boolean enabled) {
+ getSettings().setCookiePurging(enabled);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode.
+ *
+ * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerHandlingMode(final @CBCookieBannerMode int mode) {
+ getSettings().setCookieBannerMode(mode);
+ return this;
+ }
+
+ /**
+ * When set to true, enable the use of global CookieBannerRules.
+ *
+ * @param enabled A boolean indicating whether to enable the use of global CookieBannerRules.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerGlobalRulesEnabled(final boolean enabled) {
+ getSettings().setCookieBannerGlobalRulesEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * When set to true, enable the use of global CookieBannerRules in sub-frames.
+ *
+ * @param enabled A boolean indicating whether to enable the use of global CookieBannerRules
+ * in sub-frames.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerGlobalRulesSubFramesEnabled(final boolean enabled) {
+ getSettings().setCookieBannerGlobalRulesSubFramesEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * When set to true, query parameter stripping is enabled in normal mode.
+ *
+ * @param enabled A boolean indicating whether to query parameter stripping enabled in normal
+ * mode.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder queryParameterStrippingEnabled(final boolean enabled) {
+ getSettings().setQueryParameterStrippingEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * When set to true, query parameter stripping is enabled in private mode.
+ *
+ * @param enabled A boolean indicating whether to query parameter stripping enabled in private
+ * mode.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder queryParameterStrippingPrivateBrowsingEnabled(final boolean enabled) {
+ getSettings().setQueryParameterStrippingPrivateBrowsingEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * The allowed list for the query parameter stripping feature.
+ *
+ * @param list an array of identifiers for query parameter's stripping feature.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder queryParameterStrippingAllowList(final @NonNull String... list) {
+ getSettings().setQueryParameterStrippingAllowList(list);
+ return this;
+ }
+
+ /**
+ * The strip list for the query parameter stripping feature.
+ *
+ * @param list an array of identifiers for the strip list of the query parameter's stripping
+ * feature.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder queryParameterStrippingStripList(final @NonNull String... list) {
+ getSettings().setQueryParameterStrippingStripList(list);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode for private browsing.
+ *
+ * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerHandlingModePrivateBrowsing(
+ final @CBCookieBannerMode int mode) {
+ getSettings().setCookieBannerModePrivateBrowsing(mode);
+ return this;
+ }
+
+ /**
+ * When set to true, cookie banners are detected and detection events are dispatched, but they
+ * will not be handled.
+ *
+ * @param enabled A boolean indicating whether to enable cookie banner detect only mode.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerHandlingDetectOnlyMode(final boolean enabled) {
+ getSettings().setCookieBannerDetectOnlyMode(enabled);
+ return this;
+ }
+ }
+
+ /* package */ final Pref<String> mAt =
+ new Pref<String>(
+ "urlclassifier.trackingTable", ContentBlocking.catToAtPref(AntiTracking.DEFAULT));
+ /* package */ final Pref<Boolean> mCm =
+ new Pref<Boolean>("privacy.trackingprotection.cryptomining.enabled", false);
+ /* package */ final Pref<String> mCmList =
+ new Pref<String>(
+ "urlclassifier.features.cryptomining.blacklistTables",
+ ContentBlocking.catToCmListPref(AntiTracking.NONE));
+ /* package */ final Pref<Boolean> mFp =
+ new Pref<Boolean>("privacy.trackingprotection.fingerprinting.enabled", false);
+ /* package */ final Pref<String> mFpList =
+ new Pref<String>(
+ "urlclassifier.features.fingerprinting.blacklistTables",
+ ContentBlocking.catToFpListPref(AntiTracking.NONE));
+ /* package */ final Pref<Boolean> mSt =
+ new Pref<Boolean>("privacy.socialtracking.block_cookies.enabled", false);
+ /* package */ final Pref<Boolean> mStStrict =
+ new Pref<Boolean>("privacy.trackingprotection.socialtracking.enabled", false);
+ /* package */ final Pref<String> mStList =
+ new Pref<String>(
+ "urlclassifier.features.socialtracking.annotate.blacklistTables",
+ ContentBlocking.catToPref(AntiTracking.NONE, AntiTracking.STP, STP));
+
+ /* package */ final Pref<Boolean> mSbMalware =
+ new Pref<Boolean>("browser.safebrowsing.malware.enabled", true);
+ /* package */ final Pref<Boolean> mSbPhishing =
+ new Pref<Boolean>("browser.safebrowsing.phishing.enabled", true);
+ /* package */ final Pref<Integer> mCookieBehavior =
+ new Pref<Integer>("network.cookie.cookieBehavior", CookieBehavior.ACCEPT_NON_TRACKERS);
+ /* package */ final Pref<Integer> mCookieBehaviorPrivateMode =
+ new Pref<Integer>(
+ "network.cookie.cookieBehavior.pbmode", CookieBehavior.ACCEPT_NON_TRACKERS);
+ /* package */ final Pref<Boolean> mCookiePurging =
+ new Pref<Boolean>("privacy.purge_trackers.enabled", false);
+
+ /* package */ final Pref<Boolean> mEtpEnabled =
+ new Pref<Boolean>("privacy.trackingprotection.annotate_channels", false);
+ /* package */ final Pref<Boolean> mEtpStrict =
+ new Pref<Boolean>("privacy.annotate_channels.strict_list.enabled", false);
+
+ /* package */ final Pref<Integer> mCbhMode =
+ new Pref<Integer>(
+ "cookiebanners.service.mode", CookieBannerMode.COOKIE_BANNER_MODE_DISABLED);
+ /* package */ final Pref<Integer> mCbhModePrivateBrowsing =
+ new Pref<Integer>(
+ "cookiebanners.service.mode.privateBrowsing",
+ CookieBannerMode.COOKIE_BANNER_MODE_REJECT);
+
+ /* package */ final Pref<Boolean> mChbDetectOnlyMode =
+ new Pref<Boolean>("cookiebanners.service.detectOnly", false);
+ /* package */
+ final Pref<Boolean> mCbhGlobalRulesEnabled =
+ new Pref<Boolean>("cookiebanners.service.enableGlobalRules", false);
+
+ final Pref<Boolean> mCbhGlobalRulesSubFramesEnabled =
+ new Pref<Boolean>("cookiebanners.service.enableGlobalRules.subFrames", false);
+
+ /* package */ final Pref<Boolean> mQueryParameterStrippingEnabled =
+ new Pref<Boolean>("privacy.query_stripping.enabled", false);
+
+ /* package */ final Pref<Boolean> mQueryParameterStrippingPrivateBrowsingEnabled =
+ new Pref<Boolean>("privacy.query_stripping.enabled.pbmode", false);
+
+ /* package */ final Pref<String> mQueryParameterStrippingAllowList =
+ new Pref<>("privacy.query_stripping.allow_list", "");
+
+ /* package */ final Pref<String> mQueryParameterStrippingStripList =
+ new Pref<>("privacy.query_stripping.strip_list", "");
+
+ /* package */ final Pref<Boolean> mEtb =
+ new Pref<Boolean>("privacy.trackingprotection.emailtracking.enabled", false);
+
+ /* package */ final Pref<Boolean> mEtbPrivateBrowsing =
+ new Pref<Boolean>("privacy.trackingprotection.emailtracking.pbmode.enabled", false);
+
+ /* package */ final Pref<String> mEtbList =
+ new Pref<String>(
+ "urlclassifier.features.emailtracking.blocklistTables",
+ ContentBlocking.catToPref(AntiTracking.NONE, AntiTracking.EMAIL, EMAIL));
+
+ /* package */ final Pref<String> mSafeBrowsingMalwareTable =
+ new Pref<>(
+ "urlclassifier.malwareTable",
+ ContentBlocking.listsToPref(
+ "goog-malware-proto",
+ "goog-unwanted-proto",
+ "moztest-harmful-simple",
+ "moztest-malware-simple",
+ "moztest-unwanted-simple"));
+ /* package */ final Pref<String> mSafeBrowsingPhishingTable =
+ new Pref<>(
+ "urlclassifier.phishTable",
+ ContentBlocking.listsToPref(
+ // In official builds, we are allowed to use Google's private phishing
+ // list (see bug 1288840).
+ BuildConfig.MOZILLA_OFFICIAL ? "goog-phish-proto" : "googpub-phish-proto",
+ "moztest-phish-simple"));
+
+ /** Construct default settings. */
+ /* package */ Settings() {
+ this(null /* settings */);
+ }
+
+ /**
+ * Copy-construct settings.
+ *
+ * @param settings Copy from this settings.
+ */
+ /* package */ Settings(final @Nullable Settings settings) {
+ this(null /* parent */, settings);
+ }
+
+ /**
+ * Copy-construct nested settings.
+ *
+ * @param parent The parent settings used for nesting.
+ * @param settings Copy from this settings.
+ */
+ /* package */ Settings(
+ final @Nullable RuntimeSettings parent, final @Nullable Settings settings) {
+ super(parent);
+
+ if (settings != null) {
+ updatePrefs(settings);
+ } else {
+ // Set default browsing providers
+ setSafeBrowsingProviders(DEFAULT_PROVIDERS);
+ }
+ }
+
+ @Override
+ protected void updatePrefs(final @NonNull RuntimeSettings settings) {
+ super.updatePrefs(settings);
+
+ final ContentBlocking.Settings source = (ContentBlocking.Settings) settings;
+ for (final SafeBrowsingProvider provider : source.mSafeBrowsingProviders.values()) {
+ mSafeBrowsingProviders.put(provider.getName(), new SafeBrowsingProvider(this, provider));
+ }
+ }
+
+ /**
+ * Get the collection of {@link SafeBrowsingProvider} for this runtime.
+ *
+ * @return an unmodifiable collection of {@link SafeBrowsingProvider}
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Collection<SafeBrowsingProvider> getSafeBrowsingProviders() {
+ return Collections.unmodifiableCollection(mSafeBrowsingProviders.values());
+ }
+
+ /**
+ * Sets the collection of {@link SafeBrowsingProvider} for this runtime.
+ *
+ * <p>By default the collection is composed of {@link
+ * ContentBlocking#GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER} and {@link
+ * ContentBlocking#GOOGLE_SAFE_BROWSING_PROVIDER}.
+ *
+ * @param providers {@link SafeBrowsingProvider} instances for this runtime.
+ * @return the {@link Settings} instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Settings setSafeBrowsingProviders(
+ final @NonNull SafeBrowsingProvider... providers) {
+ mSafeBrowsingProviders.clear();
+
+ for (final SafeBrowsingProvider provider : providers) {
+ mSafeBrowsingProviders.put(provider.getName(), new SafeBrowsingProvider(this, provider));
+ }
+
+ return this;
+ }
+
+ /**
+ * Get the table for SafeBrowsing Phishing. The identifiers present in this table must match one
+ * of the identifiers present in {@link SafeBrowsingProvider#getLists}.
+ *
+ * @return an array of identifiers for SafeBrowsing's Phishing feature
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull String[] getSafeBrowsingPhishingTable() {
+ return ContentBlocking.prefToLists(mSafeBrowsingPhishingTable.get());
+ }
+
+ /**
+ * Sets the table for SafeBrowsing Phishing.
+ *
+ * @param table an array of identifiers for SafeBrowsing's Phishing feature.
+ * @return this {@link Settings} instance.
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull Settings setSafeBrowsingPhishingTable(final @NonNull String... table) {
+ mSafeBrowsingPhishingTable.commit(ContentBlocking.listsToPref(table));
+ return this;
+ }
+
+ /**
+ * Get the table for SafeBrowsing Malware. The identifiers present in this table must match one
+ * of the identifiers present in {@link SafeBrowsingProvider#getLists}.
+ *
+ * @return an array of identifiers for SafeBrowsing's Malware feature
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull String[] getSafeBrowsingMalwareTable() {
+ return ContentBlocking.prefToLists(mSafeBrowsingMalwareTable.get());
+ }
+
+ /**
+ * Sets the allowed list for the query parameter stripping feature.
+ *
+ * @param list an array of identifiers for the allowed list of the query parameter's stripping
+ * feature.
+ * @return this {@link Settings} instance.
+ */
+ public @NonNull Settings setQueryParameterStrippingAllowList(final @NonNull String... list) {
+ mQueryParameterStrippingAllowList.commit(ContentBlocking.listsToPref(list));
+ return this;
+ }
+
+ /**
+ * Get the allowed list for the query parameter stripping feature.
+ *
+ * @return an array of identifiers for the allowed list for the query parameter stripping
+ * feature.
+ */
+ public @NonNull String[] getQueryParameterStrippingAllowList() {
+ return ContentBlocking.prefToLists(mQueryParameterStrippingAllowList.get());
+ }
+
+ /**
+ * Sets the strip list for the query parameter stripping feature.
+ *
+ * @param list an array of identifiers for the strip list of the query parameter's stripping
+ * feature.
+ * @return this {@link Settings} instance.
+ */
+ public @NonNull Settings setQueryParameterStrippingStripList(final @NonNull String... list) {
+ mQueryParameterStrippingStripList.commit(ContentBlocking.listsToPref(list));
+ return this;
+ }
+
+ /**
+ * Get the strip list for the query parameter stripping feature
+ *
+ * @return an array of identifiers for the allowed list for the query parameter stripping
+ * feature.
+ */
+ public @NonNull String[] getQueryParameterStrippingStripList() {
+ return ContentBlocking.prefToLists(mQueryParameterStrippingStripList.get());
+ }
+
+ /**
+ * Sets the table for SafeBrowsing Malware.
+ *
+ * @param table an array of identifiers for SafeBrowsing's Malware feature.
+ * @return this {@link Settings} instance.
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull Settings setSafeBrowsingMalwareTable(final @NonNull String... table) {
+ mSafeBrowsingMalwareTable.commit(ContentBlocking.listsToPref(table));
+ return this;
+ }
+
+ /**
+ * Set anti-tracking categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the {@link
+ * ContentBlocking.AntiTracking} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setAntiTracking(final @CBAntiTracking int cat) {
+ mAt.commit(ContentBlocking.catToAtPref(cat));
+
+ mCm.commit(ContentBlocking.catToCmPref(cat));
+ mCmList.commit(ContentBlocking.catToCmListPref(cat));
+
+ mFp.commit(ContentBlocking.catToFpPref(cat));
+ mFpList.commit(ContentBlocking.catToFpListPref(cat));
+
+ mSt.commit(ContentBlocking.catToStPref(cat));
+ mStList.commit(ContentBlocking.catToPref(cat, AntiTracking.STP, STP));
+
+ mEtb.commit(ContentBlocking.catToEtbPref(cat));
+ mEtbList.commit(ContentBlocking.catToPref(cat, AntiTracking.EMAIL, EMAIL));
+ return this;
+ }
+
+ /**
+ * Set the ETP behavior level.
+ *
+ * @param level The level of ETP blocking to use; must be one of {@link
+ * ContentBlocking.EtpLevel} flags. Only takes effect if the cookie behavior is {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setEnhancedTrackingProtectionLevel(final @CBEtpLevel int level) {
+ mEtpEnabled.commit(
+ level == ContentBlocking.EtpLevel.DEFAULT || level == ContentBlocking.EtpLevel.STRICT);
+ mEtpStrict.commit(level == ContentBlocking.EtpLevel.STRICT);
+ return this;
+ }
+
+ /**
+ * Set whether or not strict social tracking protection is enabled (ie, whether to block content
+ * or just cookies). Will only block if social tracking protection lists are supplied to {@link
+ * #setAntiTracking}.
+ *
+ * @param enabled A boolean indicating whether or not to enable strict social tracking
+ * protection.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setStrictSocialTrackingProtection(final boolean enabled) {
+ mStStrict.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Set safe browsing categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the {@link
+ * ContentBlocking.SafeBrowsing} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setSafeBrowsing(final @CBSafeBrowsing int cat) {
+ mSbMalware.commit(ContentBlocking.catToSbMalware(cat));
+ mSbPhishing.commit(ContentBlocking.catToSbPhishing(cat));
+ return this;
+ }
+
+ /**
+ * Get the set anti-tracking categories.
+ *
+ * @return The categories of resources to be blocked.
+ */
+ public @CBAntiTracking int getAntiTrackingCategories() {
+ return ContentBlocking.atListToAtCat(mAt.get())
+ | ContentBlocking.cmListToAtCat(mCmList.get())
+ | ContentBlocking.fpListToAtCat(mFpList.get())
+ | ContentBlocking.stListToAtCat(mStList.get())
+ | ContentBlocking.etbListToAtCat(mEtbList.get());
+ }
+
+ /**
+ * Get the set ETP behavior level.
+ *
+ * @return The current ETP level; one of {@link ContentBlocking.EtpLevel}.
+ */
+ public @CBEtpLevel int getEnhancedTrackingProtectionLevel() {
+ if (mEtpStrict.get()) {
+ return ContentBlocking.EtpLevel.STRICT;
+ } else if (mEtpEnabled.get()) {
+ return ContentBlocking.EtpLevel.DEFAULT;
+ }
+ return ContentBlocking.EtpLevel.NONE;
+ }
+
+ /**
+ * Get whether or not strict social tracking protection is enabled.
+ *
+ * @return A boolean indicating whether or not strict social tracking protection is enabled.
+ */
+ public boolean getStrictSocialTrackingProtection() {
+ return mStStrict.get();
+ }
+
+ /**
+ * Get the set safe browsing categories.
+ *
+ * @return The categories of resources to be blocked.
+ */
+ public @CBSafeBrowsing int getSafeBrowsingCategories() {
+ return ContentBlocking.sbMalwareToSbCat(mSbMalware.get())
+ | ContentBlocking.sbPhishingToSbCat(mSbPhishing.get());
+ }
+
+ /**
+ * Get the assigned cookie storage behavior.
+ *
+ * @return The assigned behavior, as one of {@link CookieBehavior} flags.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBehavior int getCookieBehavior() {
+ return mCookieBehavior.get();
+ }
+
+ /**
+ * Set cookie storage behavior.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBehavior(final @CBCookieBehavior int behavior) {
+ mCookieBehavior.commit(behavior);
+ return this;
+ }
+
+ /**
+ * Get the assigned private mode cookie storage behavior.
+ *
+ * @return The assigned behavior, as one of {@link CookieBehavior} flags.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBehavior int getCookieBehaviorPrivateMode() {
+ return mCookieBehaviorPrivateMode.get();
+ }
+
+ /**
+ * Set cookie storage behavior for private browsing mode.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) {
+ mCookieBehaviorPrivateMode.commit(behavior);
+ return this;
+ }
+
+ /**
+ * Get whether or not cookie purging is enabled.
+ *
+ * @return A boolean indicating whether or not cookie purging is enabled.
+ */
+ public boolean getCookiePurging() {
+ return mCookiePurging.get();
+ }
+
+ /**
+ * Enable or disable cookie purging. This will automatically purge cookies from tracking sites
+ * that have no recent user interaction, provided the cookie behavior is set to {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ *
+ * @param enabled A boolean indicating whether to enable cookie purging.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookiePurging(final boolean enabled) {
+ mCookiePurging.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode to the new provided {@link CBCookieBannerMode} value.
+ *
+ * @param mode Integer indicating the new mode.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBannerMode(final @CBCookieBannerMode int mode) {
+ mCbhMode.commit(mode);
+ return this;
+ }
+
+ /**
+ * When set to true, cookie banners are detected and detection events are dispatched, but they
+ * will not be handled. Requires the service to be enabled for the desired mode via
+ * setCookieBannerMode.
+ *
+ * @param enabled A boolean indicating whether to enable cookie banners.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBannerDetectOnlyMode(final boolean enabled) {
+ mChbDetectOnlyMode.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Enables/disables the use of global CookieBannerRules, which apply to all sites. This enable
+ * handling of CMPs across sites without the use of site-specific rules.
+ *
+ * @param enabled A boolean indicating whether or not to enable.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBannerGlobalRulesEnabled(final boolean enabled) {
+ mCbhGlobalRulesEnabled.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Indicates if global CookieBannerRules is enabled or not.
+ *
+ * @return Indicates if global CookieBannerRule is enabled or disabled.
+ */
+ public boolean getCookieBannerGlobalRulesEnabled() {
+ return mCbhGlobalRulesEnabled.get();
+ }
+
+ /**
+ * Whether global rules are allowed to run in sub-frames. Running query selectors in every
+ * sub-frame may negatively impact performance, but is required for some CMPs.
+ *
+ * @param enabled A boolean indicating whether or not to enable.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBannerGlobalRulesSubFramesEnabled(final boolean enabled) {
+ mCbhGlobalRulesSubFramesEnabled.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Indicates if email tracker blocking is enabled in private mode.
+ *
+ * @return Indicates if email tracker blocking is enabled or disabled in private mode.
+ */
+ public @NonNull Boolean getEmailTrackerBlockingPrivateBrowsingEnabled() {
+ return mEtbPrivateBrowsing.get();
+ }
+
+ /**
+ * Sets whether email tracker blocking is enabled in private mode.
+ *
+ * @param enabled A boolean indicating whether or not to enable.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setEmailTrackerBlockingPrivateBrowsing(final boolean enabled) {
+ mEtbPrivateBrowsing.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Sets whether query parameter stripping is enabled in normal mode.
+ *
+ * @param enabled A boolean indicating whether or not to enable.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setQueryParameterStrippingEnabled(final boolean enabled) {
+ mQueryParameterStrippingEnabled.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Indicates if query parameter stripping is enabled in normal mode.
+ *
+ * @return Indicates if query parameter stripping is enabled or disabled in normal mode.
+ */
+ public boolean getQueryParameterStrippingEnabled() {
+ return mQueryParameterStrippingEnabled.get();
+ }
+
+ /**
+ * Sets Whether query parameter stripping is enabled in private mode.
+ *
+ * @param enabled A boolean indicating whether or not to enable in private mode.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setQueryParameterStrippingPrivateBrowsingEnabled(
+ final boolean enabled) {
+ mQueryParameterStrippingPrivateBrowsingEnabled.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Indicates if query parameter stripping is enabled in private mode.
+ *
+ * @return Indicates if global CookieBannerRules is enabled or disabled in sub-frames.
+ */
+ public boolean getQueryParameterStrippingPrivateBrowsingEnabled() {
+ return mQueryParameterStrippingPrivateBrowsingEnabled.get();
+ }
+
+ /**
+ * Indicates if global CookieBannerRules is enabled or not in sub-frames.
+ *
+ * @return Indicates if global CookieBannerRules is enabled or disabled in sub-frames.
+ */
+ public boolean getCookieBannerGlobalRulesSubFramesEnabled() {
+ return mCbhGlobalRulesSubFramesEnabled.get();
+ }
+
+ /**
+ * Indicates if cookie banner handling detect only mode is enabled.
+ *
+ * @return boolean indicating if the cookie banner handling detect only mode setting is enabled.
+ */
+ public boolean getCookieBannerDetectOnlyMode() {
+ return mChbDetectOnlyMode.get();
+ }
+
+ /**
+ * Gets the current cookie banner handling mode.
+ *
+ * @return int the current cookie banner handling mode, one of the {@link CBCookieBannerMode}.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBannerMode int getCookieBannerMode() {
+ return mCbhMode.get();
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode for private browsing to the new provided {@link
+ * CBCookieBannerMode} value.
+ *
+ * @param mode Integer indicating the new mode.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBannerModePrivateBrowsing(
+ final @CBCookieBannerMode int mode) {
+ mCbhModePrivateBrowsing.commit(mode);
+ return this;
+ }
+
+ /**
+ * Gets the current cookie banner handling mode for private browsing.
+ *
+ * @return int the current cookie banner handling mode, one of the {@link CBCookieBannerMode}.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBannerMode int getCookieBannerModePrivateBrowsing() {
+ return mCbhModePrivateBrowsing.get();
+ }
+
+ public static final Parcelable.Creator<Settings> CREATOR =
+ new Parcelable.Creator<Settings>() {
+ @Override
+ public Settings createFromParcel(final Parcel in) {
+ final Settings settings = new Settings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public Settings[] newArray(final int size) {
+ return new Settings[size];
+ }
+ };
+ }
+
+ /**
+ * Holds configuration for a SafeBrowsing provider. <br>
+ * <br>
+ * This class can be used to modify existing configuration for SafeBrowsing providers or to add a
+ * custom SafeBrowsing provider to the app. <br>
+ * <br>
+ * Default configuration for Google's SafeBrowsing servers can be found at {@link
+ * ContentBlocking#GOOGLE_SAFE_BROWSING_PROVIDER} and {@link
+ * ContentBlocking#GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER}. <br>
+ * <br>
+ * This class is immutable, once constructed its values cannot be changed. <br>
+ * <br>
+ * You can, however, use the {@link #from} method to build upon an existing configuration. For
+ * example to override the Google's server configuration, you can do the following: <br>
+ *
+ * <pre><code>
+ * SafeBrowsingProvider override = SafeBrowsingProvider
+ * .from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER)
+ * .getHashUrl("http://my-custom-server.com/...")
+ * .updateUrl("http://my-custom-server.com/...")
+ * .build();
+ *
+ * runtime.getContentBlocking().setSafeBrowsingProviders(override);
+ * </code></pre>
+ *
+ * This will override the configuration. <br>
+ * <br>
+ * You can also add a custom SafeBrowsing provider using the {@link #withName} method. For
+ * example, to add a custom provider that provides the list <code>testprovider-phish-digest256
+ * </code> do the following: <br>
+ *
+ * <pre><code>
+ * SafeBrowsingProvider custom = SafeBrowsingProvider
+ * .withName("custom-provider")
+ * .version("2.2")
+ * .lists("testprovider-phish-digest256")
+ * .updateUrl("http://my-custom-server2.com/...")
+ * .getHashUrl("http://my-custom-server2.com/...")
+ * .build();
+ * </code></pre>
+ *
+ * And then add the custom provider (adding optionally existing providers): <br>
+ *
+ * <pre><code>
+ * runtime.getContentBlocking().setSafeBrowsingProviders(
+ * custom,
+ * // Add this if you want to keep the existing configuration too.
+ * ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER,
+ * ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER);
+ * </code></pre>
+ *
+ * And set the list in the phishing configuration <br>
+ *
+ * <pre><code>
+ * runtime.getContentBlocking().setSafeBrowsingPhishingTable(
+ * "testprovider-phish-digest256",
+ * // Existing configuration
+ * "goog-phish-proto");
+ * </code></pre>
+ *
+ * Note that any list present in the phishing or malware tables need to appear in one safe
+ * browsing provider's {@link #getLists} property.
+ *
+ * <p>See also <a href="https://developers.google.com/safe-browsing/v4">safe-browsing/v4</a>.
+ */
+ @AnyThread
+ public static class SafeBrowsingProvider extends RuntimeSettings {
+ private static final String ROOT = "browser.safebrowsing.provider.";
+
+ private final String mName;
+
+ /* package */ final Pref<String> mVersion;
+ /* package */ final Pref<String> mLists;
+ /* package */ final Pref<String> mUpdateUrl;
+ /* package */ final Pref<String> mGetHashUrl;
+ /* package */ final Pref<String> mReportUrl;
+ /* package */ final Pref<String> mReportPhishingMistakeUrl;
+ /* package */ final Pref<String> mReportMalwareMistakeUrl;
+ /* package */ final Pref<String> mAdvisoryUrl;
+ /* package */ final Pref<String> mAdvisoryName;
+ /* package */ final Pref<String> mDataSharingUrl;
+ /* package */ final Pref<Boolean> mDataSharingEnabled;
+
+ /**
+ * Creates a {@link SafeBrowsingProvider.Builder} for a provider with the given name.
+ *
+ * <p>Note: the <code>mozilla</code> name is reserved for internal use, and this method will
+ * throw if you attempt to build a provider with that name.
+ *
+ * @param name The name of the provider.
+ * @return a {@link Builder} instance that can be used to build a provider.
+ * @throws IllegalArgumentException if this method is called with <code>name="mozilla"</code>
+ */
+ @NonNull
+ public static Builder withName(final @NonNull String name) {
+ if ("mozilla".equals(name)) {
+ throw new IllegalArgumentException("The 'mozilla' name is reserved for internal use.");
+ }
+ return new Builder(name);
+ }
+
+ /**
+ * Creates a {@link SafeBrowsingProvider.Builder} based on the given provider.
+ *
+ * <p>All properties not otherwise specified will be copied from the provider given in input.
+ *
+ * @param provider The source provider for this builder.
+ * @return a {@link Builder} instance that can be used to create a configuration based on the
+ * builder in input.
+ */
+ @NonNull
+ public static Builder from(final @NonNull SafeBrowsingProvider provider) {
+ return new Builder(provider);
+ }
+
+ @AnyThread
+ public static class Builder {
+ final SafeBrowsingProvider mProvider;
+
+ private Builder(final String name) {
+ mProvider = new SafeBrowsingProvider(name);
+ }
+
+ private Builder(final SafeBrowsingProvider source) {
+ mProvider = new SafeBrowsingProvider(source);
+ }
+
+ /**
+ * Sets the SafeBrowsing protocol session for this provider.
+ *
+ * @param version the version strong, e.g. "2.2" or "4".
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder version(final @NonNull String version) {
+ mProvider.mVersion.set(version);
+ return this;
+ }
+
+ /**
+ * Sets the lists provided by this provider.
+ *
+ * @param lists one or more lists for this provider, e.g. "goog-malware-proto",
+ * "goog-unwanted-proto"
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder lists(final @NonNull String... lists) {
+ mProvider.mLists.set(ContentBlocking.listsToPref(lists));
+ return this;
+ }
+
+ /**
+ * Sets the url that will be used to update the threat list for this provider.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/threatListUpdates/fetch">
+ * v4/threadListUpdates/fetch </a>.
+ *
+ * @param updateUrl the update url endpoint for this provider
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder updateUrl(final @NonNull String updateUrl) {
+ mProvider.mUpdateUrl.set(updateUrl);
+ return this;
+ }
+
+ /**
+ * Sets the url that will be used to get the full hashes that match a partial hash.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/fullHashes/find">
+ * v4/fullHashes/find </a>.
+ *
+ * @param getHashUrl the gethash url endpoint for this provider
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder getHashUrl(final @NonNull String getHashUrl) {
+ mProvider.mGetHashUrl.set(getHashUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url to the SafeBrowsing provider.
+ *
+ * @param reportUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportUrl(final @NonNull String reportUrl) {
+ mProvider.mReportUrl.set(reportUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url mistakenly reported as Phishing to the
+ * SafeBrowsing provider.
+ *
+ * @param reportPhishingMistakeUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportPhishingMistakeUrl(
+ final @NonNull String reportPhishingMistakeUrl) {
+ mProvider.mReportPhishingMistakeUrl.set(reportPhishingMistakeUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url mistakenly reported as Malware to the
+ * SafeBrowsing provider.
+ *
+ * @param reportMalwareMistakeUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportMalwareMistakeUrl(
+ final @NonNull String reportMalwareMistakeUrl) {
+ mProvider.mReportMalwareMistakeUrl.set(reportMalwareMistakeUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to give a general advisory about this SafeBrowsing provider.
+ *
+ * @param advisoryUrl the adivisory page url for this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder advisoryUrl(final @NonNull String advisoryUrl) {
+ mProvider.mAdvisoryUrl.set(advisoryUrl);
+ return this;
+ }
+
+ /**
+ * Set the advisory name for this provider.
+ *
+ * @param advisoryName the adivisory name for this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder advisoryName(final @NonNull String advisoryName) {
+ mProvider.mAdvisoryName.set(advisoryName);
+ return this;
+ }
+
+ /**
+ * Set url to share threat data to the provider, if enabled by {@link #dataSharingEnabled}.
+ *
+ * @param dataSharingUrl the url endpoint
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder dataSharingUrl(final @NonNull String dataSharingUrl) {
+ mProvider.mDataSharingUrl.set(dataSharingUrl);
+ return this;
+ }
+
+ /**
+ * Set whether to share threat data with the provider, off by default.
+ *
+ * @param dataSharingEnabled <code>true</code> if the browser should share threat data with
+ * the provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder dataSharingEnabled(final boolean dataSharingEnabled) {
+ mProvider.mDataSharingEnabled.set(dataSharingEnabled);
+ return this;
+ }
+
+ /**
+ * Build the {@link SafeBrowsingProvider} based on this {@link Builder} instance.
+ *
+ * @return thie {@link SafeBrowsingProvider} instance.
+ */
+ public @NonNull SafeBrowsingProvider build() {
+ return new SafeBrowsingProvider(mProvider);
+ }
+ }
+
+ /* package */ SafeBrowsingProvider(final SafeBrowsingProvider source) {
+ this(/* name */ null, /* parent */ null, source);
+ }
+
+ /* package */ SafeBrowsingProvider(
+ final RuntimeSettings parent, final SafeBrowsingProvider source) {
+ this(/* name */ null, parent, source);
+ }
+
+ /* package */ SafeBrowsingProvider(final String name) {
+ this(name, /* parent */ null, /* source */ null);
+ }
+
+ /* package */ SafeBrowsingProvider(
+ final String name, final RuntimeSettings parent, final SafeBrowsingProvider source) {
+ super(parent);
+
+ if (name != null) {
+ mName = name;
+ } else if (source != null) {
+ mName = source.mName;
+ } else {
+ throw new IllegalArgumentException("Either name or source must be non-null");
+ }
+
+ mVersion = new Pref<>(ROOT + mName + ".pver", null);
+ mLists = new Pref<>(ROOT + mName + ".lists", null);
+ mUpdateUrl = new Pref<>(ROOT + mName + ".updateURL", null);
+ mGetHashUrl = new Pref<>(ROOT + mName + ".gethashURL", null);
+ mReportUrl = new Pref<>(ROOT + mName + ".reportURL", null);
+ mReportPhishingMistakeUrl = new Pref<>(ROOT + mName + ".reportPhishMistakeURL", null);
+ mReportMalwareMistakeUrl = new Pref<>(ROOT + mName + ".reportMalwareMistakeURL", null);
+ mAdvisoryUrl = new Pref<>(ROOT + mName + ".advisoryURL", null);
+ mAdvisoryName = new Pref<>(ROOT + mName + ".advisoryName", null);
+ mDataSharingUrl = new Pref<>(ROOT + mName + ".dataSharingURL", null);
+ mDataSharingEnabled = new Pref<>(ROOT + mName + ".dataSharing.enabled", false);
+
+ if (source != null) {
+ updatePrefs(source);
+ }
+ }
+
+ /**
+ * Get the name of this provider.
+ *
+ * @return a string containing the name.
+ */
+ public @NonNull String getName() {
+ return mName;
+ }
+
+ /**
+ * Get the version for this provider.
+ *
+ * @return a string representing the version, e.g. "2.2" or "4".
+ */
+ public @Nullable String getVersion() {
+ return mVersion.get();
+ }
+
+ /**
+ * Get the lists provided by this provider.
+ *
+ * @return an array of string identifiers for the lists
+ */
+ public @NonNull String[] getLists() {
+ return ContentBlocking.prefToLists(mLists.get());
+ }
+
+ /**
+ * Get the url that will be used to update the threat list for this provider.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/threatListUpdates/fetch">
+ * v4/threadListUpdates/fetch </a>.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getUpdateUrl() {
+ return mUpdateUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to get the full hashes that match a partial hash.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/fullHashes/find">
+ * v4/fullHashes/find </a>.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getGetHashUrl() {
+ return mGetHashUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url to the SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportUrl() {
+ return mReportUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url mistakenly reported as Phishing to the
+ * SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportPhishingMistakeUrl() {
+ return mReportPhishingMistakeUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url mistakenly reported as Malware to the
+ * SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportMalwareMistakeUrl() {
+ return mReportMalwareMistakeUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to give a general advisory about this SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getAdvisoryUrl() {
+ return mAdvisoryUrl.get();
+ }
+
+ /**
+ * Get the advisory name for this provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getAdvisoryName() {
+ return mAdvisoryName.get();
+ }
+
+ /**
+ * Get the url to share threat data to the provider, if enabled by {@link
+ * #getDataSharingEnabled}.
+ *
+ * @return this {@link Builder} instance.
+ */
+ public @Nullable String getDataSharingUrl() {
+ return mDataSharingUrl.get();
+ }
+
+ /**
+ * Get whether to share threat data with the provider.
+ *
+ * @return <code>true</code> if the browser should whare threat data with the provider, <code>
+ * false</code> otherwise.
+ */
+ public @Nullable Boolean getDataSharingEnabled() {
+ return mDataSharingEnabled.get();
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeValue(mName);
+ super.writeToParcel(out, flags);
+ }
+
+ /** Creator instance for this class. */
+ public static final Parcelable.Creator<SafeBrowsingProvider> CREATOR =
+ new Parcelable.Creator<SafeBrowsingProvider>() {
+ @Override
+ public SafeBrowsingProvider createFromParcel(final Parcel source) {
+ final String name = (String) source.readValue(getClass().getClassLoader());
+ final SafeBrowsingProvider settings = new SafeBrowsingProvider(name);
+ settings.readFromParcel(source);
+ return settings;
+ }
+
+ @Override
+ public SafeBrowsingProvider[] newArray(final int size) {
+ return new SafeBrowsingProvider[size];
+ }
+ };
+ }
+
+ private static String listsToPref(final String... lists) {
+ final StringBuilder prefBuilder = new StringBuilder();
+
+ for (final String list : lists) {
+ if (list.contains(",")) {
+ // We use ',' as the separator, so the list name cannot contain it.
+ // Should never happen.
+ throw new IllegalArgumentException("List name cannot contain ',' character.");
+ }
+
+ prefBuilder.append(list);
+ prefBuilder.append(",");
+ }
+
+ // Remove trailing ","
+ if (lists.length > 0) {
+ prefBuilder.setLength(prefBuilder.length() - 1);
+ }
+
+ return prefBuilder.toString();
+ }
+
+ private static String[] prefToLists(final String pref) {
+ return pref != null ? pref.split(",") : new String[] {};
+ }
+
+ public static class AntiTracking {
+ public static final int NONE = 0;
+
+ /** Block advertisement trackers. */
+ public static final int AD = 1 << 1;
+
+ /** Block analytics trackers. */
+ public static final int ANALYTIC = 1 << 2;
+
+ /**
+ * Block social trackers. Note: This is not the same as "Social Tracking Protection", which is
+ * controlled by {@link #STP}.
+ */
+ public static final int SOCIAL = 1 << 3;
+
+ /** Block content trackers. May cause issues with some web sites. */
+ public static final int CONTENT = 1 << 4;
+
+ /** Block Gecko test trackers (used for tests). */
+ public static final int TEST = 1 << 5;
+
+ /** Block cryptocurrency miners. */
+ public static final int CRYPTOMINING = 1 << 6;
+
+ /** Block fingerprinting trackers. */
+ public static final int FINGERPRINTING = 1 << 7;
+
+ /** Block trackers on the Social Tracking Protection list. */
+ public static final int STP = 1 << 8;
+
+ /** Block email trackers */
+ public static final int EMAIL = 1 << 9;
+
+ /** Block ad, analytic, social and test trackers. */
+ public static final int DEFAULT = AD | ANALYTIC | SOCIAL | TEST;
+
+ /** Block all known trackers. May cause issues with some web sites. */
+ public static final int STRICT = DEFAULT | CONTENT | CRYPTOMINING | FINGERPRINTING | EMAIL;
+
+ protected AntiTracking() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ AntiTracking.AD,
+ AntiTracking.ANALYTIC,
+ AntiTracking.SOCIAL,
+ AntiTracking.CONTENT,
+ AntiTracking.TEST,
+ AntiTracking.CRYPTOMINING,
+ AntiTracking.FINGERPRINTING,
+ AntiTracking.DEFAULT,
+ AntiTracking.STRICT,
+ AntiTracking.STP,
+ AntiTracking.EMAIL,
+ AntiTracking.NONE
+ })
+ public @interface CBAntiTracking {}
+
+ public static class SafeBrowsing {
+ public static final int NONE = 0;
+
+ /** Block malware sites. */
+ public static final int MALWARE = 1 << 10;
+
+ /** Block unwanted sites. */
+ public static final int UNWANTED = 1 << 11;
+
+ /** Block harmful sites. */
+ public static final int HARMFUL = 1 << 12;
+
+ /** Block phishing sites. */
+ public static final int PHISHING = 1 << 13;
+
+ /** Block all unsafe sites. */
+ public static final int DEFAULT = MALWARE | UNWANTED | HARMFUL | PHISHING;
+
+ protected SafeBrowsing() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SafeBrowsing.MALWARE, SafeBrowsing.UNWANTED,
+ SafeBrowsing.HARMFUL, SafeBrowsing.PHISHING,
+ SafeBrowsing.DEFAULT, SafeBrowsing.NONE
+ })
+ public @interface CBSafeBrowsing {}
+
+ // Sync values with nsICookieService.idl.
+ public static class CookieBehavior {
+ /** Accept first-party and third-party cookies and site data. */
+ public static final int ACCEPT_ALL = 0;
+
+ /**
+ * Accept only first-party cookies and site data to block cookies which are not associated with
+ * the domain of the visited site.
+ */
+ public static final int ACCEPT_FIRST_PARTY = 1;
+
+ /** Do not store any cookies and site data. */
+ public static final int ACCEPT_NONE = 2;
+
+ /**
+ * Accept first-party and third-party cookies and site data only from sites previously visited
+ * in a first-party context.
+ */
+ public static final int ACCEPT_VISITED = 3;
+
+ /**
+ * Accept only first-party and non-tracking third-party cookies and site data to block cookies
+ * which are not associated with the domain of the visited site set by known trackers.
+ */
+ public static final int ACCEPT_NON_TRACKERS = 4;
+
+ /**
+ * Enable dynamic first party isolation (dFPI); this will block third-party tracking cookies in
+ * accordance with the ETP level and isolate non-tracking third-party cookies.
+ */
+ public static final int ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS = 5;
+
+ protected CookieBehavior() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ CookieBehavior.ACCEPT_ALL, CookieBehavior.ACCEPT_FIRST_PARTY,
+ CookieBehavior.ACCEPT_NONE, CookieBehavior.ACCEPT_VISITED,
+ CookieBehavior.ACCEPT_NON_TRACKERS
+ })
+ public @interface CBCookieBehavior {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({EtpLevel.NONE, EtpLevel.DEFAULT, EtpLevel.STRICT})
+ public @interface CBEtpLevel {}
+
+ /** Possible settings for ETP. */
+ public static class EtpLevel {
+ /** Do not enable ETP at all. */
+ public static final int NONE = 0;
+
+ /** Enable ETP for ads, analytic, and social tracking lists. */
+ public static final int DEFAULT = 1;
+
+ /**
+ * Enable ETP for all of the default lists as well as the content list. May break many sites!
+ */
+ public static final int STRICT = 2;
+ }
+
+ /** Holds content block event details. */
+ public static class BlockEvent {
+ /** The URI of the blocked resource. */
+ public final @NonNull String uri;
+
+ private final @CBAntiTracking int mAntiTrackingCat;
+ private final @CBSafeBrowsing int mSafeBrowsingCat;
+ private final @CBCookieBehavior int mCookieBehaviorCat;
+ private final boolean mIsBlocking;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BlockEvent(
+ @NonNull final String uri,
+ final @CBAntiTracking int atCat,
+ final @CBSafeBrowsing int sbCat,
+ final @CBCookieBehavior int cbCat,
+ final boolean isBlocking) {
+ this.uri = uri;
+ this.mAntiTrackingCat = atCat;
+ this.mSafeBrowsingCat = sbCat;
+ this.mCookieBehaviorCat = cbCat;
+ this.mIsBlocking = isBlocking;
+ }
+
+ /**
+ * The anti-tracking category types of the blocked resource.
+ *
+ * @return One or more of the {@link AntiTracking} flags.
+ */
+ @UiThread
+ public @CBAntiTracking int getAntiTrackingCategory() {
+ return mAntiTrackingCat;
+ }
+
+ /**
+ * The safe browsing category types of the blocked resource.
+ *
+ * @return One or more of the {@link SafeBrowsing} flags.
+ */
+ @UiThread
+ public @CBSafeBrowsing int getSafeBrowsingCategory() {
+ return mSafeBrowsingCat;
+ }
+
+ /**
+ * The cookie types of the blocked resource.
+ *
+ * @return One or more of the {@link CookieBehavior} flags.
+ */
+ @UiThread
+ public @CBCookieBehavior int getCookieBehaviorCategory() {
+ return mCookieBehaviorCat;
+ }
+
+ /* package */ static BlockEvent fromBundle(@NonNull final GeckoBundle bundle) {
+ final String uri = bundle.getString("uri");
+ final String blockedList = bundle.getString("blockedList");
+ final String loadedList = TextUtils.join(",", bundle.getStringArray("loadedLists"));
+ final long error = bundle.getLong("error", 0L);
+ final long category = bundle.getLong("category", 0L);
+
+ final String matchedList = blockedList != null ? blockedList : loadedList;
+
+ // Note: Even if loadedList is non-empty it does not necessarily
+ // mean that the event is not a blocking event.
+ final boolean blocking =
+ (blockedList != null || error != 0L || ContentBlocking.isBlockingGeckoCbCat(category));
+
+ return new BlockEvent(
+ uri,
+ ContentBlocking.atListToAtCat(matchedList)
+ | ContentBlocking.cmListToAtCat(matchedList)
+ | ContentBlocking.fpListToAtCat(matchedList)
+ | ContentBlocking.stListToAtCat(matchedList)
+ | ContentBlocking.etbListToAtCat(matchedList),
+ ContentBlocking.errorToSbCat(error),
+ ContentBlocking.geckoCatToCbCat(category),
+ blocking);
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public boolean isBlocking() {
+ return mIsBlocking;
+ }
+ }
+
+ /** GeckoSession applications implement this interface to handle content blocking events. */
+ public interface Delegate {
+ /**
+ * A content element has been blocked from loading. Set blocked element categories via {@link
+ * GeckoRuntimeSettings} and enable content blocking via {@link GeckoSessionSettings}.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param event The {@link BlockEvent} details.
+ */
+ @UiThread
+ default void onContentBlocked(
+ @NonNull final GeckoSession session, @NonNull final BlockEvent event) {}
+
+ /**
+ * A content element that could be blocked has been loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param event The {@link BlockEvent} details.
+ */
+ @UiThread
+ default void onContentLoaded(
+ @NonNull final GeckoSession session, @NonNull final BlockEvent event) {}
+ }
+
+ private static final String TEST = "moztest-track-simple";
+ private static final String AD = "ads-track-digest256";
+ private static final String ANALYTIC = "analytics-track-digest256";
+ private static final String SOCIAL = "social-track-digest256";
+ private static final String CONTENT = "content-track-digest256";
+ private static final String CRYPTOMINING = "base-cryptomining-track-digest256";
+ private static final String FINGERPRINTING = "base-fingerprinting-track-digest256";
+ private static final String STP =
+ "social-tracking-protection-facebook-digest256,social-tracking-protection-linkedin-digest256,social-tracking-protection-twitter-digest256";
+ private static final String EMAIL = "base-email-track-digest256";
+
+ /* package */ static @CBSafeBrowsing int sbMalwareToSbCat(final boolean enabled) {
+ return enabled
+ ? (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL)
+ : SafeBrowsing.NONE;
+ }
+
+ /* package */ static @CBSafeBrowsing int sbPhishingToSbCat(final boolean enabled) {
+ return enabled ? SafeBrowsing.PHISHING : SafeBrowsing.NONE;
+ }
+
+ /* package */ static boolean catToSbMalware(@CBAntiTracking final int cat) {
+ return (cat & (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL)) != 0;
+ }
+
+ /* package */ static boolean catToSbPhishing(@CBAntiTracking final int cat) {
+ return (cat & SafeBrowsing.PHISHING) != 0;
+ }
+
+ /* package */ static String catToAtPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.TEST) != 0) {
+ builder.append(TEST).append(',');
+ }
+ if ((cat & AntiTracking.AD) != 0) {
+ builder.append(AD).append(',');
+ }
+ if ((cat & AntiTracking.ANALYTIC) != 0) {
+ builder.append(ANALYTIC).append(',');
+ }
+ if ((cat & AntiTracking.SOCIAL) != 0) {
+ builder.append(SOCIAL).append(',');
+ }
+ if ((cat & AntiTracking.CONTENT) != 0) {
+ builder.append(CONTENT).append(',');
+ }
+ if (builder.length() == 0) {
+ return "";
+ }
+ // Trim final ','.
+ return builder.substring(0, builder.length() - 1);
+ }
+
+ /* package */ static boolean catToCmPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.CRYPTOMINING) != 0;
+ }
+
+ /* package */ static String catToCmListPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.CRYPTOMINING) != 0) {
+ builder.append(CRYPTOMINING);
+ }
+ return builder.toString();
+ }
+
+ /* package */ static boolean catToFpPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.FINGERPRINTING) != 0;
+ }
+
+ /* package */ static String catToFpListPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.FINGERPRINTING) != 0) {
+ builder.append(FINGERPRINTING);
+ }
+ return builder.toString();
+ }
+
+ /* package */ static @CBAntiTracking int fpListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(FINGERPRINTING) != -1) {
+ cat |= AntiTracking.FINGERPRINTING;
+ }
+ return cat;
+ }
+
+ /* package */ static boolean catToStPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.STP) != 0;
+ }
+
+ /* package */ static boolean catToEtbPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.EMAIL) != 0;
+ }
+
+ /**
+ * Generic method for converting a category of anti-tracking to a Pref.
+ *
+ * @param cat Int representing the enabled anti-tracking blockers.
+ * @param tbCat Int representing the category mask to check for.
+ * @param catPrefString String to return if [cat] contains [tbCat].
+ * @return Pref string if [cat] contains [tbCat] otherwise empty string.
+ */
+ /* package */ static String catToPref(
+ @CBAntiTracking final int cat, final int tbCat, final String catPrefString) {
+ if ((cat & tbCat) != 0) {
+ return catPrefString;
+ } else {
+ return "";
+ }
+ }
+
+ /* package */ static @CBAntiTracking int atListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(TEST) != -1) {
+ cat |= AntiTracking.TEST;
+ }
+ if (list.indexOf(AD) != -1) {
+ cat |= AntiTracking.AD;
+ }
+ if (list.indexOf(ANALYTIC) != -1) {
+ cat |= AntiTracking.ANALYTIC;
+ }
+ if (list.indexOf(SOCIAL) != -1) {
+ cat |= AntiTracking.SOCIAL;
+ }
+ if (list.indexOf(CONTENT) != -1) {
+ cat |= AntiTracking.CONTENT;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBAntiTracking int cmListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(CRYPTOMINING) != -1) {
+ cat |= AntiTracking.CRYPTOMINING;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBAntiTracking int stListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(STP) != -1) {
+ cat |= AntiTracking.STP;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBAntiTracking int etbListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(EMAIL) != -1) {
+ cat |= AntiTracking.EMAIL;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBSafeBrowsing int errorToSbCat(final long error) {
+ // Match flags with XPCOM ErrorList.h.
+ if (error == 0x805D001FL) {
+ return SafeBrowsing.PHISHING;
+ }
+ if (error == 0x805D001EL) {
+ return SafeBrowsing.MALWARE;
+ }
+ if (error == 0x805D0023L) {
+ return SafeBrowsing.UNWANTED;
+ }
+ if (error == 0x805D0026L) {
+ return SafeBrowsing.HARMFUL;
+ }
+ return SafeBrowsing.NONE;
+ }
+
+ // Match flags with nsIWebProgressListener.idl.
+ private static final long STATE_COOKIES_LOADED = 0x8000L;
+ private static final long STATE_COOKIES_LOADED_TRACKER = 0x40000L;
+ private static final long STATE_COOKIES_LOADED_SOCIALTRACKER = 0x80000L;
+ private static final long STATE_COOKIES_BLOCKED_TRACKER = 0x20000000L;
+ private static final long STATE_COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000L;
+ private static final long STATE_COOKIES_BLOCKED_ALL = 0x40000000L;
+ private static final long STATE_COOKIES_BLOCKED_FOREIGN = 0x80L;
+
+ /* package */ static boolean isBlockingGeckoCbCat(final long geckoCat) {
+ return (geckoCat
+ & (STATE_COOKIES_BLOCKED_TRACKER
+ | STATE_COOKIES_BLOCKED_SOCIALTRACKER
+ | STATE_COOKIES_BLOCKED_ALL
+ | STATE_COOKIES_BLOCKED_FOREIGN))
+ != 0;
+ }
+
+ /* package */ static @CBCookieBehavior int geckoCatToCbCat(final long geckoCat) {
+ if ((geckoCat & STATE_COOKIES_LOADED) != 0) {
+ // We don't know which setting would actually block this cookie, so
+ // we return the most strict value.
+ return CookieBehavior.ACCEPT_NONE;
+ }
+ if ((geckoCat & STATE_COOKIES_BLOCKED_FOREIGN) != 0) {
+ return CookieBehavior.ACCEPT_FIRST_PARTY;
+ }
+ // If we receive STATE_COOKIES_LOADED_{SOCIAL,}TRACKER we know that this
+ // setting would block this cookie.
+ if ((geckoCat
+ & (STATE_COOKIES_BLOCKED_TRACKER
+ | STATE_COOKIES_BLOCKED_SOCIALTRACKER
+ | STATE_COOKIES_LOADED_TRACKER
+ | STATE_COOKIES_LOADED_SOCIALTRACKER))
+ != 0) {
+ return CookieBehavior.ACCEPT_NON_TRACKERS;
+ }
+ if ((geckoCat & STATE_COOKIES_BLOCKED_ALL) != 0) {
+ return CookieBehavior.ACCEPT_NONE;
+ }
+ // TODO: There are more reasons why cookies may be blocked.
+ return CookieBehavior.ACCEPT_ALL;
+ }
+
+ // Cookie Banner Handling feature.
+
+ public static class CookieBannerMode {
+ /** Do not enable handling cookie banners. */
+ public static final int COOKIE_BANNER_MODE_DISABLED = 0;
+
+ /** Only handle banners where selecting "reject all" is possible. */
+ public static final int COOKIE_BANNER_MODE_REJECT = 1;
+
+ /** Reject cookies when possible otherwise accept the cookies. */
+ public static final int COOKIE_BANNER_MODE_REJECT_OR_ACCEPT = 2;
+
+ protected CookieBannerMode() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ CookieBannerMode.COOKIE_BANNER_MODE_DISABLED,
+ CookieBannerMode.COOKIE_BANNER_MODE_REJECT,
+ CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT,
+ })
+ public @interface CBCookieBannerMode {}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java
new file mode 100644
index 0000000000..151f289e5d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java
@@ -0,0 +1,214 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * ContentBlockingController is used to manage and modify the content blocking exception list. This
+ * list is shared across all sessions.
+ */
+@AnyThread
+public class ContentBlockingController {
+ private static final String LOGTAG = "GeckoContentBlocking";
+
+ public static class Event {
+ // These values must be kept in sync with the corresponding values in
+ // nsIWebProgressListener.idl.
+ /** Tracking content has been blocked from loading. */
+ public static final int BLOCKED_TRACKING_CONTENT = 0x00001000;
+
+ /** Level 1 tracking content has been loaded. */
+ public static final int LOADED_LEVEL_1_TRACKING_CONTENT = 0x00002000;
+
+ /** Level 2 tracking content has been loaded. */
+ public static final int LOADED_LEVEL_2_TRACKING_CONTENT = 0x00100000;
+
+ /** Fingerprinting content has been blocked from loading. */
+ public static final int BLOCKED_FINGERPRINTING_CONTENT = 0x00000040;
+
+ /** Fingerprinting content has been loaded. */
+ public static final int LOADED_FINGERPRINTING_CONTENT = 0x00000400;
+
+ /** Cryptomining content has been blocked from loading. */
+ public static final int BLOCKED_CRYPTOMINING_CONTENT = 0x00000800;
+
+ /** Cryptomining content has been loaded. */
+ public static final int LOADED_CRYPTOMINING_CONTENT = 0x00200000;
+
+ /** Content which appears on the SafeBrowsing list has been blocked from loading. */
+ public static final int BLOCKED_UNSAFE_CONTENT = 0x00004000;
+
+ /**
+ * Performed a storage access check, which usually means something like a cookie or a storage
+ * item was loaded/stored on the current tab. Alternatively this could indicate that something
+ * in the current tab attempted to communicate with its same-origin counterparts in other tabs.
+ */
+ public static final int COOKIES_LOADED = 0x00008000;
+
+ /**
+ * Similar to {@link #COOKIES_LOADED}, but only sent if the subject of the action was a
+ * third-party tracker when the active cookie policy imposes restrictions on such content.
+ */
+ public static final int COOKIES_LOADED_TRACKER = 0x00040000;
+
+ /**
+ * Similar to {@link #COOKIES_LOADED}, but only sent if the subject of the action was a
+ * third-party social tracker when the active cookie policy imposes restrictions on such
+ * content.
+ */
+ public static final int COOKIES_LOADED_SOCIALTRACKER = 0x00080000;
+
+ /** Rejected for custom site permission. */
+ public static final int COOKIES_BLOCKED_BY_PERMISSION = 0x10000000;
+
+ /** Rejected because the resource is a tracker and cookie policy doesn't allow its loading. */
+ public static final int COOKIES_BLOCKED_TRACKER = 0x20000000;
+
+ /**
+ * Rejected because the resource is a tracker from a social origin and cookie policy doesn't
+ * allow its loading.
+ */
+ public static final int COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000;
+
+ /** Rejected because cookie policy blocks all cookies. */
+ public static final int COOKIES_BLOCKED_ALL = 0x40000000;
+
+ /**
+ * Rejected because the resource is a third-party and cookie policy forces third-party resources
+ * to be partitioned.
+ */
+ public static final int COOKIES_PARTITIONED_FOREIGN = 0x80000000;
+
+ /** Rejected because cookie policy blocks 3rd party cookies. */
+ public static final int COOKIES_BLOCKED_FOREIGN = 0x00000080;
+
+ /** SocialTracking content has been blocked from loading. */
+ public static final int BLOCKED_SOCIALTRACKING_CONTENT = 0x00010000;
+
+ /** SocialTracking content has been loaded. */
+ public static final int LOADED_SOCIALTRACKING_CONTENT = 0x00020000;
+
+ /** Email content has been blocked from loading. */
+ public static final int BLOCKED_EMAILTRACKING_CONTENT = 0x00400000;
+
+ /** EmailTracking content from the Disconnect level 1 has been loaded. */
+ public static final int LOADED_EMAILTRACKING_LEVEL_1_CONTENT = 0x00800000;
+
+ /** EmailTracking content from the Disconnect level 2 has been loaded. */
+ public static final int LOADED_EMAILTRACKING_LEVEL_2_CONTENT = 0x00000100;
+
+ /**
+ * Indicates that content that would have been blocked has instead been replaced with a shim.
+ */
+ public static final int REPLACED_TRACKING_CONTENT = 0x00000010;
+
+ /** Indicates that content that would have been blocked has instead been allowed by a shim. */
+ public static final int ALLOWED_TRACKING_CONTENT = 0x00000020;
+
+ protected Event() {}
+ }
+
+ /** An entry in the content blocking log for a site. */
+ @AnyThread
+ public static class LogEntry {
+ /** Data about why a given entry was blocked. */
+ public static class BlockingData {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ Event.BLOCKED_TRACKING_CONTENT, Event.LOADED_LEVEL_1_TRACKING_CONTENT,
+ Event.LOADED_LEVEL_2_TRACKING_CONTENT, Event.BLOCKED_FINGERPRINTING_CONTENT,
+ Event.LOADED_FINGERPRINTING_CONTENT, Event.BLOCKED_CRYPTOMINING_CONTENT,
+ Event.LOADED_CRYPTOMINING_CONTENT, Event.BLOCKED_UNSAFE_CONTENT,
+ Event.COOKIES_LOADED, Event.COOKIES_LOADED_TRACKER,
+ Event.COOKIES_LOADED_SOCIALTRACKER, Event.COOKIES_BLOCKED_BY_PERMISSION,
+ Event.COOKIES_BLOCKED_TRACKER, Event.COOKIES_BLOCKED_SOCIALTRACKER,
+ Event.COOKIES_BLOCKED_ALL, Event.COOKIES_PARTITIONED_FOREIGN,
+ Event.COOKIES_BLOCKED_FOREIGN, Event.BLOCKED_SOCIALTRACKING_CONTENT,
+ Event.LOADED_SOCIALTRACKING_CONTENT, Event.REPLACED_TRACKING_CONTENT,
+ Event.LOADED_EMAILTRACKING_LEVEL_1_CONTENT, Event.LOADED_EMAILTRACKING_LEVEL_2_CONTENT,
+ Event.BLOCKED_EMAILTRACKING_CONTENT
+ })
+ public @interface LogEvent {}
+
+ /** A category the entry falls under. */
+ public final @LogEvent int category;
+
+ /** Indicates whether or not blocking occured for this category, where applicable. */
+ public final boolean blocked;
+
+ /** The count of consecutive repeated appearances. */
+ public final int count;
+
+ /* package */ BlockingData(final @NonNull GeckoBundle bundle) {
+ category = bundle.getInt("category");
+ blocked = bundle.getBoolean("blocked");
+ count = bundle.getInt("count");
+ }
+
+ protected BlockingData() {
+ category = Event.BLOCKED_TRACKING_CONTENT;
+ blocked = false;
+ count = 0;
+ }
+ }
+
+ /** The origin of this log entry. */
+ public final @NonNull String origin;
+
+ /** The blocking data for this origin, sorted chronologically. */
+ public final @NonNull List<BlockingData> blockingData;
+
+ /* package */ LogEntry(final @NonNull GeckoBundle bundle) {
+ origin = bundle.getString("origin");
+ final GeckoBundle[] data = bundle.getBundleArray("blockData");
+ final ArrayList<BlockingData> dataArray = new ArrayList<BlockingData>(data.length);
+ for (final GeckoBundle b : data) {
+ dataArray.add(new BlockingData(b));
+ }
+ blockingData = Collections.unmodifiableList(dataArray);
+ }
+
+ protected LogEntry() {
+ origin = null;
+ blockingData = null;
+ }
+ }
+
+ private List<LogEntry> logFromBundle(final GeckoBundle value) {
+ final GeckoBundle[] bundles = value.getBundleArray("log");
+ final ArrayList<LogEntry> logArray = new ArrayList<>(bundles.length);
+ for (final GeckoBundle b : bundles) {
+ logArray.add(new LogEntry(b));
+ }
+ return Collections.unmodifiableList(logArray);
+ }
+
+ /**
+ * Get a log of all content blocking information for the site currently loaded by the supplied
+ * {@link GeckoSession}.
+ *
+ * @param session A {@link GeckoSession} for which you want the content blocking log.
+ * @return A {@link GeckoResult} that resolves to the list of content blocking log entries.
+ */
+ @UiThread
+ public @NonNull GeckoResult<List<LogEntry>> getLog(final @NonNull GeckoSession session) {
+ return session
+ .getEventDispatcher()
+ .queryBundle("ContentBlocking:RequestLog")
+ .map(this::logFromBundle);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentInputStream.java
new file mode 100644
index 0000000000..aa3f5c3174
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentInputStream.java
@@ -0,0 +1,149 @@
+/* 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.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ProviderInfo;
+import android.content.res.AssetFileDescriptor;
+import android.net.Uri;
+import android.os.Process;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import java.io.IOException;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * This class provides an {@link OutputStream} wrapper for a Gecko nsIOutputStream (or really,
+ * nsIRequest).
+ */
+/* package */ class ContentInputStream extends GeckoViewInputStream {
+ private static final String LOGTAG = "ContentInputStream";
+
+ private static final byte[][] HEADERS = {{'%', 'P', 'D', 'F', '-'}};
+
+ private AssetFileDescriptor mFd;
+
+ ContentInputStream(final @NonNull String aUri) {
+ final Uri uri = Uri.parse(aUri);
+ final Context context = GeckoAppShell.getApplicationContext();
+ final ContentResolver cr = context.getContentResolver();
+
+ try {
+ mFd = cr.openAssetFileDescriptor(uri, "r");
+ setInputStream(mFd.createInputStream());
+
+ if (!checkHeaders(HEADERS)) {
+ Log.e(LOGTAG, "Cannot open the uri: " + aUri + " (invalid header)");
+ close();
+ }
+ } catch (final IOException | SecurityException e) {
+ Log.e(LOGTAG, "Cannot open the uri: " + aUri, e);
+ close();
+ }
+ }
+
+ @Override
+ public void close() {
+ if (mFd != null) {
+ try {
+ mFd.close();
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Cannot close the file descriptor", e);
+ } finally {
+ mFd = null;
+ }
+ }
+ super.close();
+ }
+
+ private static boolean isExported(final @NonNull Context aCtx, final @NonNull Uri aUri) {
+ // For reference:
+ // https://developer.android.com/topic/security/risks/content-resolver#mitigations_2
+ final String authority = aUri.getAuthority();
+ final PackageManager packageManager = aCtx.getPackageManager();
+ if (authority == null || packageManager == null) {
+ return false;
+ }
+ final ProviderInfo info = packageManager.resolveContentProvider(authority, 0);
+ if (info == null) {
+ return false;
+ }
+
+ // We check that the provider is exported:
+ // https://developer.android.com/reference/android/content/pm/ComponentInfo?hl=en#exported
+ return info.exported;
+ }
+
+ private static boolean wasGrantedPermission(
+ final @NonNull Context aCtx, final @NonNull Uri aUri) {
+ // For reference:
+ // https://developer.android.com/topic/security/risks/content-resolver#mitigations_2
+ final int pid = Process.myPid();
+ final int uid = Process.myUid();
+ return aCtx.checkUriPermission(aUri, pid, uid, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ private static boolean belongsToCurrentApplication(
+ final @NonNull Context aCtx, final @NonNull Uri aUri) {
+ // For reference:
+ // https://developer.android.com/topic/security/risks/content-resolver#mitigations_2
+ final String authority = aUri.getAuthority();
+ final PackageManager packageManager = aCtx.getPackageManager();
+ if (authority == null || packageManager == null) {
+ return false;
+ }
+ final ProviderInfo info = packageManager.resolveContentProvider(authority, 0);
+ if (info == null) {
+ return false;
+ }
+
+ // We check that the provider is GV itself (when testing GV, the provider is GV itself).
+ final String packageName = aCtx.getPackageName();
+ return packageName != null && packageName.equals(info.packageName);
+ }
+
+ @WrapForJNI
+ @AnyThread
+ private static boolean isReadable(final @NonNull String aUri) {
+ final Uri uri = Uri.parse(aUri);
+ final Context context = GeckoAppShell.getApplicationContext();
+
+ try {
+ // The check for this criteria is based on recommendations in
+ // https://developer.android.com/privacy-and-security/risks/content-resolver#mitigations_2
+ // The documentation recommends checking:
+ // 1. If URI targets our app (belongsToCurrentApplication)
+ // 2. OR if targeted provider is exported (isExported)
+ // 3. OR if granted explicit permission (wasGrantedPermission)
+ if (belongsToCurrentApplication(context, uri)
+ || isExported(context, uri)
+ || wasGrantedPermission(context, uri)) {
+ final ContentResolver cr = context.getContentResolver();
+ cr.openAssetFileDescriptor(uri, "r").close();
+ Log.d(LOGTAG, "The uri is readable: " + uri);
+ return true;
+ }
+ } catch (final IOException | SecurityException e) {
+ // A SecurityException could happen if the uri is no more valid or if
+ // we're in an isolated process.
+ Log.e(LOGTAG, "Cannot read the uri: " + uri, e);
+ }
+
+ Log.d(LOGTAG, "The uri isn't readable: " + uri);
+ return false;
+ }
+
+ @WrapForJNI
+ @AnyThread
+ private static @NonNull GeckoViewInputStream getInstance(final @NonNull String aUri) {
+ return new ContentInputStream(aUri);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashHandler.java
new file mode 100644
index 0000000000..eb00f87b41
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashHandler.java
@@ -0,0 +1,587 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Process;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Arrays;
+import java.util.UUID;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+
+public class CrashHandler implements Thread.UncaughtExceptionHandler {
+
+ private static final String LOGTAG = "GeckoCrashHandler";
+ private static final Thread MAIN_THREAD = Thread.currentThread();
+ private static final String DEFAULT_SERVER_URL =
+ "https://crash-reports.mozilla.com/submit?id=%1$s&version=%2$s&buildid=%3$s";
+
+ // Context for getting device information
+ private @Nullable final Context mAppContext;
+ // Thread that this handler applies to, or null for a global handler
+ private @Nullable final Thread mHandlerThread;
+ private final @Nullable Thread.UncaughtExceptionHandler systemUncaughtHandler;
+
+ private boolean mCrashing;
+ private boolean mUnregistered;
+
+ private @Nullable final Class<? extends Service> mHandlerService;
+
+ /**
+ * Get the root exception from the 'cause' chain of an exception.
+ *
+ * @param exc An exception
+ * @return The root exception
+ */
+ @AnyThread
+ @NonNull
+ public static Throwable getRootException(@NonNull final Throwable exc) {
+ Throwable cause;
+ Throwable result = exc;
+ for (cause = exc; cause != null; cause = cause.getCause()) {
+ result = cause;
+ }
+
+ return result;
+ }
+
+ /**
+ * Get the standard stack trace string of an exception.
+ *
+ * @param exc An exception
+ * @return The exception stack trace.
+ */
+ @AnyThread
+ @NonNull
+ public static String getExceptionStackTrace(@NonNull final Throwable exc) {
+ final StringWriter sw = new StringWriter();
+ final PrintWriter pw = new PrintWriter(sw);
+ exc.printStackTrace(pw);
+ pw.flush();
+ return sw.toString();
+ }
+
+ /** Terminate the current process. */
+ @AnyThread
+ public static void terminateProcess() {
+ Process.killProcess(Process.myPid());
+ }
+
+ /**
+ * Create and register a CrashHandler for all threads and thread groups.
+ *
+ * @param handlerService Service receiving native code crashes
+ */
+ public CrashHandler(@Nullable final Class<? extends Service> handlerService) {
+ this((Context) null, handlerService);
+ }
+
+ /**
+ * Create and register a CrashHandler for all threads and thread groups.
+ *
+ * @param aAppContext A Context for retrieving application information.
+ * @param aHandlerService Service receiving native code crashes
+ */
+ public CrashHandler(
+ @Nullable final Context aAppContext,
+ @Nullable final Class<? extends Service> aHandlerService) {
+ this.mAppContext = aAppContext;
+ this.mHandlerThread = null;
+ this.mHandlerService = aHandlerService;
+ this.systemUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler(this);
+ }
+
+ /**
+ * Create and register a CrashHandler for a particular thread.
+ *
+ * @param thread A thread to register the CrashHandler
+ * @param handlerService Service receiving native code crashes
+ */
+ public CrashHandler(final Thread thread, final Class<? extends Service> handlerService) {
+ this(thread, null, handlerService);
+ }
+
+ /**
+ * Create and register a CrashHandler for a particular thread.
+ *
+ * @param thread A thread to register the CrashHandler
+ * @param aAppContext A Context for retrieving application information.
+ * @param aHandlerService Service receiving native code crashes
+ */
+ public CrashHandler(
+ @Nullable final Thread thread,
+ final Context aAppContext,
+ final Class<? extends Service> aHandlerService) {
+ this.mAppContext = aAppContext;
+ this.mHandlerThread = thread;
+ this.mHandlerService = aHandlerService;
+ this.systemUncaughtHandler = thread.getUncaughtExceptionHandler();
+ thread.setUncaughtExceptionHandler(this);
+ }
+
+ /** Unregister this CrashHandler for exception handling. */
+ @AnyThread
+ public void unregister() {
+ mUnregistered = true;
+
+ // Restore the previous handler if we are still the topmost handler.
+ // If not, we are part of a chain of handlers, and we cannot just restore the previous
+ // handler, because that would replace whatever handler that's above us in the chain.
+
+ if (mHandlerThread != null) {
+ if (mHandlerThread.getUncaughtExceptionHandler() == this) {
+ mHandlerThread.setUncaughtExceptionHandler(systemUncaughtHandler);
+ }
+ } else {
+ if (Thread.getDefaultUncaughtExceptionHandler() == this) {
+ Thread.setDefaultUncaughtExceptionHandler(systemUncaughtHandler);
+ }
+ }
+ }
+
+ /**
+ * Record an exception stack in logs.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ */
+ @AnyThread
+ public static void logException(@NonNull final Thread thread, @NonNull final Throwable exc) {
+ try {
+ Log.e(
+ LOGTAG,
+ ">>> REPORTING UNCAUGHT EXCEPTION FROM THREAD "
+ + thread.getId()
+ + " (\""
+ + thread.getName()
+ + "\")",
+ exc);
+
+ if (MAIN_THREAD != thread) {
+ Log.e(LOGTAG, "Main thread (" + MAIN_THREAD.getId() + ") stack:");
+ for (final StackTraceElement ste : MAIN_THREAD.getStackTrace()) {
+ Log.e(LOGTAG, " " + ste.toString());
+ }
+ }
+ } catch (final Throwable e) {
+ // If something throws here, we want to continue to report the exception,
+ // so we catch all exceptions and ignore them.
+ }
+ }
+
+ private static long getCrashTime() {
+ return System.currentTimeMillis() / 1000;
+ }
+
+ private static long getStartupTime() {
+ // Process start time is also the proc file modified time.
+ final long uptimeMins = (new File("/proc/self/cmdline")).lastModified();
+ if (uptimeMins == 0L) {
+ return getCrashTime();
+ }
+ return uptimeMins / 1000;
+ }
+
+ private static String getJavaPackageName() {
+ return CrashHandler.class.getPackage().getName();
+ }
+
+ @Nullable
+ private static String getProcessName() {
+ try {
+ final FileReader reader = new FileReader("/proc/self/cmdline");
+ final char[] buffer = new char[64];
+ try {
+ if (reader.read(buffer) > 0) {
+ // cmdline is delimited by '\0', and we want the first token.
+ final int nul = Arrays.asList(buffer).indexOf('\0');
+ return (new String(buffer, 0, nul < 0 ? buffer.length : nul)).trim();
+ }
+ } finally {
+ reader.close();
+ }
+ } catch (final IOException e) {
+ }
+
+ return null;
+ }
+
+ /**
+ * @return the application package name. if context is not null; if context is null,
+ * CrashHandler's package name will be returned.
+ */
+ @Nullable
+ @AnyThread
+ public String getAppPackageName() {
+ final Context context = getAppContext();
+
+ if (context != null) {
+ return context.getPackageName();
+ }
+
+ // Package name is also the process name in most cases.
+ final String processName = getProcessName();
+ if (processName != null) {
+ return processName;
+ }
+
+ // Fallback to using CrashHandler's package name.
+ return getJavaPackageName();
+ }
+
+ /**
+ * @return application context.
+ */
+ @AnyThread
+ @Nullable
+ public Context getAppContext() {
+ return mAppContext;
+ }
+
+ /**
+ * Get the crash "extras" to be reported.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return "Extras" in the from of a Bundle
+ */
+ @AnyThread
+ @NonNull
+ public Bundle getCrashExtras(@NonNull final Thread thread, @NonNull final Throwable exc) {
+ final Context context = getAppContext();
+ final Bundle extras = new Bundle();
+ final String pkgName = getAppPackageName();
+
+ extras.putLong("CrashTime", getCrashTime());
+ extras.putLong("StartupTime", getStartupTime());
+ extras.putString("Android_ProcessName", getProcessName());
+ extras.putString("Android_PackageName", pkgName);
+
+ final String notes = GeckoAppShell.getAppNotes();
+ if (notes != null) {
+ extras.putString("Notes", notes);
+ }
+
+ if (context != null) {
+ final PackageManager pkgMgr = context.getPackageManager();
+ try {
+ final PackageInfo pkgInfo = pkgMgr.getPackageInfo(pkgName, 0);
+ extras.putString("Version", pkgInfo.versionName);
+ extras.putInt("BuildID", pkgInfo.versionCode);
+ extras.putLong("InstallTime", pkgInfo.lastUpdateTime / 1000);
+ } catch (final PackageManager.NameNotFoundException e) {
+ Log.i(LOGTAG, "Error getting package info", e);
+ }
+ }
+
+ extras.putString("JavaStackTrace", getExceptionStackTrace(exc));
+ return extras;
+ }
+
+ /**
+ * Get the crash minidump content to be reported.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return Minidump content
+ */
+ @NonNull
+ @AnyThread
+ public byte[] getCrashDump(@Nullable final Thread thread, @Nullable final Throwable exc) {
+ return new byte[0]; // No minidump.
+ }
+
+ @AnyThread
+ @NonNull
+ private static String normalizeUrlString(@Nullable final String str) {
+ if (str == null) {
+ return "";
+ }
+ return Uri.encode(str);
+ }
+
+ /**
+ * Get the server URL to send the crash report to.
+ *
+ * @param extras The crash extras Bundle
+ * @return the URL that the crash reporter will submit reports to.
+ */
+ @NonNull
+ @AnyThread
+ public String getServerUrl(@NonNull final Bundle extras) {
+ return String.format(
+ DEFAULT_SERVER_URL,
+ normalizeUrlString(extras.getString("ProductID")),
+ normalizeUrlString(extras.getString("Version")),
+ normalizeUrlString(extras.getString("BuildID")));
+ }
+
+ /**
+ * Launch the crash reporter activity that sends the crash report to the server.
+ *
+ * @param dumpFile Path for the minidump file
+ * @param extraFile Path for the crash extra file
+ * @return Whether the crash reporter was successfully launched
+ */
+ @AnyThread
+ public boolean launchCrashReporter(
+ @NonNull final String dumpFile, @NonNull final String extraFile) {
+ try {
+ final Context context = getAppContext();
+ final ProcessBuilder pb;
+
+ if (mHandlerService == null) {
+ Log.w(LOGTAG, "No crash handler service defined, unable to report crash");
+ return false;
+ }
+
+ if (context != null) {
+ final Intent intent = new Intent(GeckoRuntime.ACTION_CRASHED);
+ intent.putExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH, dumpFile);
+ intent.putExtra(GeckoRuntime.EXTRA_EXTRAS_PATH, extraFile);
+ intent.putExtra(
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ intent.setClass(context, mHandlerService);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent);
+ } else {
+ context.startService(intent);
+ }
+ return true;
+ }
+
+ final int deviceSdkVersion = Build.VERSION.SDK_INT;
+ if (deviceSdkVersion < 17) {
+ pb =
+ new ProcessBuilder(
+ "/system/bin/am",
+ "startservice",
+ "-a",
+ GeckoRuntime.ACTION_CRASHED,
+ "-n",
+ getAppPackageName() + '/' + mHandlerService.getName(),
+ "--es",
+ GeckoRuntime.EXTRA_MINIDUMP_PATH,
+ dumpFile,
+ "--es",
+ GeckoRuntime.EXTRA_EXTRAS_PATH,
+ extraFile,
+ "--es",
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE,
+ GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ } else {
+ final String startServiceCommand;
+ if (deviceSdkVersion >= 26) {
+ startServiceCommand = "start-foreground-service";
+ } else {
+ startServiceCommand = "startservice";
+ }
+
+ pb =
+ new ProcessBuilder(
+ "/system/bin/am",
+ startServiceCommand,
+ "--user", /* USER_CURRENT_OR_SELF */
+ "-3",
+ "-a",
+ GeckoRuntime.ACTION_CRASHED,
+ "-n",
+ getAppPackageName() + '/' + mHandlerService.getName(),
+ "--es",
+ GeckoRuntime.EXTRA_MINIDUMP_PATH,
+ dumpFile,
+ "--es",
+ GeckoRuntime.EXTRA_EXTRAS_PATH,
+ extraFile,
+ "--es",
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE,
+ GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ }
+
+ pb.start().waitFor();
+
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Error launching crash reporter", e);
+ return false;
+
+ } catch (final InterruptedException e) {
+ Log.i(LOGTAG, "Interrupted while waiting to launch crash reporter", e);
+ // Fall-through
+ }
+ return true;
+ }
+
+ /**
+ * Report an exception to Socorro.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return Whether the exception was successfully reported
+ */
+ @AnyThread
+ @SuppressLint("SdCardPath")
+ public boolean reportException(@NonNull final Thread thread, @NonNull final Throwable exc) {
+ final Context context = getAppContext();
+ final String id = UUID.randomUUID().toString();
+
+ // Use the cache directory under the app directory to store crash files.
+ final File dir;
+ if (context != null) {
+ dir = context.getCacheDir();
+ } else {
+ dir = new File("/data/data/" + getAppPackageName() + "/cache");
+ }
+
+ dir.mkdirs();
+ if (!dir.exists()) {
+ return false;
+ }
+
+ final File dmpFile = new File(dir, id + ".dmp");
+ final File extraFile = new File(dir, id + ".extra");
+
+ try {
+ // Write out minidump file as binary.
+
+ final byte[] minidump = getCrashDump(thread, exc);
+ final FileOutputStream dmpStream = new FileOutputStream(dmpFile);
+ try {
+ dmpStream.write(minidump);
+ } finally {
+ dmpStream.close();
+ }
+
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Error writing minidump file", e);
+ return false;
+ }
+
+ try {
+ // Write out crash extra file as text.
+
+ final Bundle extras = getCrashExtras(thread, exc);
+ final String url = getServerUrl(extras);
+ extras.putString("ServerURL", url);
+
+ final JSONObject json = new JSONObject();
+ for (final String key : extras.keySet()) {
+ json.put(key, extras.get(key));
+ }
+
+ final BufferedWriter extraWriter = new BufferedWriter(new FileWriter(extraFile));
+ try {
+ extraWriter.write(json.toString());
+ } finally {
+ extraWriter.close();
+ }
+ } catch (final IOException | JSONException e) {
+ Log.e(LOGTAG, "Error writing extra file", e);
+ return false;
+ }
+
+ return launchCrashReporter(dmpFile.getAbsolutePath(), extraFile.getAbsolutePath());
+ }
+
+ /**
+ * Implements the default behavior for handling uncaught exceptions.
+ *
+ * @param thread The exception thread
+ * @param exc An uncaught exception
+ */
+ @Override
+ public void uncaughtException(@Nullable final Thread thread, @NonNull final Throwable exc) {
+ if (this.mCrashing) {
+ // Prevent possible infinite recusions.
+ return;
+ }
+
+ Thread resolvedThread = thread;
+ if (resolvedThread == null) {
+ // Gecko may pass in null for thread to denote the current thread.
+ resolvedThread = Thread.currentThread();
+ }
+
+ try {
+ Throwable rootException = exc;
+ if (!this.mUnregistered) {
+ // Only process crash ourselves if we have not been unregistered.
+
+ this.mCrashing = true;
+ rootException = getRootException(exc);
+ logException(resolvedThread, rootException);
+
+ if (reportException(resolvedThread, rootException)) {
+ // Reporting succeeded; we can terminate our process now.
+ return;
+ }
+ }
+
+ if (systemUncaughtHandler != null) {
+ // Follow the chain of uncaught handlers.
+ systemUncaughtHandler.uncaughtException(resolvedThread, rootException);
+ }
+ } finally {
+ terminateProcess();
+ }
+ }
+
+ /**
+ * Return a default CrashHandler object for all threads and thread groups.
+ *
+ * @param context application context
+ * @return a default CrashHandler object
+ */
+ @AnyThread
+ @NonNull
+ public static CrashHandler createDefaultCrashHandler(@NonNull final Context context) {
+ return new CrashHandler(context, null) {
+ @Override
+ public Bundle getCrashExtras(final Thread thread, final Throwable exc) {
+ final Bundle extras = super.getCrashExtras(thread, exc);
+
+ extras.putString("ProductName", BuildConfig.MOZ_APP_BASENAME);
+ extras.putString("ProductID", BuildConfig.MOZ_APP_ID);
+ extras.putString("Version", BuildConfig.MOZ_APP_VERSION);
+ extras.putString("BuildID", BuildConfig.MOZ_APP_BUILDID);
+ extras.putString("Vendor", BuildConfig.MOZ_APP_VENDOR);
+ extras.putString("ReleaseChannel", BuildConfig.MOZ_UPDATE_CHANNEL);
+ return extras;
+ }
+
+ @Override
+ public boolean reportException(final Thread thread, final Throwable exc) {
+ if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) {
+ // Only use Java crash reporter if enabled on official build.
+ return super.reportException(thread, exc);
+ }
+ return false;
+ }
+ };
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java
new file mode 100644
index 0000000000..691686e230
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java
@@ -0,0 +1,385 @@
+/* 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.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.zip.GZIPOutputStream;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.util.ProxySelector;
+
+/**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> crash
+ * report server.
+ */
+public class CrashReporter {
+ private static final String LOGTAG = "GeckoCrashReporter";
+ private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump";
+ private static final String PAGE_URL_KEY = "URL";
+ private static final String MINIDUMP_SHA256_HASH_KEY = "MinidumpSha256Hash";
+ private static final String NOTES_KEY = "Notes";
+ private static final String SERVER_URL_KEY = "ServerURL";
+ private static final String STACK_TRACES_KEY = "StackTraces";
+ private static final String PRODUCT_NAME_KEY = "ProductName";
+ private static final String PRODUCT_ID_KEY = "ProductID";
+ private static final String PRODUCT_ID = "{eeb82917-e434-4870-8148-5c03d4caa81b}";
+ private static final List<String> IGNORE_KEYS =
+ Arrays.asList(PAGE_URL_KEY, SERVER_URL_KEY, STACK_TRACES_KEY);
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current Context
+ * @param intent The Intent sent to the {@link GeckoRuntime} crash handler
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context, @NonNull final Intent intent, @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ return sendCrashReport(context, intent.getExtras(), appName);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current Context
+ * @param intentExtras The Bundle of extras attached to the Intent received by a crash handler.
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context,
+ @NonNull final Bundle intentExtras,
+ @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ final File dumpFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_MINIDUMP_PATH));
+ final File extrasFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_EXTRAS_PATH));
+
+ return sendCrashReport(context, dumpFile, extrasFile, appName);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current {@link Context}
+ * @param minidumpFile A {@link File} referring to the minidump.
+ * @param extrasFile A {@link File} referring to the extras file.
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context,
+ @NonNull final File minidumpFile,
+ @NonNull final File extrasFile,
+ @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ final JSONObject annotations = getCrashAnnotations(context, minidumpFile, extrasFile, appName);
+
+ final String url = annotations.optString(SERVER_URL_KEY, null);
+ if (url == null) {
+ return GeckoResult.fromException(new Exception("No server url present"));
+ }
+
+ for (final String key : IGNORE_KEYS) {
+ annotations.remove(key);
+ }
+
+ return sendCrashReport(url, minidumpFile, annotations);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server.
+ *
+ * @param serverURL The URL used to submit the crash report.
+ * @param minidumpFile A {@link File} referring to the minidump.
+ * @param extras A {@link JSONObject} holding the parsed JSON from the extra file.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final String serverURL,
+ @NonNull final File minidumpFile,
+ @NonNull final JSONObject extras)
+ throws IOException, URISyntaxException {
+ Log.d(LOGTAG, "Sending crash report: " + minidumpFile.getPath());
+
+ HttpURLConnection conn = null;
+ try {
+ final URL url = new URL(URLDecoder.decode(serverURL, "UTF-8"));
+ final URI uri =
+ new URI(
+ url.getProtocol(),
+ url.getUserInfo(),
+ url.getHost(),
+ url.getPort(),
+ url.getPath(),
+ url.getQuery(),
+ url.getRef());
+ conn = (HttpURLConnection) ProxySelector.openConnectionWithProxy(uri);
+ conn.setRequestMethod("POST");
+ final String boundary = generateBoundary();
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+ conn.setRequestProperty("Content-Encoding", "gzip");
+
+ final OutputStream os = new GZIPOutputStream(conn.getOutputStream());
+ sendAnnotations(os, boundary, extras);
+ sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile);
+ os.write(("\r\n--" + boundary + "--\r\n").getBytes());
+ os.flush();
+ os.close();
+
+ BufferedReader br = null;
+ try {
+ br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
+ final HashMap<String, String> responseMap = readStringsFromReader(br);
+
+ if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
+ final String crashid = responseMap.get("CrashID");
+ if (crashid != null) {
+ Log.i(LOGTAG, "Successfully sent crash report: " + crashid);
+ return GeckoResult.fromValue(crashid);
+ } else {
+ Log.i(LOGTAG, "Server rejected crash report");
+ }
+ } else {
+ Log.w(
+ LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode());
+ }
+ } catch (final Exception e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ } finally {
+ try {
+ if (br != null) {
+ br.close();
+ }
+ } catch (final IOException e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ }
+ }
+ } catch (final Exception e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ } finally {
+ if (conn != null) {
+ conn.disconnect();
+ }
+ }
+ return GeckoResult.fromException(new Exception("Failed to submit crash report"));
+ }
+
+ private static String computeMinidumpHash(@NonNull final File minidump) throws IOException {
+ MessageDigest md = null;
+ final FileInputStream stream = new FileInputStream(minidump);
+ try {
+ md = MessageDigest.getInstance("SHA-256");
+
+ final byte[] buffer = new byte[4096];
+ int readBytes;
+
+ while ((readBytes = stream.read(buffer)) != -1) {
+ md.update(buffer, 0, readBytes);
+ }
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IOException(e);
+ } finally {
+ stream.close();
+ }
+
+ final byte[] digest = md.digest();
+ final StringBuilder hash = new StringBuilder(64);
+
+ for (int i = 0; i < digest.length; i++) {
+ hash.append(Integer.toHexString((digest[i] & 0xf0) >> 4));
+ hash.append(Integer.toHexString(digest[i] & 0x0f));
+ }
+
+ return hash.toString();
+ }
+
+ private static HashMap<String, String> readStringsFromReader(final BufferedReader reader)
+ throws IOException {
+ String line;
+ final HashMap<String, String> map = new HashMap<>();
+ while ((line = reader.readLine()) != null) {
+ int equalsPos = -1;
+ if ((equalsPos = line.indexOf('=')) != -1) {
+ final String key = line.substring(0, equalsPos);
+ final String val = unescape(line.substring(equalsPos + 1));
+ map.put(key, val);
+ }
+ }
+ return map;
+ }
+
+ private static JSONObject readExtraFile(final String filePath) throws IOException, JSONException {
+ final byte[] buffer = new byte[4096];
+ final FileInputStream inputStream = new FileInputStream(filePath);
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ int bytesRead = 0;
+
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, bytesRead);
+ }
+
+ final String contents = new String(outputStream.toByteArray(), "UTF-8");
+ return new JSONObject(contents);
+ }
+
+ private static JSONObject getCrashAnnotations(
+ @NonNull final Context context,
+ @NonNull final File minidump,
+ @NonNull final File extra,
+ @NonNull final String appName)
+ throws IOException {
+ try {
+ final JSONObject annotations = readExtraFile(extra.getPath());
+
+ // Compute the minidump hash and generate the stack traces
+ try {
+ final String hash = computeMinidumpHash(minidump);
+ annotations.put(MINIDUMP_SHA256_HASH_KEY, hash);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "exception while computing the minidump hash: ", e);
+ }
+
+ annotations.put(PRODUCT_NAME_KEY, appName);
+ annotations.put(PRODUCT_ID_KEY, PRODUCT_ID);
+ annotations.put("Android_Manufacturer", Build.MANUFACTURER);
+ annotations.put("Android_Model", Build.MODEL);
+ annotations.put("Android_Board", Build.BOARD);
+ annotations.put("Android_Brand", Build.BRAND);
+ annotations.put("Android_Device", Build.DEVICE);
+ annotations.put("Android_Display", Build.DISPLAY);
+ annotations.put("Android_Fingerprint", Build.FINGERPRINT);
+ annotations.put("Android_CPU_ABI", Build.CPU_ABI);
+ annotations.put("Android_PackageName", context.getPackageName());
+ try {
+ annotations.put("Android_CPU_ABI2", Build.CPU_ABI2);
+ annotations.put("Android_Hardware", Build.HARDWARE);
+ } catch (final Exception ex) {
+ Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex);
+ }
+ annotations.put(
+ "Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")");
+
+ return annotations;
+ } catch (final JSONException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static String generateBoundary() {
+ // Generate some random numbers to fill out the boundary
+ final int r0 = (int) (Integer.MAX_VALUE * Math.random());
+ final int r1 = (int) (Integer.MAX_VALUE * Math.random());
+ return String.format("---------------------------%08X%08X", r0, r1);
+ }
+
+ private static void sendAnnotations(
+ final OutputStream os, final String boundary, final JSONObject extras) throws IOException {
+ os.write(
+ ("--"
+ + boundary
+ + "\r\n"
+ + "Content-Disposition: form-data; name=\"extra\"; "
+ + "filename=\"extra.json\"\r\n"
+ + "Content-Type: application/json\r\n"
+ + "\r\n")
+ .getBytes());
+ os.write(extras.toString().getBytes("UTF-8"));
+ os.write('\n');
+ }
+
+ private static void sendFile(
+ final OutputStream os, final String boundary, final String name, final File file)
+ throws IOException {
+ os.write(
+ ("--"
+ + boundary
+ + "\r\n"
+ + "Content-Disposition: form-data; name=\""
+ + name
+ + "\"; "
+ + "filename=\""
+ + file.getName()
+ + "\"\r\n"
+ + "Content-Type: application/octet-stream\r\n"
+ + "\r\n")
+ .getBytes());
+ final FileChannel fc = new FileInputStream(file).getChannel();
+ fc.transferTo(0, fc.size(), Channels.newChannel(os));
+ fc.close();
+ }
+
+ private static String unescape(final String string) {
+ return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t");
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java
new file mode 100644
index 0000000000..fe6b723983
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java
@@ -0,0 +1,36 @@
+/* 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 static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.LOCAL_VARIABLE;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PACKAGE;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Additional metadata about a deprecation notice. */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(value = {CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE})
+public @interface DeprecationSchedule {
+ /**
+ * @return Major version when we expect to remove the deprecated member attached to this
+ * annotation.
+ */
+ int version();
+
+ /**
+ * @return Identifier for a deprecation notice. All notices with the same identifier will be
+ * removed at the same time.
+ */
+ String id();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ExperimentDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ExperimentDelegate.java
new file mode 100644
index 0000000000..0eb7ee0252
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ExperimentDelegate.java
@@ -0,0 +1,168 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import static org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import org.json.JSONObject;
+
+/**
+ * This delegate is used to pass experiment information between the embedding application and
+ * GeckoView.
+ *
+ * <p>An experiment is used to give users different application behavior in order to learn and
+ * improve upon what features users prefer the most. This is accomplished by providing users
+ * different application experiences and collecting data about how the differing experiences
+ * impacted user behavior.
+ */
+public interface ExperimentDelegate {
+ /**
+ * Used to retrieve experiment information for the given feature identification.
+ *
+ * <p>A @param feature is the item or experience the experimented is about. For example, "prompt"
+ * or "print" could be a feature.
+ *
+ * <p>The @return experiment information will be information on what the application should do for
+ * the experiment. This is highly context dependent on how the experiment was setup and is decided
+ * and controlled by the experiment framework. For example, a feature of "prompt" may return
+ * {dismiss-button: {color: "red", full-screen: true}} or "print" may return {dotprint-enabled:
+ * true}. That information can then be used to present differing behavior for the user.
+ *
+ * @param feature The name or identification of the experiment feature.
+ * @return A {@link GeckoResult<JSONObject>} with experiment criteria. Typically will have a value
+ * related to showing or adjusting a feature. Will complete exceptionally with {@link
+ * ExperimentException} if the feature wasn't found.
+ */
+ @AnyThread
+ default @NonNull GeckoResult<JSONObject> onGetExperimentFeature(@NonNull String feature) {
+ final GeckoResult<JSONObject> result = new GeckoResult<>();
+ result.completeExceptionally(
+ new ExperimentException(ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED));
+ return result;
+ }
+
+ /**
+ * Used to let the experiment framework know that the user was shown the feature. Should be
+ * recorded as close as possible to the differing behavior.
+ *
+ * <p>One important part of experimentation is knowing when users encountered an experiment
+ * surface or difference in behavior. Sending an exposure event is recording with the experiment
+ * framework that the user encountered a differing behavior.
+ *
+ * <p>For example, if a user never encountered a @param feature "prompt", then the exposure event
+ * would never be recorded. However, if the user does encounter a "prompt", then the experiment
+ * framework needs a record that the user encountered the experiment surface.
+ *
+ * @param feature The name or identification the experiment feature.
+ * @return A {@link GeckoResult<Void>} will complete if the feature was found and exposure
+ * recorded. Will complete exceptionally with {@link ExperimentException} if the feature
+ * wasn't found.
+ */
+ @AnyThread
+ default @NonNull GeckoResult<Void> onRecordExposureEvent(@NonNull String feature) {
+ final GeckoResult<Void> result = new GeckoResult<>();
+ result.completeExceptionally(
+ new ExperimentException(ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED));
+ return result;
+ }
+
+ /**
+ * Used to let the experiment framework know that the user was shown the feature in a given
+ * experiment. Should be recorded as close as possible to the differing behavior.
+ *
+ * <p>Use [onRecordExposureEvent], if there is no experiment slug.
+ *
+ * <p>This API is used similarly to [onRecordExposureEvent], but when a specific feature was
+ * encountered. For example a @param feature may be "prompt" and a given @param slug may be
+ * "dismiss" or "confirm". This is used to indicate a specific experiment surface was encountered.
+ *
+ * @param feature The name or identification the experiment feature.
+ * @param slug The name or identification of the specific experiment feature.
+ * @return A {@link GeckoResult<Void>} will complete if the feature was found and exposure
+ * recorded. Will complete exceptionally with {@link ExperimentException} if the feature
+ * wasn't found or not recorded.
+ */
+ @AnyThread
+ default @NonNull GeckoResult<Void> onRecordExperimentExposureEvent(
+ @NonNull String feature, @NonNull String slug) {
+ final GeckoResult<Void> result = new GeckoResult<>();
+ result.completeExceptionally(
+ new ExperimentException(ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED));
+ return result;
+ }
+
+ /**
+ * Used to let the experiment framework send a malformed configuration event when the feature
+ * configuration is not semantically valid.
+ *
+ * @param feature The name or identification the experiment feature.
+ * @param part An optional detail or part identifier to be attached to the event.
+ * @return A {@link GeckoResult<Void>} will complete if the feature was found and the event
+ * recorded. Will complete exceptionally with {@link ExperimentException} if the feature
+ * wasn't found or not recorded.
+ */
+ @AnyThread
+ default @NonNull GeckoResult<Void> onRecordMalformedConfigurationEvent(
+ @NonNull String feature, @NonNull String part) {
+ final GeckoResult<Void> result = new GeckoResult<>();
+ result.completeExceptionally(
+ new ExperimentException(ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED));
+ return result;
+ }
+
+ /**
+ * An exception to be used when there is an issue retrieving or sending information to the
+ * experiment framework.
+ */
+ class ExperimentException extends Exception {
+
+ /**
+ * Construct an [ExperimentException]
+ *
+ * @param code error code the given exception corresponds to
+ */
+ public ExperimentException(final @Codes int code) {
+ this.code = code;
+ }
+
+ /** Default error for unexpected issues. */
+ public static final int ERROR_UNKNOWN = -1;
+
+ /** The experiment feature was not available. */
+ public static final int ERROR_FEATURE_NOT_FOUND = -2;
+
+ /** The experiment slug was not available. */
+ public static final int ERROR_EXPERIMENT_SLUG_NOT_FOUND = -3;
+
+ /** The experiment delegate is not implemented. */
+ public static final int ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED = -4;
+
+ /** Experiment exception error codes. */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ ERROR_UNKNOWN,
+ ERROR_FEATURE_NOT_FOUND,
+ ERROR_EXPERIMENT_SLUG_NOT_FOUND,
+ ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED
+ })
+ public @interface Codes {}
+
+ /** One of {@link Codes} that provides more information about this exception. */
+ public final @Codes int code;
+
+ @Override
+ public String toString() {
+ return "ExperimentException: " + code;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java
new file mode 100644
index 0000000000..1fc34cb8bb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java
@@ -0,0 +1,528 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.Bitmap;
+import android.graphics.Rect;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * Applications use a GeckoDisplay instance to provide {@link GeckoSession} with a {@link Surface}
+ * for displaying content. To ensure drawing only happens on a valid {@link Surface}, {@link
+ * GeckoSession} will only use the provided {@link Surface} after {@link
+ * #surfaceChanged(SurfaceInfo)} is called and before {@link #surfaceDestroyed()} returns.
+ */
+public class GeckoDisplay {
+ private final GeckoSession mSession;
+
+ protected GeckoDisplay(final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Interface that allows Gecko the request a new Surface from the application. An implementation
+ * of this should be set on the {@link GeckoDisplay.SurfaceInfo} object passed to {@link
+ * GeckoDisplay#surfaceChanged(SurfaceInfo)}, by using {@link
+ * GeckoDisplay.SurfaceInfo.Builder#newSurfaceProvider(NewSurfaceProvider)}.
+ */
+ public interface NewSurfaceProvider {
+ /**
+ * Called by Gecko to request a new Surface from the application.
+ *
+ * <p>Occasionally the Surface provided to Gecko via {@link #surfaceChanged(SurfaceInfo)} is
+ * invalid and Gecko is unable to render in to it. This function will be called in such
+ * circumstances. It is the implementation's responsibility to ensure that {@link
+ * #surfaceChanged(SurfaceInfo)} gets called soon afterwards with a new Surface, allowing Gecko
+ * to resume rendering.
+ *
+ * <p>Failure to implement this function may result in Gecko either crashing or not rendering
+ * correctly should it encounter an invalid Surface.
+ */
+ @UiThread
+ void requestNewSurface();
+ }
+
+ /**
+ * Wrapper class containing a Surface and associated information that the compositor should render
+ * in to. Should be constructed using {@link SurfaceInfo.Builder}.
+ */
+ public static class SurfaceInfo {
+ /* package */ final @NonNull Surface mSurface;
+ /* package */ final @Nullable SurfaceControl mSurfaceControl;
+ /* package */ final @Nullable NewSurfaceProvider mNewSurfaceProvider;
+ /* package */ final int mLeft;
+ /* package */ final int mTop;
+ /* package */ final int mWidth;
+ /* package */ final int mHeight;
+
+ private SurfaceInfo(final @NonNull Builder builder) {
+ mSurface = builder.mSurface;
+ mSurfaceControl = builder.mSurfaceControl;
+ mNewSurfaceProvider = builder.mNewSurfaceProvider;
+ mLeft = builder.mLeft;
+ mTop = builder.mTop;
+ mWidth = builder.mWidth;
+ mHeight = builder.mHeight;
+ }
+
+ /** Helper class for constructing a {@link SurfaceInfo} object. */
+ public static class Builder {
+ private Surface mSurface;
+ private SurfaceControl mSurfaceControl;
+ private NewSurfaceProvider mNewSurfaceProvider;
+ private int mLeft;
+ private int mTop;
+ private int mWidth;
+ private int mHeight;
+
+ /**
+ * Creates a new Builder and sets the new Surface.
+ *
+ * @param surface The new Surface.
+ */
+ public Builder(final @NonNull Surface surface) {
+ mSurface = surface;
+ }
+
+ /**
+ * Sets the SurfaceControl associated with the new Surface's SurfaceView.
+ *
+ * <p>This must be called when rendering in to a {@link android.view.SurfaceView} on SDK level
+ * 29 or above. On earlier SDK levels, or when rendering in to something other than a
+ * SurfaceView, this call can be omitted or the value can be null.
+ *
+ * @param surfaceControl The SurfaceControl associated with the new Surface's SurfaceView, or
+ * null.
+ * @return The builder object
+ */
+ @UiThread
+ public @NonNull Builder surfaceControl(final @Nullable SurfaceControl surfaceControl) {
+ mSurfaceControl = surfaceControl;
+ return this;
+ }
+
+ /**
+ * Sets a NewSurfaceProvider from which Gecko can request a new Surface.
+ *
+ * <p>This allows Gecko to recover from situations where the current Surface is for whatever
+ * reason invalid and Gecko is unable to render in to it. Failure to set this field correctly
+ * may result in Gecko either crashing or not rendering correctly should it encounter an
+ * invalid Surface.
+ *
+ * @param newSurfaceProvider A NewSurfaceProvider from which Gecko can request a new Surface.
+ * @return The builder object
+ */
+ @UiThread
+ public @NonNull Builder newSurfaceProvider(
+ final @Nullable NewSurfaceProvider newSurfaceProvider) {
+ mNewSurfaceProvider = newSurfaceProvider;
+ return this;
+ }
+
+ /**
+ * Sets the new compositor origin offset.
+ *
+ * @param left The compositor origin offset in the X axis. Can not be negative.
+ * @param top The compositor origin offset in the Y axis. Can not be negative.
+ * @return The builder object
+ */
+ @UiThread
+ public @NonNull Builder offset(final int left, final int top) {
+ mLeft = left;
+ mTop = top;
+ return this;
+ }
+
+ /**
+ * Sets the new surface size.
+ *
+ * @param width New width of the Surface. Can not be negative.
+ * @param height New height of the Surface. Can not be negative.
+ * @return The builder object
+ */
+ @UiThread
+ public @NonNull Builder size(final int width, final int height) {
+ mWidth = width;
+ mHeight = height;
+ return this;
+ }
+
+ /**
+ * Builds the {@link SurfaceInfo} object with the specified properties.
+ *
+ * @return The SurfaceInfo object
+ */
+ @UiThread
+ public @NonNull SurfaceInfo build() {
+ if ((mLeft < 0) || (mTop < 0)) {
+ throw new IllegalArgumentException("Left and Top offsets can not be negative.");
+ }
+
+ return new SurfaceInfo(this);
+ }
+ }
+ }
+
+ /**
+ * Sets a surface for the compositor render a surface.
+ *
+ * <p>Required call. The display's Surface has been created or changed. Must be called on the
+ * application main thread. GeckoSession may block this call to ensure the Surface is valid while
+ * resuming drawing.
+ *
+ * <p>If rendering in to a {@link android.view.SurfaceView} on SDK level 29 or above, please
+ * ensure that the SurfaceControl field of the {@link SurfaceInfo} object is set.
+ *
+ * @param surfaceInfo Information about the new Surface.
+ */
+ @UiThread
+ public void surfaceChanged(@NonNull final SurfaceInfo surfaceInfo) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSurfaceChanged(surfaceInfo);
+ }
+ }
+
+ /**
+ * Removes the current surface registered with the compositor.
+ *
+ * <p>Required call. The display's Surface has been destroyed. Must be called on the application
+ * main thread. GeckoSession may block this call to ensure the Surface is valid while pausing
+ * drawing.
+ */
+ @UiThread
+ public void surfaceDestroyed() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSurfaceDestroyed();
+ }
+ }
+
+ /**
+ * Update the position of the surface on the screen.
+ *
+ * <p>Optional call. The display's coordinates on the screen has changed. Must be called on the
+ * application main thread.
+ *
+ * @param left The X coordinate of the display on the screen, in screen pixels.
+ * @param top The Y coordinate of the display on the screen, in screen pixels.
+ */
+ @UiThread
+ public void screenOriginChanged(final int left, final int top) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onScreenOriginChanged(left, top);
+ }
+ }
+
+ /**
+ * Update the safe area insets of the surface on the screen.
+ *
+ * @param left left margin of safe area
+ * @param top top margin of safe area
+ * @param right right margin of safe area
+ * @param bottom bottom margin of safe area
+ */
+ @UiThread
+ public void safeAreaInsetsChanged(
+ final int top, final int right, final int bottom, final int left) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSafeAreaInsetsChanged(top, right, bottom, left);
+ }
+ }
+
+ /**
+ * Set the maximum height of the dynamic toolbar(s).
+ *
+ * <p>If the toolbar is dynamic, this function needs to be called with the maximum possible
+ * toolbar height so that Gecko can make the ICB static even during the dynamic toolbar height is
+ * being changed.
+ *
+ * @param height The maximum height of the dynamic toolbar(s).
+ */
+ @UiThread
+ public void setDynamicToolbarMaxHeight(final int height) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession != null) {
+ mSession.setDynamicToolbarMaxHeight(height);
+ }
+ }
+
+ /**
+ * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion
+ * of the display. Tells gecko where to put bottom fixed elements so they are fully visible.
+ *
+ * <p>Optional call. The display's visible vertical space has changed. Must be called on the
+ * application main thread.
+ *
+ * @param clippingHeight The height of the bottom clipped space in screen pixels.
+ */
+ @UiThread
+ public void setVerticalClipping(final int clippingHeight) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession != null) {
+ mSession.setFixedBottomOffset(clippingHeight);
+ }
+ }
+
+ /**
+ * Return whether the display should be pinned on the screen.
+ *
+ * <p>When pinned, the display should not be moved on the screen due to animation, scrolling, etc.
+ * A common reason for the display being pinned is when the user is dragging a selection caret
+ * inside the display; normal user interaction would be disrupted in that case if the display was
+ * moved on screen.
+ *
+ * @return True if display should be pinned on the screen.
+ */
+ @UiThread
+ public boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+ return mSession.getDisplay() == this && mSession.shouldPinOnScreen();
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being rendered.
+ *
+ * <p>Returned {@link Bitmap} will have the same dimensions as the {@link Surface} the {@link
+ * GeckoDisplay} is currently using.
+ *
+ * <p>If the {@link GeckoSession#isCompositorReady} is false the {@link GeckoResult} will complete
+ * with an {@link IllegalStateException}.
+ *
+ * <p>This function must be called on the UI thread.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the currently visible rendered web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capturePixels() {
+ return screenshot().capture();
+ }
+
+ /** Builder to construct screenshot requests. */
+ public static final class ScreenshotBuilder {
+ private static final int NONE = 0;
+ private static final int SCALE = 1;
+ private static final int ASPECT = 2;
+ private static final int FULL = 3;
+ private static final int RECYCLE = 4;
+
+ private final GeckoSession mSession;
+ private int mOffsetX;
+ private int mOffsetY;
+ private int mSrcWidth;
+ private int mSrcHeight;
+ private int mOutWidth;
+ private int mOutHeight;
+ private int mAspectPreservingWidth;
+ private float mScale;
+ private Bitmap mRecycle;
+ private int mSizeType;
+
+ /* package */ ScreenshotBuilder(final GeckoSession session) {
+ this.mSizeType = NONE;
+ this.mSession = session;
+ }
+
+ /**
+ * The screenshot will be of a region instead of the entire screen
+ *
+ * @param x Left most pixel of the source region.
+ * @param y Top most pixel of the source region.
+ * @param width Width of the source region in screen pixels
+ * @param height Height of the source region in screen pixels
+ * @return The builder
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder source(
+ final int x, final int y, final int width, final int height) {
+ mOffsetX = x;
+ mOffsetY = y;
+ mSrcWidth = width;
+ mSrcHeight = height;
+ return this;
+ }
+
+ /**
+ * The screenshot will be of a region instead of the entire screen
+ *
+ * @param source Region of the screen to capture in screen pixels
+ * @return The builder
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder source(final @NonNull Rect source) {
+ mOffsetX = source.left;
+ mOffsetY = source.top;
+ mSrcWidth = source.width();
+ mSrcHeight = source.height();
+ return this;
+ }
+
+ private void checkAndSetSizeType(final int sizeType) {
+ if (mSizeType != NONE) {
+ throw new IllegalStateException("Size has already been set.");
+ }
+ mSizeType = sizeType;
+ }
+
+ /**
+ * The width of the bitmap to create when taking the screenshot. The height will be calculated
+ * to match the aspect ratio of the source as closely as possible. The source screenshot will be
+ * scaled into the resulting Bitmap.
+ *
+ * @param width of the result Bitmap in screen pixels.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder aspectPreservingSize(final int width) {
+ checkAndSetSizeType(ASPECT);
+ mAspectPreservingWidth = width;
+ return this;
+ }
+
+ /**
+ * The scale of the bitmap relative to the source. The height and width of the output bitmap
+ * will be within one pixel of this multiple of the source dimensions. The source screenshot
+ * will be scaled into the resulting Bitmap.
+ *
+ * @param scale of the result Bitmap relative to the source.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder scale(final float scale) {
+ checkAndSetSizeType(SCALE);
+ mScale = scale;
+ return this;
+ }
+
+ /**
+ * Size of the bitmap to create when taking the screenshot. The source screenshot will be scaled
+ * into the resulting Bitmap
+ *
+ * @param width of the result Bitmap in screen pixels.
+ * @param height of the result Bitmap in screen pixels.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder size(final int width, final int height) {
+ checkAndSetSizeType(FULL);
+ mOutWidth = width;
+ mOutHeight = height;
+ return this;
+ }
+
+ /**
+ * Instead of creating a new Bitmap for the result, the builder will use the passed Bitmap.
+ *
+ * @param bitmap The Bitmap to use in the result.
+ * @return The builder.
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder bitmap(final @Nullable Bitmap bitmap) {
+ checkAndSetSizeType(RECYCLE);
+ mRecycle = bitmap;
+ return this;
+ }
+
+ /**
+ * Request a {@link Bitmap} of the requested portion of the web page currently being rendered
+ * using any parameters specified with the builder.
+ *
+ * <p>This function must be called on the UI thread.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the requested portion of the visible web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capture() {
+ ThreadUtils.assertOnUiThread();
+ if (!mSession.isCompositorReady()) {
+ throw new IllegalStateException("Compositor must be ready before pixels can be captured");
+ }
+
+ final GeckoResult<Bitmap> result = new GeckoResult<>();
+ final Bitmap target;
+ final Rect rect = new Rect();
+
+ if (mSrcWidth == 0 || mSrcHeight == 0) {
+ // Source is unset or invalid, use defaults.
+ mSession.getSurfaceBounds(rect);
+ mSrcWidth = rect.width();
+ mSrcHeight = rect.height();
+ }
+
+ switch (mSizeType) {
+ case NONE:
+ mOutWidth = mSrcWidth;
+ mOutHeight = mSrcHeight;
+ break;
+ case SCALE:
+ mSession.getSurfaceBounds(rect);
+ mOutWidth = (int) (rect.width() * mScale);
+ mOutHeight = (int) (rect.height() * mScale);
+ break;
+ case ASPECT:
+ mSession.getSurfaceBounds(rect);
+ mOutWidth = mAspectPreservingWidth;
+ mOutHeight = (int) (rect.height() * (mAspectPreservingWidth / (double) rect.width()));
+ break;
+ case RECYCLE:
+ mOutWidth = mRecycle.getWidth();
+ mOutHeight = mRecycle.getHeight();
+ break;
+ // case FULL does not need to be handled, as width and height are already set.
+ }
+
+ if (mRecycle == null) {
+ try {
+ target = Bitmap.createBitmap(mOutWidth, mOutHeight, Bitmap.Config.ARGB_8888);
+ } catch (final Throwable e) {
+ if (e instanceof NullPointerException || e instanceof OutOfMemoryError) {
+ return GeckoResult.fromException(
+ new OutOfMemoryError("Not enough memory to allocate for bitmap"));
+ }
+ return GeckoResult.fromException(new Throwable("Failed to create bitmap", e));
+ }
+ } else {
+ target = mRecycle;
+ }
+
+ mSession.mCompositor.requestScreenPixels(
+ result, target, mOffsetX, mOffsetY, mSrcWidth, mSrcHeight, mOutWidth, mOutHeight);
+
+ return result;
+ }
+ }
+
+ /**
+ * Creates a new screenshot builder.
+ *
+ * @return The new {@link ScreenshotBuilder}
+ */
+ @UiThread
+ public @NonNull ScreenshotBuilder screenshot() {
+ return new ScreenshotBuilder(mSession);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
new file mode 100644
index 0000000000..d365f303c2
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
@@ -0,0 +1,2613 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.method.KeyListener;
+import android.text.method.TextKeyListener;
+import android.text.style.CharacterStyle;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.mozilla.gecko.GeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEContextFlags;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMENotificationType;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEState;
+
+/**
+ * GeckoEditable implements only some functions of Editable The field mText contains the actual
+ * underlying SpannableStringBuilder/Editable that contains our text.
+ */
+/* package */ final class GeckoEditable extends IGeckoEditableParent.Stub
+ implements InvocationHandler, Editable, SessionTextInput.EditableClient {
+
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "GeckoEditable";
+
+ // Filters to implement Editable's filtering functionality
+ private InputFilter[] mFilters;
+
+ /**
+ * We need a WeakReference here to avoid unnecessary retention of the GeckoSession. Passing
+ * objects around via JNI seems to confuse the GC into thinking we have a native GC root.
+ */
+ /* package */ final WeakReference<GeckoSession> mSession;
+
+ private final AsyncText mText;
+ private final Editable mProxy;
+ private final ConcurrentLinkedQueue<Action> mActions;
+ private KeyCharacterMap mKeyMap;
+
+ // mIcRunHandler is the Handler that currently runs Gecko-to-IC Runnables
+ // mIcPostHandler is the Handler to post Gecko-to-IC Runnables to
+ // The two can be different when switching from one handler to another
+ private Handler mIcRunHandler;
+ private Handler mIcPostHandler;
+
+ // Parent process child used as a default for key events.
+ /* package */ IGeckoEditableChild mDefaultChild; // Used by IC thread.
+ // Parent or content process child that has the focus.
+ /* package */ IGeckoEditableChild mFocusedChild; // Used by IC thread.
+ /* package */ IBinder mFocusedToken; // Used by Gecko/binder thread.
+ /* package */ SessionTextInput.EditableListener mListener;
+
+ /* package */ boolean mInBatchMode; // Used by IC thread
+ /* package */ boolean mNeedSync; // Used by IC thread
+ // Gecko side needs an updated composition from Java;
+ private boolean mNeedUpdateComposition; // Used by IC thread
+ private boolean mSuppressKeyUp; // Used by IC thread
+
+ @IMEState
+ private int mIMEState = // Used by IC thread.
+ SessionTextInput.EditableListener.IME_STATE_DISABLED;
+
+ private String mIMETypeHint = ""; // Used by IC/UI thread.
+ private String mIMEModeHint = ""; // Used by IC thread.
+ private String mIMEActionHint = ""; // Used by IC thread.
+ private String mIMEAutocapitalize = ""; // Used by IC thread.
+ @IMEContextFlags private int mIMEFlags; // Used by IC thread.
+
+ private boolean mIgnoreSelectionChange; // Used by Gecko thread
+ // Combined offsets from the previous batch of onTextChange calls; valid
+ // between the onTextChange calls and the next onSelectionChange call.
+ private int mLastTextChangeStart = Integer.MAX_VALUE; // Used by Gecko thread
+ private int mLastTextChangeOldEnd = -1; // Used by Gecko thread
+ private int mLastTextChangeNewEnd = -1; // Used by Gecko thread
+ private boolean mLastTextChangeReplacedSelection; // Used by Gecko thread
+
+ // Prevent showSoftInput and hideSoftInput from being called multiple times in a row,
+ // including reentrant calls on some devices. Used by UI/IC thread.
+ /* package */ final AtomicInteger mSoftInputReentrancyGuard = new AtomicInteger();
+
+ private static final int IME_RANGE_CARETPOSITION = 1;
+ private static final int IME_RANGE_RAWINPUT = 2;
+ private static final int IME_RANGE_SELECTEDRAWTEXT = 3;
+ private static final int IME_RANGE_CONVERTEDTEXT = 4;
+ private static final int IME_RANGE_SELECTEDCONVERTEDTEXT = 5;
+
+ private static final int IME_RANGE_LINE_NONE = 0;
+ private static final int IME_RANGE_LINE_SOLID = 1;
+ private static final int IME_RANGE_LINE_DOTTED = 2;
+ private static final int IME_RANGE_LINE_DASHED = 3;
+ private static final int IME_RANGE_LINE_DOUBLE = 4;
+ private static final int IME_RANGE_LINE_WAVY = 5;
+
+ private static final int IME_RANGE_UNDERLINE = 1;
+ private static final int IME_RANGE_FORECOLOR = 2;
+ private static final int IME_RANGE_BACKCOLOR = 4;
+ private static final int IME_RANGE_LINECOLOR = 8;
+
+ private void onKeyEvent(
+ final IGeckoEditableChild child,
+ final KeyEvent event,
+ final int action,
+ final int savedMetaState,
+ final boolean isSynthesizedImeKey)
+ throws RemoteException {
+ // Use a separate action argument so we can override the key's original action,
+ // e.g. change ACTION_MULTIPLE to ACTION_DOWN. That way we don't have to allocate
+ // a new key event just to change its action field.
+ //
+ // Normally we expect event.getMetaState() to reflect the current meta-state; however,
+ // some software-generated key events may not have event.getMetaState() set, e.g. key
+ // events from Swype. Therefore, it's necessary to combine the key's meta-states
+ // with the meta-states that we keep separately in KeyListener
+ final int metaState = event.getMetaState() | savedMetaState;
+ final int unmodifiedMetaState =
+ metaState & ~(KeyEvent.META_ALT_MASK | KeyEvent.META_CTRL_MASK | KeyEvent.META_META_MASK);
+
+ final int unicodeChar = event.getUnicodeChar(metaState);
+ final int unmodifiedUnicodeChar = event.getUnicodeChar(unmodifiedMetaState);
+ final int domPrintableKeyValue =
+ unicodeChar >= ' '
+ ? unicodeChar
+ : unmodifiedMetaState != metaState ? unmodifiedUnicodeChar : 0;
+
+ // If a modifier (e.g. meta key) caused a different character to be entered, we
+ // drop that modifier from the metastate for the generated keypress event.
+ final int keyPressMetaState =
+ (unicodeChar >= ' ' && unicodeChar != unmodifiedUnicodeChar)
+ ? unmodifiedMetaState
+ : metaState;
+
+ // For synthesized keys, ignore modifier metastates from the synthesized event,
+ // because the synthesized modifier metastates don't reflect the actual state of
+ // the meta keys (bug 1387889). For example, the Latin sharp S (U+00DF) is
+ // synthesized as Alt+S, but we don't want the Alt metastate because the Alt key
+ // is not actually pressed in this case.
+ final int keyUpDownMetaState =
+ isSynthesizedImeKey ? (unmodifiedMetaState | savedMetaState) : metaState;
+
+ child.onKeyEvent(
+ action,
+ event.getKeyCode(),
+ event.getScanCode(),
+ keyUpDownMetaState,
+ keyPressMetaState,
+ event.getEventTime(),
+ domPrintableKeyValue,
+ event.getRepeatCount(),
+ event.getFlags(),
+ isSynthesizedImeKey,
+ event);
+ }
+
+ /**
+ * Class that encapsulates asynchronous text editing. There are two copies of the text, a current
+ * copy and a shadow copy. Both can be modified independently through the current*** and shadow***
+ * methods, respectively. The current copy can only be modified on the Gecko side and reflects the
+ * authoritative version of the text. The shadow copy can only be modified on the IC side and
+ * reflects what we think the current text is. Periodically, the shadow copy can be synced to the
+ * current copy through syncShadowText, so the shadow copy once again refers to the same text as
+ * the current copy.
+ */
+ private final class AsyncText {
+ // The current text is the update-to-date version of the text, and is only updated
+ // on the Gecko side.
+ private final SpannableStringBuilder mCurrentText = new SpannableStringBuilder();
+ // Track changes on the current side for syncing purposes.
+ // Start of the changed range in current text since last sync.
+ private int mCurrentStart = Integer.MAX_VALUE;
+ // End of the changed range (before the change) in current text since last sync.
+ private int mCurrentOldEnd;
+ // End of the changed range (after the change) in current text since last sync.
+ private int mCurrentNewEnd;
+ // Track selection changes separately.
+ private boolean mCurrentSelectionChanged;
+
+ // The shadow text is what we think the current text is on the Java side, and is
+ // periodically synced with the current text.
+ private final SpannableStringBuilder mShadowText = new SpannableStringBuilder();
+ // Track changes on the shadow side for syncing purposes.
+ // Start of the changed range in shadow text since last sync.
+ private int mShadowStart = Integer.MAX_VALUE;
+ // End of the changed range (before the change) in shadow text since last sync.
+ private int mShadowOldEnd;
+ // End of the changed range (after the change) in shadow text since last sync.
+ private int mShadowNewEnd;
+
+ private void addCurrentChangeLocked(final int start, final int oldEnd, final int newEnd) {
+ // Merge the new change into any existing change.
+ mCurrentStart = Math.min(mCurrentStart, start);
+ mCurrentOldEnd += Math.max(0, oldEnd - mCurrentNewEnd);
+ mCurrentNewEnd = newEnd + Math.max(0, mCurrentNewEnd - oldEnd);
+ }
+
+ public synchronized void currentReplace(
+ final int start, final int end, final CharSequence newText) {
+ // On Gecko or binder thread.
+ mCurrentText.replace(start, end, newText);
+ addCurrentChangeLocked(start, end, start + newText.length());
+ }
+
+ public synchronized void currentSetSelection(final int start, final int end) {
+ // On Gecko or binder thread.
+ Selection.setSelection(mCurrentText, start, end);
+ mCurrentSelectionChanged = true;
+ }
+
+ public synchronized void currentSetSpan(
+ final Object obj, final int start, final int end, final int flags) {
+ // On Gecko or binder thread.
+ mCurrentText.setSpan(obj, start, end, flags);
+ addCurrentChangeLocked(start, end, end);
+ }
+
+ public synchronized void currentRemoveSpan(final Object obj) {
+ // On Gecko or binder thread.
+ if (obj == null) {
+ mCurrentText.clearSpans();
+ addCurrentChangeLocked(0, mCurrentText.length(), mCurrentText.length());
+ return;
+ }
+ final int start = mCurrentText.getSpanStart(obj);
+ final int end = mCurrentText.getSpanEnd(obj);
+ if (start < 0 || end < 0) {
+ return;
+ }
+ mCurrentText.removeSpan(obj);
+ addCurrentChangeLocked(start, end, end);
+ }
+
+ // Return Spanned instead of Editable because the returned object is supposed to
+ // be read-only. Editing should be done through one of the current*** methods.
+ public Spanned getCurrentText() {
+ // On Gecko or binder thread.
+ return mCurrentText;
+ }
+
+ private void addShadowChange(final int start, final int oldEnd, final int newEnd) {
+ // Merge the new change into any existing change.
+ mShadowStart = Math.min(mShadowStart, start);
+ mShadowOldEnd += Math.max(0, oldEnd - mShadowNewEnd);
+ mShadowNewEnd = newEnd + Math.max(0, mShadowNewEnd - oldEnd);
+ }
+
+ public void shadowReplace(final int start, final int end, final CharSequence newText) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ mShadowText.replace(start, end, newText);
+ addShadowChange(start, end, start + newText.length());
+ }
+
+ public void shadowSetSpan(final Object obj, final int start, final int end, final int flags) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ mShadowText.setSpan(obj, start, end, flags);
+ addShadowChange(start, end, end);
+ }
+
+ public void shadowRemoveSpan(final Object obj) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ if (obj == null) {
+ mShadowText.clearSpans();
+ addShadowChange(0, mShadowText.length(), mShadowText.length());
+ return;
+ }
+ final int start = mShadowText.getSpanStart(obj);
+ final int end = mShadowText.getSpanEnd(obj);
+ if (start < 0 || end < 0) {
+ return;
+ }
+ mShadowText.removeSpan(obj);
+ addShadowChange(start, end, end);
+ }
+
+ // Return Spanned instead of Editable because the returned object is supposed to
+ // be read-only. Editing should be done through one of the shadow*** methods.
+ public Spanned getShadowText() {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ return mShadowText;
+ }
+
+ /**
+ * Check whether we are currently discarding the composition. It means that shadow text has
+ * composition, but current text has no composition. So syncShadowText will discard composition.
+ *
+ * @return true if discarding composition
+ */
+ private boolean isDiscardingComposition() {
+ if (!isComposing(mShadowText)) {
+ return false;
+ }
+
+ return !isComposing(mCurrentText);
+ }
+
+ public synchronized void syncShadowText(final SessionTextInput.EditableListener listener) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ if (mCurrentStart > mCurrentOldEnd && mShadowStart > mShadowOldEnd) {
+ // Still check selection changes.
+ if (!mCurrentSelectionChanged) {
+ return;
+ }
+ final int start = Selection.getSelectionStart(mCurrentText);
+ final int end = Selection.getSelectionEnd(mCurrentText);
+ Selection.setSelection(mShadowText, start, end);
+ mCurrentSelectionChanged = false;
+
+ if (listener != null) {
+ listener.onSelectionChange();
+ }
+ return;
+ }
+
+ if (isDiscardingComposition()) {
+ if (listener != null) {
+ listener.onDiscardComposition();
+ }
+ }
+
+ // Copy the portion of the current text that has changed over to the shadow
+ // text, with consideration for any concurrent changes in the shadow text.
+ final int start = Math.min(mShadowStart, mCurrentStart);
+ final int shadowEnd = mShadowNewEnd + Math.max(0, mCurrentOldEnd - mShadowOldEnd);
+ final int currentEnd = mCurrentNewEnd + Math.max(0, mShadowOldEnd - mCurrentOldEnd);
+
+ // Remove existing spans that may no longer be in the new text.
+ Object[] spans = mShadowText.getSpans(start, shadowEnd, Object.class);
+ for (final Object span : spans) {
+ mShadowText.removeSpan(span);
+ }
+
+ mShadowText.replace(start, shadowEnd, mCurrentText, start, currentEnd);
+
+ // The replace() call may not have copied all affected spans, so we re-copy all the
+ // spans manually just in case. Expand bounds by 1 so we get all the spans.
+ spans =
+ mCurrentText.getSpans(
+ Math.max(start - 1, 0),
+ Math.min(currentEnd + 1, mCurrentText.length()),
+ Object.class);
+ for (final Object span : spans) {
+ if (span == Selection.SELECTION_START || span == Selection.SELECTION_END) {
+ continue;
+ }
+ mShadowText.setSpan(
+ span,
+ mCurrentText.getSpanStart(span),
+ mCurrentText.getSpanEnd(span),
+ mCurrentText.getSpanFlags(span));
+ }
+
+ // SpannableStringBuilder has some internal logic to fix up selections, but we
+ // don't want that, so we always fix up the selection a second time.
+ final int selStart = Selection.getSelectionStart(mCurrentText);
+ final int selEnd = Selection.getSelectionEnd(mCurrentText);
+ Selection.setSelection(mShadowText, selStart, selEnd);
+
+ if (DEBUG && !checkEqualText(mShadowText, mCurrentText)) {
+ // Sanity check.
+ throw new IllegalStateException(
+ "Failed to sync: "
+ + mShadowStart
+ + '-'
+ + mShadowOldEnd
+ + '-'
+ + mShadowNewEnd
+ + '/'
+ + mCurrentStart
+ + '-'
+ + mCurrentOldEnd
+ + '-'
+ + mCurrentNewEnd);
+ }
+
+ if (listener != null) {
+ // Call onTextChange after selection fix-up but before we call
+ // onSelectionChange.
+ listener.onTextChange();
+
+ if (mCurrentSelectionChanged
+ || (mCurrentOldEnd != mCurrentNewEnd
+ && (selStart >= mCurrentStart || selEnd >= mCurrentStart))) {
+ listener.onSelectionChange();
+ }
+ }
+
+ // These values ensure the first change is properly added.
+ mCurrentStart = mShadowStart = Integer.MAX_VALUE;
+ mCurrentOldEnd = mShadowOldEnd = 0;
+ mCurrentNewEnd = mShadowNewEnd = 0;
+ mCurrentSelectionChanged = false;
+ }
+ }
+
+ private static boolean checkEqualText(final Spanned s1, final Spanned s2) {
+ if (!s1.toString().equals(s2.toString())) {
+ return false;
+ }
+
+ final Object[] o1s = s1.getSpans(0, s1.length(), Object.class);
+ final Object[] o2s = s2.getSpans(0, s2.length(), Object.class);
+
+ if (o1s.length != o2s.length) {
+ return false;
+ }
+
+ o1loop:
+ for (final Object o1 : o1s) {
+ for (final Object o2 : o2s) {
+ if (o1 != o2) {
+ continue;
+ }
+ if (s1.getSpanStart(o1) != s2.getSpanStart(o2)
+ || s1.getSpanEnd(o1) != s2.getSpanEnd(o2)
+ || s1.getSpanFlags(o1) != s2.getSpanFlags(o2)) {
+ return false;
+ }
+ continue o1loop;
+ }
+ // o1 not found in o2s.
+ return false;
+ }
+ return true;
+ }
+
+ /* An action that alters the Editable
+
+ Each action corresponds to a Gecko event. While the Gecko event is being sent to the Gecko
+ thread, the action stays on top of mActions queue. After the Gecko event is processed and
+ replied, the action is removed from the queue
+ */
+ private static final class Action {
+ // For input events (keypress, etc.); use with onImeSynchronize
+ static final int TYPE_EVENT = 0;
+ // For Editable.replace() call; use with onImeReplaceText
+ static final int TYPE_REPLACE_TEXT = 1;
+ // For Editable.setSpan() call; use with onImeSynchronize
+ static final int TYPE_SET_SPAN = 2;
+ // For Editable.removeSpan() call; use with onImeSynchronize
+ static final int TYPE_REMOVE_SPAN = 3;
+ // For switching handler; use with onImeSynchronize
+ static final int TYPE_SET_HANDLER = 4;
+
+ final int mType;
+ int mStart;
+ int mEnd;
+ CharSequence mSequence;
+ Object mSpanObject;
+ int mSpanFlags;
+ Handler mHandler;
+
+ Action(final int type) {
+ mType = type;
+ }
+
+ static Action newReplaceText(final CharSequence text, final int start, final int end) {
+ if (start < 0 || start > end) {
+ Log.e(LOGTAG, "invalid replace text offsets: " + start + " to " + end);
+ throw new IllegalArgumentException("invalid replace text offsets");
+ }
+
+ final Action action = new Action(TYPE_REPLACE_TEXT);
+ action.mSequence = text;
+ action.mStart = start;
+ action.mEnd = end;
+ return action;
+ }
+
+ static Action newSetSpan(final Object object, final int start, final int end, final int flags) {
+ if (start < 0 || start > end) {
+ Log.e(LOGTAG, "invalid span offsets: " + start + " to " + end);
+ throw new IllegalArgumentException("invalid span offsets");
+ }
+ final Action action = new Action(TYPE_SET_SPAN);
+ action.mSpanObject = object;
+ action.mStart = start;
+ action.mEnd = end;
+ action.mSpanFlags = flags;
+ return action;
+ }
+
+ static Action newRemoveSpan(final Object object) {
+ final Action action = new Action(TYPE_REMOVE_SPAN);
+ action.mSpanObject = object;
+ return action;
+ }
+
+ static Action newSetHandler(final Handler handler) {
+ final Action action = new Action(TYPE_SET_HANDLER);
+ action.mHandler = handler;
+ return action;
+ }
+ }
+
+ private void icOfferAction(final Action action) {
+ if (DEBUG) {
+ assertOnIcThread();
+ Log.d(LOGTAG, "offer: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")");
+ }
+
+ switch (action.mType) {
+ case Action.TYPE_EVENT:
+ case Action.TYPE_SET_HANDLER:
+ break;
+
+ case Action.TYPE_SET_SPAN:
+ mText.shadowSetSpan(
+ action.mSpanObject, action.mStart,
+ action.mEnd, action.mSpanFlags);
+ break;
+
+ case Action.TYPE_REMOVE_SPAN:
+ action.mSpanFlags = mText.getShadowText().getSpanFlags(action.mSpanObject);
+ mText.shadowRemoveSpan(action.mSpanObject);
+ break;
+
+ case Action.TYPE_REPLACE_TEXT:
+ mText.shadowReplace(action.mStart, action.mEnd, action.mSequence);
+ break;
+
+ default:
+ throw new IllegalStateException("Action not processed");
+ }
+
+ // Always perform actions on the shadow text side above, so we still act as a
+ // valid Editable object, but don't send the actions to Gecko below if we haven't
+ // been focused or initialized, or we've been destroyed.
+ if (mFocusedChild == null || mListener == null) {
+ return;
+ }
+
+ mActions.offer(action);
+
+ try {
+ icPerformAction(action);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ // Undo the offer.
+ mActions.remove(action);
+ }
+ }
+
+ private void icPerformAction(final Action action) throws RemoteException {
+ switch (action.mType) {
+ case Action.TYPE_EVENT:
+ case Action.TYPE_SET_HANDLER:
+ mFocusedChild.onImeSynchronize();
+ break;
+
+ case Action.TYPE_SET_SPAN:
+ {
+ final boolean needUpdate =
+ (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0
+ && ((action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0
+ || action.mSpanObject == Selection.SELECTION_START
+ || action.mSpanObject == Selection.SELECTION_END);
+
+ action.mSequence = TextUtils.substring(mText.getShadowText(), action.mStart, action.mEnd);
+
+ mNeedUpdateComposition |= needUpdate;
+ if (needUpdate) {
+ icMaybeSendComposition(
+ mText.getShadowText(),
+ SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT);
+ }
+
+ mFocusedChild.onImeSynchronize();
+ break;
+ }
+ case Action.TYPE_REMOVE_SPAN:
+ {
+ final boolean needUpdate =
+ (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0
+ && (action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0;
+
+ mNeedUpdateComposition |= needUpdate;
+ if (needUpdate) {
+ icMaybeSendComposition(
+ mText.getShadowText(),
+ SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT);
+ }
+
+ mFocusedChild.onImeSynchronize();
+ break;
+ }
+ case Action.TYPE_REPLACE_TEXT:
+ // Always sync text after a replace action, so that if the Gecko
+ // text is not changed, we will revert the shadow text to before.
+ mNeedSync = true;
+
+ // Because we get composition styling here essentially for free,
+ // we don't need to check if we're in batch mode.
+ if (icMaybeSendComposition(action.mSequence, SEND_COMPOSITION_USE_ENTIRE_TEXT)) {
+ mFocusedChild.onImeReplaceText(action.mStart, action.mEnd, action.mSequence.toString());
+ break;
+ }
+
+ // Since we don't have a composition, we can try sending key events.
+ sendCharKeyEvents(action);
+
+ // onImeReplaceText will set the selection range. But we don't
+ // know whether event state manager is processing text and
+ // selection. So current shadow may not be synchronized with
+ // Gecko's text and selection. So we have to avoid unnecessary
+ // selection update.
+ final int selStartOnShadow = Selection.getSelectionStart(mText.getShadowText());
+ final int selEndOnShadow = Selection.getSelectionEnd(mText.getShadowText());
+ int actionStart = action.mStart;
+ int actionEnd = action.mEnd;
+ // If action range is collapsed and selection of shadow text is
+ // collapsed, we may try to dispatch keypress on current caret
+ // position. Action range is previous range before dispatching
+ // keypress, and shadow range is new range after dispatching
+ // it.
+ if (action.mStart == action.mEnd
+ && selStartOnShadow == selEndOnShadow
+ && action.mStart == selStartOnShadow + action.mSequence.toString().length()) {
+ // Replacing range is same value as current shadow's selection.
+ // So it is unnecessary to update the selection on Gecko.
+ actionStart = -1;
+ actionEnd = -1;
+ }
+ mFocusedChild.onImeReplaceText(actionStart, actionEnd, action.mSequence.toString());
+ break;
+
+ default:
+ throw new IllegalStateException("Action not processed");
+ }
+ }
+
+ private KeyEvent[] synthesizeKeyEvents(final CharSequence cs) {
+ try {
+ if (mKeyMap == null) {
+ mKeyMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
+ }
+ } catch (final Exception e) {
+ // KeyCharacterMap.UnavailableException is not found on Gingerbread;
+ // besides, it seems like HC and ICS will throw something other than
+ // KeyCharacterMap.UnavailableException; so use a generic Exception here
+ return null;
+ }
+ final KeyEvent[] keyEvents = mKeyMap.getEvents(cs.toString().toCharArray());
+ if (keyEvents == null || keyEvents.length == 0) {
+ return null;
+ }
+ return keyEvents;
+ }
+
+ private void sendCharKeyEvents(final Action action) throws RemoteException {
+ if (action.mSequence.length() != 1
+ || (action.mSequence instanceof Spannable
+ && ((Spannable) action.mSequence).nextSpanTransition(-1, Integer.MAX_VALUE, null)
+ < Integer.MAX_VALUE)) {
+ // Spans are not preserved when we use key events,
+ // so we need the sequence to not have any spans
+ return;
+ }
+ final KeyEvent[] keyEvents = synthesizeKeyEvents(action.mSequence);
+ if (keyEvents == null) {
+ return;
+ }
+ for (final KeyEvent event : keyEvents) {
+ if (KeyEvent.isModifierKey(event.getKeyCode())) {
+ continue;
+ }
+ if (event.getAction() == KeyEvent.ACTION_UP && mSuppressKeyUp) {
+ continue;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "sending: " + event);
+ }
+ onKeyEvent(
+ mFocusedChild,
+ event,
+ event.getAction(),
+ /* metaState */ 0, /* isSynthesizedImeKey */
+ true);
+ }
+ }
+
+ public GeckoEditable(@NonNull final GeckoSession session) {
+ if (DEBUG) {
+ // Called by SessionTextInput.
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mSession = new WeakReference<>(session);
+ mText = new AsyncText();
+ mActions = new ConcurrentLinkedQueue<Action>();
+
+ final Class<?>[] PROXY_INTERFACES = {Editable.class};
+ mProxy =
+ (Editable) Proxy.newProxyInstance(Editable.class.getClassLoader(), PROXY_INTERFACES, this);
+
+ mIcRunHandler = mIcPostHandler = ThreadUtils.getUiHandler();
+ }
+
+ @Override // IGeckoEditableParent
+ public void setDefaultChild(final IGeckoEditableChild child) {
+ if (DEBUG) {
+ // On Gecko or binder thread.
+ Log.d(LOGTAG, "setDefaultEditableChild " + child);
+ }
+ mDefaultChild = child;
+ }
+
+ public void setListener(final SessionTextInput.EditableListener newListener) {
+ if (DEBUG) {
+ // Called by SessionTextInput.
+ ThreadUtils.assertOnUiThread();
+ Log.d(LOGTAG, "setListener " + newListener);
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onViewChange (set listener)");
+ }
+
+ mListener = newListener;
+ }
+ });
+ }
+
+ private boolean onIcThread() {
+ return mIcRunHandler.getLooper() == Looper.myLooper();
+ }
+
+ private void assertOnIcThread() {
+ ThreadUtils.assertOnThread(mIcRunHandler.getLooper().getThread(), AssertBehavior.THROW);
+ }
+
+ private Object getField(final Object obj, final String field, final Object def) {
+ try {
+ return obj.getClass().getField(field).get(obj);
+ } catch (final Exception e) {
+ return def;
+ }
+ }
+
+ // Flags for icMaybeSendComposition
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SEND_COMPOSITION_USE_ENTIRE_TEXT,
+ SEND_COMPOSITION_NOTIFY_GECKO,
+ SEND_COMPOSITION_KEEP_CURRENT
+ })
+ public @interface CompositionFlags {}
+
+ // If text has composing spans, treat the entire text as a Gecko composition,
+ // instead of just the spanned part.
+ private static final int SEND_COMPOSITION_USE_ENTIRE_TEXT = 1 << 0;
+ // Notify Gecko of the new composition ranges;
+ // otherwise, the caller is responsible for notifying Gecko.
+ private static final int SEND_COMPOSITION_NOTIFY_GECKO = 1 << 1;
+ // Keep the current composition when updating;
+ // composition is not updated if there is no current composition.
+ private static final int SEND_COMPOSITION_KEEP_CURRENT = 1 << 2;
+
+ /**
+ * Send composition ranges to Gecko if the text has composing spans.
+ *
+ * @param sequence Text with possible composing spans
+ * @param flags Bitmask of SEND_COMPOSITION_* flags for updating composition.
+ * @return Whether there was a composition
+ */
+ private boolean icMaybeSendComposition(
+ final CharSequence sequence, @CompositionFlags final int flags) throws RemoteException {
+ final boolean useEntireText = (flags & SEND_COMPOSITION_USE_ENTIRE_TEXT) != 0;
+ final boolean notifyGecko = (flags & SEND_COMPOSITION_NOTIFY_GECKO) != 0;
+ final boolean keepCurrent = (flags & SEND_COMPOSITION_KEEP_CURRENT) != 0;
+ final int updateFlags = keepCurrent ? GeckoEditableChild.FLAG_KEEP_CURRENT_COMPOSITION : 0;
+
+ if (!keepCurrent) {
+ // If keepCurrent is true, the composition may not actually be updated;
+ // so we may still need to update the composition in the future.
+ mNeedUpdateComposition = false;
+ }
+
+ int selStart = Selection.getSelectionStart(sequence);
+ int selEnd = Selection.getSelectionEnd(sequence);
+
+ if (sequence instanceof Spanned) {
+ final Spanned text = (Spanned) sequence;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ boolean found = false;
+ int composingStart = useEntireText ? 0 : Integer.MAX_VALUE;
+ int composingEnd = useEntireText ? text.length() : 0;
+
+ // Find existence and range of any composing spans (spans with the
+ // SPAN_COMPOSING flag set).
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) == 0) {
+ continue;
+ }
+ found = true;
+ if (useEntireText) {
+ break;
+ }
+ composingStart = Math.min(composingStart, text.getSpanStart(span));
+ composingEnd = Math.max(composingEnd, text.getSpanEnd(span));
+ }
+
+ if (useEntireText && (selStart < 0 || selEnd < 0)) {
+ selStart = composingEnd;
+ selEnd = composingEnd;
+ }
+
+ if (found) {
+ if (selStart < composingStart || selEnd > composingEnd) {
+ // GBoard will set caret position that is out of composing
+ // range. Unfortunately, Gecko doesn't support this caret
+ // position. So we shouldn't set composing range data now.
+ // But this is temporary composing range, then GBoard will
+ // set valid range soon.
+ if (DEBUG) {
+ final StringBuilder sb =
+ new StringBuilder("icSendComposition(): invalid caret position. ");
+ sb.append("composing = ")
+ .append(composingStart)
+ .append("-")
+ .append(composingEnd)
+ .append(", selection = ")
+ .append(selStart)
+ .append("-")
+ .append(selEnd);
+ Log.d(LOGTAG, sb.toString());
+ }
+ } else {
+ icSendComposition(text, selStart, selEnd, composingStart, composingEnd);
+ if (notifyGecko) {
+ mFocusedChild.onImeUpdateComposition(composingStart, composingEnd, updateFlags);
+ }
+ return true;
+ }
+ }
+ }
+
+ if (notifyGecko) {
+ // Set the selection by using a composition without ranges.
+ final Spanned currentText = mText.getCurrentText();
+ if (Selection.getSelectionStart(currentText) != selStart
+ || Selection.getSelectionEnd(currentText) != selEnd) {
+ // Gecko's selection is different of requested selection, so
+ // we have to set selection of Gecko side.
+ // If selection is same, it is unnecessary to update it.
+ // This may be race with Gecko's updating selection via
+ // JavaScript or keyboard event. But we don't know whether
+ // Gecko is during updating selection.
+ mFocusedChild.onImeUpdateComposition(selStart, selEnd, updateFlags);
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "icSendComposition(): no composition");
+ }
+ return false;
+ }
+
+ private void icSendComposition(
+ final Spanned text,
+ final int selStart,
+ final int selEnd,
+ final int composingStart,
+ final int composingEnd)
+ throws RemoteException {
+ if (DEBUG) {
+ assertOnIcThread();
+ final StringBuilder sb = new StringBuilder("icSendComposition(");
+ sb.append("\"")
+ .append(text)
+ .append("\"")
+ .append(", range = ")
+ .append(composingStart)
+ .append("-")
+ .append(composingEnd)
+ .append(", selection = ")
+ .append(selStart)
+ .append("-")
+ .append(selEnd)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (selEnd >= composingStart && selEnd <= composingEnd) {
+ mFocusedChild.onImeAddCompositionRange(
+ selEnd - composingStart,
+ selEnd - composingStart,
+ IME_RANGE_CARETPOSITION,
+ 0,
+ 0,
+ false,
+ 0,
+ 0,
+ 0);
+ }
+
+ int rangeStart = composingStart;
+ final TextPaint tp = new TextPaint();
+ final TextPaint emptyTp = new TextPaint();
+ // set initial foreground color to 0, because we check for tp.getColor() == 0
+ // below to decide whether to pass a foreground color to Gecko
+ emptyTp.setColor(0);
+ do {
+ final int rangeType;
+ int rangeStyles = 0;
+ int rangeLineStyle = IME_RANGE_LINE_NONE;
+ boolean rangeBoldLine = false;
+ int rangeForeColor = 0, rangeBackColor = 0, rangeLineColor = 0;
+ int rangeEnd = text.nextSpanTransition(rangeStart, composingEnd, Object.class);
+
+ if (selStart > rangeStart && selStart < rangeEnd) {
+ rangeEnd = selStart;
+ } else if (selEnd > rangeStart && selEnd < rangeEnd) {
+ rangeEnd = selEnd;
+ }
+ final CharacterStyle[] styleSpans = text.getSpans(rangeStart, rangeEnd, CharacterStyle.class);
+
+ if (DEBUG) {
+ Log.d(LOGTAG, " found " + styleSpans.length + " spans @ " + rangeStart + "-" + rangeEnd);
+ }
+
+ if (styleSpans.length == 0) {
+ rangeType =
+ (selStart == rangeStart && selEnd == rangeEnd)
+ ? IME_RANGE_SELECTEDRAWTEXT
+ : IME_RANGE_RAWINPUT;
+ } else {
+ rangeType =
+ (selStart == rangeStart && selEnd == rangeEnd)
+ ? IME_RANGE_SELECTEDCONVERTEDTEXT
+ : IME_RANGE_CONVERTEDTEXT;
+ tp.set(emptyTp);
+ for (final CharacterStyle span : styleSpans) {
+ span.updateDrawState(tp);
+ }
+ int tpUnderlineColor = 0;
+ float tpUnderlineThickness = 0.0f;
+
+ // These TextPaint fields only exist on Android ICS+ and are not in the SDK.
+ tpUnderlineColor = (Integer) getField(tp, "underlineColor", 0);
+ tpUnderlineThickness = (Float) getField(tp, "underlineThickness", 0.0f);
+ if (tpUnderlineColor != 0) {
+ rangeStyles |= IME_RANGE_UNDERLINE | IME_RANGE_LINECOLOR;
+ rangeLineColor = tpUnderlineColor;
+ // Approximately translate underline thickness to what Gecko understands
+ if (tpUnderlineThickness <= 0.5f) {
+ rangeLineStyle = IME_RANGE_LINE_DOTTED;
+ } else {
+ rangeLineStyle = IME_RANGE_LINE_SOLID;
+ if (tpUnderlineThickness >= 2.0f) {
+ rangeBoldLine = true;
+ }
+ }
+ } else if (tp.isUnderlineText()) {
+ rangeStyles |= IME_RANGE_UNDERLINE;
+ rangeLineStyle = IME_RANGE_LINE_SOLID;
+ }
+ if (tp.getColor() != 0) {
+ rangeStyles |= IME_RANGE_FORECOLOR;
+ rangeForeColor = tp.getColor();
+ }
+ if (tp.bgColor != 0) {
+ rangeStyles |= IME_RANGE_BACKCOLOR;
+ rangeBackColor = tp.bgColor;
+ }
+ }
+ mFocusedChild.onImeAddCompositionRange(
+ rangeStart - composingStart,
+ rangeEnd - composingStart,
+ rangeType,
+ rangeStyles,
+ rangeLineStyle,
+ rangeBoldLine,
+ rangeForeColor,
+ rangeBackColor,
+ rangeLineColor);
+ rangeStart = rangeEnd;
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ " added "
+ + rangeType
+ + " : "
+ + Integer.toHexString(rangeStyles)
+ + " : "
+ + Integer.toHexString(rangeForeColor)
+ + " : "
+ + Integer.toHexString(rangeBackColor));
+ }
+ } while (rangeStart < composingEnd);
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void sendKeyEvent(
+ final @Nullable View view, final int action, final @NonNull KeyEvent event) {
+ final Editable editable = mProxy;
+ final KeyListener keyListener = TextKeyListener.getInstance();
+ final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event);
+
+ // We only let TextKeyListener do UI things on the UI thread.
+ final View v = ThreadUtils.isOnUiThread() ? view : null;
+ final int keyCode = translatedEvent.getKeyCode();
+ final boolean handled;
+
+ if (shouldSkipKeyListener(keyCode, translatedEvent)) {
+ handled = false;
+ } else if (action == KeyEvent.ACTION_DOWN) {
+ setSuppressKeyUp(true);
+ handled = keyListener.onKeyDown(v, editable, keyCode, translatedEvent);
+ } else if (action == KeyEvent.ACTION_UP) {
+ handled = keyListener.onKeyUp(v, editable, keyCode, translatedEvent);
+ } else {
+ handled = keyListener.onKeyOther(v, editable, translatedEvent);
+ }
+
+ if (!handled) {
+ sendKeyEvent(translatedEvent, action, TextKeyListener.getMetaState(editable));
+ }
+
+ if (action == KeyEvent.ACTION_DOWN) {
+ if (!handled) {
+ // Usually, the down key listener call above adjusts meta states for us.
+ // However, if the call didn't handle the event, we have to manually
+ // adjust meta states so the meta states remain consistent.
+ TextKeyListener.adjustMetaAfterKeypress(editable);
+ }
+ setSuppressKeyUp(false);
+ }
+ }
+
+ private void sendKeyEvent(final @NonNull KeyEvent event, final int action, final int metaState) {
+ if (DEBUG) {
+ assertOnIcThread();
+ Log.d(LOGTAG, "sendKeyEvent(" + event + ", " + action + ", " + metaState + ")");
+ }
+ /*
+ We are actually sending two events to Gecko here,
+ 1. Event from the event parameter (key event)
+ 2. Sync event from the icOfferAction call
+ The first event is a normal event that does not reply back to us,
+ the second sync event will have a reply, during which we see that there is a pending
+ event-type action, and update the shadow text accordingly.
+ */
+ try {
+ if (mFocusedChild == null) {
+ if (mDefaultChild == null) {
+ Log.w(LOGTAG, "Discarding key event");
+ return;
+ }
+ // Not focused; send simple key event to chrome window.
+ onKeyEvent(mDefaultChild, event, action, metaState, /* isSynthesizedImeKey */ false);
+ return;
+ }
+
+ // Most IMEs handle arrow key, then set caret position. But GBoard
+ // doesn't handle it. GBoard will dispatch KeyEvent for arrow left/right
+ // even if having IME composition.
+ // Since Gecko doesn't dispatch keypress during IME composition due to
+ // DOM UI events spec, we have to emulate arrow key's behaviour.
+ boolean commitCompositionBeforeKeyEvent = action == KeyEvent.ACTION_DOWN;
+ if (isComposing(mText.getShadowText())
+ && action == KeyEvent.ACTION_DOWN
+ && event.hasNoModifiers()) {
+ final int selStart = Selection.getSelectionStart(mText.getShadowText());
+ final int selEnd = Selection.getSelectionEnd(mText.getShadowText());
+ if (selStart == selEnd) {
+ // If dispatching arrow left/right key into composition,
+ // we update IME caret.
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ if (getComposingStart(mText.getShadowText()) < selStart) {
+ Selection.setSelection(getEditable(), selStart - 1, selStart - 1);
+ mNeedUpdateComposition = true;
+ commitCompositionBeforeKeyEvent = false;
+ } else if (selStart == 0) {
+ // Keep current composition
+ commitCompositionBeforeKeyEvent = false;
+ }
+ break;
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ if (getComposingEnd(mText.getShadowText()) > selEnd) {
+ Selection.setSelection(getEditable(), selStart + 1, selStart + 1);
+ mNeedUpdateComposition = true;
+ commitCompositionBeforeKeyEvent = false;
+ } else if (selEnd == mText.getShadowText().length()) {
+ // Keep current composition
+ commitCompositionBeforeKeyEvent = false;
+ }
+ break;
+ }
+ }
+ }
+
+ // Focused; key event may go to chrome window or to content window.
+ if (mNeedUpdateComposition) {
+ icMaybeSendComposition(mText.getShadowText(), SEND_COMPOSITION_NOTIFY_GECKO);
+ }
+
+ if (commitCompositionBeforeKeyEvent) {
+ mFocusedChild.onImeRequestCommit();
+ }
+ onKeyEvent(mFocusedChild, event, action, metaState, /* isSynthesizedImeKey */ false);
+ icOfferAction(new Action(Action.TYPE_EVENT));
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ private boolean shouldSkipKeyListener(final int keyCode, final @NonNull KeyEvent event) {
+ if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ return true;
+ }
+
+ // Preserve enter and tab keys for the browser
+ if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_TAB) {
+ return true;
+ }
+ // BaseKeyListener returns false even if it handled these keys for us,
+ // so we skip the key listener entirely and handle these ourselves
+ return keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL;
+ }
+
+ private static KeyEvent translateSonyXperiaGamepadKeys(final int keyCode, final KeyEvent event) {
+ // The cross and circle button mappings may be swapped in the different regions so
+ // determine if they are swapped so the proper key codes can be mapped to the keys
+ final boolean areKeysSwapped = areSonyXperiaGamepadKeysSwapped();
+
+ int translatedKeyCode = keyCode;
+ // If a Sony Xperia, remap the cross and circle buttons to buttons
+ // A and B for the gamepad API
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BACK:
+ translatedKeyCode =
+ (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_A : KeyEvent.KEYCODE_BUTTON_B);
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ translatedKeyCode =
+ (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_B : KeyEvent.KEYCODE_BUTTON_A);
+ break;
+
+ default:
+ return event;
+ }
+
+ return new KeyEvent(event.getAction(), translatedKeyCode);
+ }
+
+ private static final int SONY_XPERIA_GAMEPAD_DEVICE_ID = 196611;
+
+ private static boolean isSonyXperiaGamepadKeyEvent(final KeyEvent event) {
+ return (event.getDeviceId() == SONY_XPERIA_GAMEPAD_DEVICE_ID
+ && "Sony Ericsson".equals(Build.MANUFACTURER)
+ && ("R800".equals(Build.MODEL) || "R800i".equals(Build.MODEL)));
+ }
+
+ private static boolean areSonyXperiaGamepadKeysSwapped() {
+ // The cross and circle buttons on Sony Xperia phones are swapped
+ // in different regions
+ // http://developer.sonymobile.com/2011/02/13/xperia-play-game-keys/
+ final char DEFAULT_O_BUTTON_LABEL = 0x25CB;
+
+ boolean swapped = false;
+ final int[] deviceIds = InputDevice.getDeviceIds();
+
+ for (int i = 0; deviceIds != null && i < deviceIds.length; i++) {
+ final KeyCharacterMap keyCharacterMap = KeyCharacterMap.load(deviceIds[i]);
+ if (keyCharacterMap != null
+ && DEFAULT_O_BUTTON_LABEL
+ == keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_DPAD_CENTER)) {
+ swapped = true;
+ break;
+ }
+ }
+ return swapped;
+ }
+
+ private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) {
+ if (isSonyXperiaGamepadKeyEvent(event)) {
+ return translateSonyXperiaGamepadKeys(keyCode, event);
+ }
+ return event;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public Editable getEditable() {
+ if (!onIcThread()) {
+ // Android may be holding an old InputConnection; ignore
+ if (DEBUG) {
+ Log.i(LOGTAG, "getEditable() called on non-IC thread");
+ }
+ return null;
+ }
+ if (mListener == null) {
+ // We haven't initialized or we've been destroyed.
+ return null;
+ }
+ return mProxy;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void setBatchMode(final boolean inBatchMode) {
+ if (!onIcThread()) {
+ // Android may be holding an old InputConnection; ignore
+ if (DEBUG) {
+ Log.i(LOGTAG, "setBatchMode() called on non-IC thread");
+ }
+ return;
+ }
+
+ mInBatchMode = inBatchMode;
+
+ if (!inBatchMode && mFocusedChild != null) {
+ // We may not commit composition on Gecko even if Java side has
+ // no composition. So we have to sync composition state with Gecko
+ // when batch edit is done.
+ //
+ // i.e. Although finishComposingText removes composing span, we
+ // don't commit current composition yet.
+ final Editable editable = getEditable();
+ if (editable != null && !isComposing(editable)) {
+ try {
+ mFocusedChild.onImeRequestCommit();
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+ // Committing composition doesn't change text, so we can sync shadow text.
+ }
+
+ if (!inBatchMode && mNeedSync) {
+ icSyncShadowText();
+ }
+ }
+
+ /* package */ void icSyncShadowText() {
+ if (mListener == null) {
+ // Not yet attached or already destroyed.
+ return;
+ }
+
+ if (mInBatchMode || !mActions.isEmpty()) {
+ mNeedSync = true;
+ return;
+ }
+
+ mNeedSync = false;
+ mText.syncShadowText(mListener);
+ }
+
+ private void setSuppressKeyUp(final boolean suppress) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ // Suppress key up event generated as a result of
+ // translating characters to key events
+ mSuppressKeyUp = suppress;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public Handler setInputConnectionHandler(final Handler handler) {
+ if (handler == mIcRunHandler) {
+ return mIcRunHandler;
+ }
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ // There are three threads at this point: Gecko thread, old IC thread, and new IC
+ // thread, and we want to safely switch from old IC thread to new IC thread.
+ // We first send a TYPE_SET_HANDLER action to the Gecko thread; this ensures that
+ // the Gecko thread is stopped at a known point. At the same time, the old IC
+ // thread blocks on the action; this ensures that the old IC thread is stopped at
+ // a known point. Finally, inside the Gecko thread, we post a Runnable to the old
+ // IC thread; this Runnable switches from old IC thread to new IC thread. We
+ // switch IC thread on the old IC thread to ensure any pending Runnables on the
+ // old IC thread are processed before we switch over. Inside the Gecko thread, we
+ // also post a Runnable to the new IC thread; this Runnable blocks until the
+ // switch is complete; this ensures that the new IC thread won't accept
+ // InputConnection calls until after the switch.
+
+ handler.post(
+ new Runnable() { // Make the new IC thread wait.
+ @Override
+ public void run() {
+ synchronized (handler) {
+ while (mIcRunHandler != handler) {
+ try {
+ handler.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ }
+ }
+ });
+
+ icOfferAction(Action.newSetHandler(handler));
+ return handler;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void postToInputConnection(final Runnable runnable) {
+ mIcPostHandler.post(runnable);
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void requestCursorUpdates(@CursorMonitorMode final int requestMode) {
+ try {
+ if (mFocusedChild != null) {
+ mFocusedChild.onImeRequestCursorUpdates(requestMode);
+ }
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void insertImage(final @NonNull byte[] data, final @NonNull String mimeType) {
+ if (mFocusedChild == null) {
+ return;
+ }
+
+ try {
+ mFocusedChild.onImeInsertImage(data, mimeType);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call to insert image failed", e);
+ }
+ }
+
+ private void geckoSetIcHandler(final Handler newHandler) {
+ // On Gecko or binder thread.
+ mIcPostHandler.post(
+ new Runnable() { // posting to old IC thread
+ @Override
+ public void run() {
+ synchronized (newHandler) {
+ mIcRunHandler = newHandler;
+ newHandler.notify();
+ }
+ }
+ });
+
+ // At this point, all future Runnables should be posted to the new IC thread, but
+ // we don't switch mIcRunHandler yet because there may be pending Runnables on the
+ // old IC thread still waiting to run.
+ mIcPostHandler = newHandler;
+ }
+
+ private void geckoActionReply(final Action action) {
+ // On Gecko or binder thread.
+ if (action == null) {
+ Log.w(LOGTAG, "Mismatched reply");
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "reply: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")");
+ }
+ switch (action.mType) {
+ case Action.TYPE_REPLACE_TEXT:
+ {
+ final Spanned currentText = mText.getCurrentText();
+ final int actionNewEnd = action.mStart + action.mSequence.length();
+ if (mLastTextChangeStart > mLastTextChangeNewEnd
+ || mLastTextChangeNewEnd > currentText.length()
+ || action.mStart < mLastTextChangeStart
+ || actionNewEnd > mLastTextChangeNewEnd) {
+ // Replace-text action doesn't match our text change.
+ break;
+ }
+
+ int indexInText =
+ TextUtils.indexOf(
+ currentText, action.mSequence, action.mStart, mLastTextChangeNewEnd);
+ if (indexInText < 0 && action.mStart != mLastTextChangeStart) {
+ final String changedText =
+ TextUtils.substring(currentText, mLastTextChangeStart, actionNewEnd);
+ indexInText = changedText.lastIndexOf(action.mSequence.toString());
+ if (indexInText >= 0) {
+ indexInText += mLastTextChangeStart;
+ }
+ }
+ if (indexInText < 0) {
+ // Replace-text action doesn't match our current text.
+ break;
+ }
+
+ final int selStart = Selection.getSelectionStart(currentText);
+ final int selEnd = Selection.getSelectionEnd(currentText);
+
+ // Replace-text action matches our current text; copy the new spans to the
+ // current text.
+ mText.currentReplace(
+ indexInText, indexInText + action.mSequence.length(), action.mSequence);
+ // Make sure selection is preserved.
+ mText.currentSetSelection(selStart, selEnd);
+
+ // The text change is caused by the replace-text event. If the text change
+ // replaced the previous selection, we need to rely on Gecko for an updated
+ // selection, so don't ignore selection change. However, if the text change
+ // did not replace the previous selection, we can ignore the Gecko selection
+ // in favor of the Java selection.
+ mIgnoreSelectionChange = !mLastTextChangeReplacedSelection;
+ break;
+ }
+
+ case Action.TYPE_SET_SPAN:
+ final int len = mText.getCurrentText().length();
+ if (action.mStart > len
+ || action.mEnd > len
+ || !TextUtils.substring(mText.getCurrentText(), action.mStart, action.mEnd)
+ .equals(action.mSequence)) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "discarding stale set span call");
+ }
+ break;
+ }
+ if ((action.mSpanObject == Selection.SELECTION_START
+ || action.mSpanObject == Selection.SELECTION_END)
+ && (action.mStart < mLastTextChangeStart && action.mEnd < mLastTextChangeStart
+ || action.mStart > mLastTextChangeOldEnd && action.mEnd > mLastTextChangeOldEnd)) {
+ // Use the Java selection if, between text-change notification and replace-text
+ // processing, we specifically set the selection to outside the replaced range.
+ mLastTextChangeReplacedSelection = false;
+ }
+ mText.currentSetSpan(action.mSpanObject, action.mStart, action.mEnd, action.mSpanFlags);
+ break;
+
+ case Action.TYPE_REMOVE_SPAN:
+ mText.currentRemoveSpan(action.mSpanObject);
+ break;
+
+ case Action.TYPE_SET_HANDLER:
+ geckoSetIcHandler(action.mHandler);
+ break;
+ }
+ }
+
+ private synchronized boolean binderCheckToken(final IBinder token, final boolean allowNull) {
+ // Verify that we're getting an IME notification from the currently focused child.
+ if (mFocusedToken == token || (mFocusedToken == null && allowNull)) {
+ return true;
+ }
+ Log.w(LOGTAG, "Invalid token");
+ return false;
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIME(final IGeckoEditableChild child, @IMENotificationType final int type) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ // NOTIFY_IME_REPLY_EVENT is logged separately, inside geckoActionReply()
+ if (type != SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ Log.d(
+ LOGTAG,
+ "notifyIME("
+ + getConstantName(SessionTextInput.EditableListener.class, "NOTIFY_IME_", type)
+ + ")");
+ }
+ }
+
+ final IBinder token = child.asBinder();
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN) {
+ synchronized (this) {
+ if (mFocusedToken != null && mFocusedToken != token && mFocusedToken.pingBinder()) {
+ // Focused child already exists and is alive.
+ Log.w(LOGTAG, "Already focused");
+ return;
+ }
+ mFocusedToken = token;
+ return;
+ }
+ } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB) {
+ // Always from parent process.
+ ThreadUtils.assertOnGeckoThread();
+ } else if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR) {
+ synchronized (this) {
+ onTextChange(token, "", 0, Integer.MAX_VALUE, false);
+ mActions.clear();
+ mFocusedToken = null;
+ }
+ } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ geckoActionReply(mActions.poll());
+ if (!mActions.isEmpty()) {
+ // Only post to IC thread below when the queue is empty.
+ return;
+ }
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icNotifyIME(child, type);
+ }
+ });
+ }
+
+ /* package */ void icNotifyIME(
+ final IGeckoEditableChild child, @IMENotificationType final int type) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ if (mNeedSync) {
+ icSyncShadowText();
+ }
+ return;
+ }
+
+ switch (type) {
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_FOCUS:
+ if (mFocusedChild != null) {
+ // Already focused, so blur first.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ false);
+ }
+
+ mFocusedChild = child;
+ mNeedSync = false;
+ mText.syncShadowText(/* listener */ null);
+
+ // Most of the time notifyIMEContext comes _before_ notifyIME, but sometimes it
+ // comes _after_ notifyIME. In that case, the state is disabled here, and
+ // notifyIMEContext is responsible for calling restartInput.
+ if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ mIMEState = SessionTextInput.EditableListener.IME_STATE_UNKNOWN;
+ } else {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true);
+ }
+ break;
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR:
+ if (mFocusedChild != null) {
+ mFocusedChild = null;
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ true);
+ }
+ break;
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB:
+ toggleSoftInput(/* force */ true, mIMEState);
+ return; // Don't notify listener.
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_TO_COMMIT_COMPOSITION:
+ {
+ // Gecko already committed its composition. However, Android keyboards
+ // have trouble dealing with us removing the composition manually on the
+ // Java side. Therefore, we keep the composition intact on the Java side.
+ // The text content should still be in-sync on both sides.
+ //
+ // Nevertheless, if we somehow lost the composition, we must force the
+ // keyboard to reset.
+ if (isComposing(mText.getShadowText())) {
+ // Still have composition; no need to reset.
+ return; // Don't notify listener.
+ }
+ // No longer have composition; perform reset.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ false);
+ return; // Don't notify listener.
+ }
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN:
+ case SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT:
+ case SessionTextInput.EditableListener.NOTIFY_IME_TO_CANCEL_COMPOSITION:
+ default:
+ throw new IllegalArgumentException("Invalid notifyIME type: " + type);
+ }
+
+ if (mListener != null) {
+ mListener.notifyIME(type);
+ }
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIMEContext(
+ final IBinder token,
+ @IMEState final int state,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ @IMEContextFlags final int flags) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("notifyIMEContext(");
+ sb.append(getConstantName(SessionTextInput.EditableListener.class, "IME_STATE_", state))
+ .append(", type=\"")
+ .append(typeHint)
+ .append("\", inputmode=\"")
+ .append(modeHint)
+ .append("\", autocapitalize=\"")
+ .append(autocapitalize)
+ .append("\", flags=0x")
+ .append(Integer.toHexString(flags))
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ // Regular notifyIMEContext calls all come from the parent process (with the default child),
+ // so always allow calls from there. We can get additional notifyIMEContext calls during
+ // a session transfer; calls in those cases can come from child processes, and we must
+ // perform a token check in that situation.
+ if (token != mDefaultChild.asBinder() && !binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icNotifyIMEContext(state, typeHint, modeHint, actionHint, autocapitalize, flags);
+ }
+ });
+ }
+
+ /* package */ void icNotifyIMEContext(
+ @IMEState final int originalState,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ @IMEContextFlags final int flags) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ // For some input type we will use a widget to display the ui, for those we must not
+ // display the ime. We can display a widget for date and time types and, if the sdk version
+ // is 11 or greater, for datetime/month/week as well.
+ final int state;
+ if ((typeHint != null
+ && (typeHint.equalsIgnoreCase("date")
+ || typeHint.equalsIgnoreCase("time")
+ || typeHint.equalsIgnoreCase("month")
+ || typeHint.equalsIgnoreCase("week")
+ || typeHint.equalsIgnoreCase("datetime-local")))
+ || (modeHint != null && modeHint.equals("none"))) {
+ state = SessionTextInput.EditableListener.IME_STATE_DISABLED;
+ } else {
+ state = originalState;
+ }
+
+ final int oldState = mIMEState;
+ mIMEState = state;
+ mIMETypeHint = (typeHint == null) ? "" : typeHint;
+ mIMEModeHint = (modeHint == null) ? "" : modeHint;
+ mIMEActionHint = (actionHint == null) ? "" : actionHint;
+ mIMEAutocapitalize = (autocapitalize == null) ? "" : autocapitalize;
+ mIMEFlags = flags;
+
+ if (mListener != null) {
+ mListener.notifyIMEContext(state, typeHint, modeHint, actionHint, flags);
+ }
+
+ if (mFocusedChild == null) {
+ // We have no focus.
+ return;
+ }
+
+ if ((flags & SessionTextInput.EditableListener.IME_FOCUS_NOT_CHANGED) != 0) {
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("icNotifyIMEContext: ");
+ sb.append("focus isn't changed. oldState=")
+ .append(oldState)
+ .append(", newState=")
+ .append(state);
+ Log.d(LOGTAG, sb.toString());
+ }
+ if (((oldState == SessionTextInput.EditableListener.IME_STATE_ENABLED
+ || oldState == SessionTextInput.EditableListener.IME_STATE_PASSWORD)
+ && state == SessionTextInput.EditableListener.IME_STATE_DISABLED)
+ || (oldState == SessionTextInput.EditableListener.IME_STATE_DISABLED
+ && (state == SessionTextInput.EditableListener.IME_STATE_ENABLED
+ || state == SessionTextInput.EditableListener.IME_STATE_PASSWORD))) {
+ // Even if focus isn't changed, software keyboard state is changed.
+ // We have to show or dismiss it.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ true);
+ return;
+ }
+ }
+
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ // When focus is being lost, icNotifyIME with NOTIFY_IME_OF_BLUR
+ // will dismiss it.
+ // So ignore to control software keyboard at this time.
+ return;
+ }
+
+ // We changed state while focused. If the old state is unknown, it means this
+ // notifyIMEContext call came _after_ the notifyIME call, so we need to call
+ // restartInput(FOCUS) here (see comment in icNotifyIME). Otherwise, this change
+ // counts as a content change.
+ if (oldState == SessionTextInput.EditableListener.IME_STATE_UNKNOWN) {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true);
+ } else if (oldState != SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ false);
+ }
+ }
+
+ private void icRestartInput(
+ @GeckoSession.RestartReason final int reason, final boolean toggleSoftInput) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "restartInput(" + reason + ", " + toggleSoftInput + ')');
+ }
+
+ final GeckoSession session = mSession.get();
+ if (session != null) {
+ session.getTextInput().getDelegate().restartInput(session, reason);
+ }
+
+ if (!toggleSoftInput) {
+ return;
+ }
+ postToInputConnection(
+ new Runnable() {
+ @Override
+ public void run() {
+ int state = mIMEState;
+ if (reason == GeckoSession.TextInputDelegate.RESTART_REASON_BLUR
+ && mFocusedChild == null) {
+ // On blur, notifyIMEContext() is called after notifyIME(). Therefore,
+ // mIMEState is not up-to-date here and we need to override it.
+ state = SessionTextInput.EditableListener.IME_STATE_DISABLED;
+ }
+ toggleSoftInput(/* force */ false, state);
+ }
+ });
+ }
+ });
+ }
+
+ public void onCreateInputConnection(final EditorInfo outAttrs) {
+ final int state = mIMEState;
+ final String typeHint = mIMETypeHint;
+ final String modeHint = mIMEModeHint;
+ final String actionHint = mIMEActionHint;
+ final String autocapitalize = mIMEAutocapitalize;
+ final int flags = mIMEFlags;
+
+ // Some keyboards require us to fill out outAttrs even if we return null.
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE;
+ outAttrs.actionLabel = null;
+
+ if (modeHint.equals("none")) {
+ // inputmode=none hides VKB at force.
+ outAttrs.inputType = InputType.TYPE_NULL;
+ toggleSoftInput(/* force */ true, SessionTextInput.EditableListener.IME_STATE_DISABLED);
+ return;
+ }
+
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ outAttrs.inputType = InputType.TYPE_NULL;
+ toggleSoftInput(/* force */ false, state);
+ return;
+ }
+
+ // We give priority to typeHint so that content authors can't annoy
+ // users by doing dumb things like opening the numeric keyboard for
+ // an email form field.
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
+ if (state == SessionTextInput.EditableListener.IME_STATE_PASSWORD
+ || "password".equalsIgnoreCase(typeHint)) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_PASSWORD;
+ } else if (typeHint.equalsIgnoreCase("url") || modeHint.equals("mozAwesomebar")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_URI;
+ } else if (typeHint.equalsIgnoreCase("email")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ } else if (typeHint.equalsIgnoreCase("tel")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_PHONE;
+ } else if (typeHint.equalsIgnoreCase("number") || typeHint.equalsIgnoreCase("range")) {
+ outAttrs.inputType =
+ InputType.TYPE_CLASS_NUMBER
+ | InputType.TYPE_NUMBER_VARIATION_NORMAL
+ | InputType.TYPE_NUMBER_FLAG_DECIMAL;
+ } else {
+ // We look at modeHint
+ if (modeHint.equals("tel")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_PHONE;
+ } else if (modeHint.equals("url")) {
+ outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_URI;
+ } else if (modeHint.equals("email")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ } else if (modeHint.equals("numeric")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_NORMAL;
+ } else if (modeHint.equals("decimal")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL;
+ } else {
+ // TYPE_TEXT_FLAG_IME_MULTI_LINE flag makes the fullscreen IME line wrap
+ outAttrs.inputType |=
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE;
+ }
+ }
+
+ if (autocapitalize.equals("characters")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS;
+ } else if (autocapitalize.equals("none")) {
+ // not set anymore.
+ } else if (autocapitalize.equals("sentences")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
+ } else if (autocapitalize.equals("words")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_WORDS;
+ } else if (modeHint.length() == 0
+ && (outAttrs.inputType & InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE) != 0
+ && !typeHint.equalsIgnoreCase("text")) {
+ // auto-capitalized mode is the default for types other than text (bug 871884)
+ // except to password, url and email.
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
+ }
+
+ if (actionHint.equals("enter")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE;
+ } else if (actionHint.equals("go")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_GO;
+ } else if (actionHint.equals("done")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE;
+ } else if (actionHint.equals("next") || actionHint.equals("maybenext")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NEXT;
+ } else if (actionHint.equals("previous")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_PREVIOUS;
+ } else if (actionHint.equals("search") || typeHint.equals("search")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_SEARCH;
+ } else if (actionHint.equals("send")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_SEND;
+ } else if (actionHint.length() > 0) {
+ if (DEBUG) Log.w(LOGTAG, "Unexpected actionHint=\"" + actionHint + "\"");
+ outAttrs.actionLabel = actionHint;
+ }
+
+ if ((flags & SessionTextInput.EditableListener.IME_FLAG_PRIVATE_BROWSING) != 0) {
+ outAttrs.imeOptions |= InputMethods.IME_FLAG_NO_PERSONALIZED_LEARNING;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && typeHint.length() == 0) {
+ // contenteditable allows image insertion.
+ outAttrs.contentMimeTypes = new String[] {"image/gif", "image/jpeg", "image/png"};
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ final Spanned currentText = mText.getCurrentText();
+ outAttrs.initialSelStart = Selection.getSelectionStart(currentText);
+ outAttrs.initialSelEnd = Selection.getSelectionEnd(currentText);
+ outAttrs.setInitialSurroundingText(currentText);
+ }
+
+ toggleSoftInput(/* force */ false, state);
+ }
+
+ /* package */ void toggleSoftInput(final boolean force, final int state) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "toggleSoftInput");
+ }
+ // Can be called from UI or IC thread.
+ final int flags = mIMEFlags;
+
+ // There are three paths that toggleSoftInput() can be called:
+ // 1) through calling restartInput(), which then indirectly calls
+ // onCreateInputConnection() and then toggleSoftInput().
+ // 2) through calling toggleSoftInput() directly from restartInput().
+ // This path is the fallback in case 1) does not happen.
+ // 3) through a system-generated onCreateInputConnection() call when the activity
+ // is restored from background, which then calls toggleSoftInput().
+ // mSoftInputReentrancyGuard is needed to ensure that between the different paths,
+ // the soft input is only toggled exactly once.
+
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ try {
+ final int reentrancyGuard = mSoftInputReentrancyGuard.incrementAndGet();
+ final boolean isReentrant = reentrancyGuard > 1;
+
+ // When using Find In Page, we can still receive notifyIMEContext calls due to the
+ // selection changing when highlighting. However in this case we don't want to
+ // show/hide the keyboard because the find box has the focus and is taking input from
+ // the keyboard.
+ final GeckoSession session = mSession.get();
+
+ if (session == null) {
+ return;
+ }
+
+ final View view = session.getTextInput().getView();
+ final boolean isFocused = (view == null) || view.hasFocus();
+
+ final boolean isUserAction =
+ ((flags & SessionTextInput.EditableListener.IME_FLAG_USER_ACTION) != 0);
+
+ if (!force && (isReentrant || !isFocused || !isUserAction)) {
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "toggleSoftInput: no-op, reentrant="
+ + isReentrant
+ + ", focused="
+ + isFocused
+ + ", user="
+ + isUserAction);
+ }
+ return;
+ }
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ session.getTextInput().getDelegate().hideSoftInput(session);
+ return;
+ }
+ {
+ final GeckoBundle bundle = new GeckoBundle();
+ // This bit is subtle. We want to force-zoom to the input
+ // if we're _not_ force-showing the virtual keyboard.
+ //
+ // We only force-show the virtual keyboard as a result of
+ // something that _doesn't_ switch the focus, and we don't
+ // want to move the view out of the focused editor unless
+ // we _actually_ show toggle the keyboard.
+ bundle.putBoolean("force", !force);
+ session.getEventDispatcher().dispatch("GeckoView:ZoomToInput", bundle);
+ }
+ session.getTextInput().getDelegate().showSoftInput(session);
+ } finally {
+ mSoftInputReentrancyGuard.decrementAndGet();
+ }
+ }
+ });
+ }
+
+ @Override // IGeckoEditableParent
+ public void onSelectionChange(
+ final IBinder token, final int start, final int end, final boolean causedOnlyByComposition) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onSelectionChange(");
+ sb.append(start)
+ .append(", ")
+ .append(end)
+ .append(", ")
+ .append(causedOnlyByComposition)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (mIgnoreSelectionChange) {
+ mIgnoreSelectionChange = false;
+ } else {
+ mText.currentSetSelection(start, end);
+ }
+
+ // We receive selection change notification after receiving replies for pending
+ // events, so we can reset text change bounds at this point.
+ mLastTextChangeStart = Integer.MAX_VALUE;
+ mLastTextChangeOldEnd = -1;
+ mLastTextChangeNewEnd = -1;
+ mLastTextChangeReplacedSelection = false;
+
+ if (causedOnlyByComposition) {
+ // It is unnecessary to sync shadow text since this change is by composition from Java
+ // side.
+ return;
+ }
+
+ // It is ready to synchronize Java text with Gecko text when no more input events is
+ // dispatched.
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icSyncShadowText();
+ }
+ });
+ }
+
+ private boolean geckoIsSameText(final int start, final int oldEnd, final CharSequence newText) {
+ return oldEnd - start == newText.length()
+ && TextUtils.regionMatches(mText.getCurrentText(), start, newText, 0, oldEnd - start);
+ }
+
+ @Override // IGeckoEditableParent
+ public void onTextChange(
+ final IBinder token,
+ final CharSequence text,
+ final int start,
+ final int unboundedOldEnd,
+ final boolean causedOnlyByComposition) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onTextChange(");
+ debugAppend(sb, text)
+ .append(", ")
+ .append(start)
+ .append(", ")
+ .append(unboundedOldEnd)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (unboundedOldEnd >= Integer.MAX_VALUE / 2) {
+ // Integer.MAX_VALUE / 2 is a magic number to synchronize all.
+ // (See GeckoEditableSupport::FlushIMEText.)
+ // Previous text transactions are unnecessary now, so we have to ignore it.
+ mActions.clear();
+ }
+
+ final int currentLength = mText.getCurrentText().length();
+ final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd;
+ final int newEnd = start + text.length();
+
+ if (start == 0 && unboundedOldEnd > currentLength && !causedOnlyByComposition) {
+ // | oldEnd > currentLength | signals entire text is cleared (e.g. for
+ // newly-focused editors). Simply replace the text in that case; replace in
+ // two steps to properly clear composing spans that span the whole range.
+ mText.currentReplace(0, currentLength, "");
+ mText.currentReplace(0, 0, text);
+
+ // Don't ignore the next selection change because we are re-syncing with Gecko
+ mIgnoreSelectionChange = false;
+
+ mLastTextChangeStart = Integer.MAX_VALUE;
+ mLastTextChangeOldEnd = -1;
+ mLastTextChangeNewEnd = -1;
+ mLastTextChangeReplacedSelection = false;
+
+ } else if (!geckoIsSameText(start, oldEnd, text)) {
+ final Spanned currentText = mText.getCurrentText();
+ final int selStart = Selection.getSelectionStart(currentText);
+ final int selEnd = Selection.getSelectionEnd(currentText);
+
+ // True if the selection was in the middle of the replaced text; in that case
+ // we don't know where to place the selection after replacement, and must rely
+ // on the Gecko selection.
+ mLastTextChangeReplacedSelection |=
+ (selStart >= start && selStart <= oldEnd) || (selEnd >= start && selEnd <= oldEnd);
+
+ // Gecko side initiated the text change. Replace in two steps to properly
+ // clear composing spans that span the whole range.
+ mText.currentReplace(start, oldEnd, "");
+ mText.currentReplace(start, start, text);
+
+ mLastTextChangeStart = Math.min(start, mLastTextChangeStart);
+ mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd);
+ mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd);
+
+ } else {
+ // Nothing to do because the text is the same. This could happen when
+ // the composition is updated for example, in which case we want to keep the
+ // Java selection.
+ final Action action = mActions.peek();
+ mIgnoreSelectionChange =
+ mIgnoreSelectionChange
+ || (action != null
+ && (action.mType == Action.TYPE_REPLACE_TEXT
+ || action.mType == Action.TYPE_SET_SPAN
+ || action.mType == Action.TYPE_REMOVE_SPAN));
+
+ mLastTextChangeStart = Math.min(start, mLastTextChangeStart);
+ mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd);
+ mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd);
+ }
+
+ // onTextChange is always followed by onSelectionChange, so we let
+ // onSelectionChange schedule a shadow text sync.
+ }
+
+ @Override // IGeckoEditableParent
+ public void onDefaultKeyEvent(final IBinder token, final KeyEvent event) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onDefaultKeyEvent(");
+ sb.append("action=")
+ .append(event.getAction())
+ .append(", ")
+ .append("keyCode=")
+ .append(event.getKeyCode())
+ .append(", ")
+ .append("metaState=")
+ .append(event.getMetaState())
+ .append(", ")
+ .append("time=")
+ .append(event.getEventTime())
+ .append(", ")
+ .append("repeatCount=")
+ .append(event.getRepeatCount())
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ // Allow default key processing even if we're not focused.
+ if (!binderCheckToken(token, /* allowNull */ true)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mListener == null) {
+ return;
+ }
+ mListener.onDefaultKeyEvent(event);
+ }
+ });
+ }
+
+ @Override // IGeckoEditableParent
+ public void updateCompositionRects(
+ final IBinder token, final RectF[] rects, final RectF caretRect) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")");
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mListener == null) {
+ return;
+ }
+ mListener.updateCompositionRects(rects, caretRect);
+ }
+ });
+ }
+
+ // InvocationHandler interface
+
+ static String getConstantName(final Class<?> cls, final String prefix, final Object value) {
+ for (final Field fld : cls.getDeclaredFields()) {
+ try {
+ if (fld.getName().startsWith(prefix) && fld.get(null).equals(value)) {
+ return fld.getName();
+ }
+ } catch (final IllegalAccessException e) {
+ }
+ }
+ return String.valueOf(value);
+ }
+
+ private static String getPrintableChar(final char chr) {
+ if (chr >= 0x20 && chr <= 0x7e) {
+ return String.valueOf(chr);
+ } else if (chr == '\n') {
+ return "\u21b2";
+ }
+ return String.format("\\u%04x", (int) chr);
+ }
+
+ static StringBuilder debugAppend(final StringBuilder sb, final Object obj) {
+ if (obj == null) {
+ sb.append("null");
+ } else if (obj instanceof GeckoEditable) {
+ sb.append("GeckoEditable");
+ } else if (obj instanceof GeckoEditableChild) {
+ sb.append("GeckoEditableChild");
+ } else if (Proxy.isProxyClass(obj.getClass())) {
+ debugAppend(sb, Proxy.getInvocationHandler(obj));
+ } else if (obj instanceof Character) {
+ sb.append('\'').append(getPrintableChar((Character) obj)).append('\'');
+ } else if (obj instanceof CharSequence) {
+ final String str = obj.toString();
+ sb.append('"');
+ for (int i = 0; i < str.length(); i++) {
+ final char chr = str.charAt(i);
+ if (chr >= 0x20 && chr <= 0x7e) {
+ sb.append(chr);
+ } else {
+ sb.append(getPrintableChar(chr));
+ }
+ }
+ sb.append('"');
+ } else if (obj.getClass().isArray()) {
+ sb.append(obj.getClass().getComponentType().getSimpleName())
+ .append('[')
+ .append(Array.getLength(obj))
+ .append(']');
+ } else {
+ sb.append(obj);
+ }
+ return sb;
+ }
+
+ @Override
+ public Object invoke(final Object proxy, final Method method, final Object[] args)
+ throws Throwable {
+ final Object target;
+ final Class<?> methodInterface = method.getDeclaringClass();
+ if (DEBUG) {
+ // Editable methods should all be called from the IC thread
+ assertOnIcThread();
+ }
+ if (methodInterface == Editable.class
+ || methodInterface == Appendable.class
+ || methodInterface == Spannable.class) {
+ // Method alters the Editable; route calls to our implementation
+ target = this;
+ } else {
+ target = mText.getShadowText();
+ }
+
+ final Object ret = method.invoke(target, args);
+ if (DEBUG) {
+ final StringBuilder log = new StringBuilder(method.getName());
+ log.append("(");
+ if (args != null) {
+ for (final Object arg : args) {
+ debugAppend(log, arg).append(", ");
+ }
+ if (args.length > 0) {
+ log.setLength(log.length() - 2);
+ }
+ }
+ if (method.getReturnType().equals(Void.TYPE)) {
+ log.append(")");
+ } else {
+ debugAppend(log.append(") = "), ret);
+ }
+ Log.d(LOGTAG, log.toString());
+ }
+ return ret;
+ }
+
+ // Spannable interface
+
+ @Override
+ public void removeSpan(final Object what) {
+ if (what == null) {
+ return;
+ }
+
+ if (what == Selection.SELECTION_START || what == Selection.SELECTION_END) {
+ Log.w(LOGTAG, "selection removed with removeSpan()");
+ }
+
+ icOfferAction(Action.newRemoveSpan(what));
+ }
+
+ @Override
+ public void setSpan(final Object what, final int start, final int end, final int flags) {
+ icOfferAction(Action.newSetSpan(what, start, end, flags));
+ }
+
+ // Appendable interface
+
+ @Override
+ public Editable append(final CharSequence text) {
+ return replace(mProxy.length(), mProxy.length(), text, 0, text.length());
+ }
+
+ @Override
+ public Editable append(final CharSequence text, final int start, final int end) {
+ return replace(mProxy.length(), mProxy.length(), text, start, end);
+ }
+
+ @Override
+ public Editable append(final char text) {
+ return replace(mProxy.length(), mProxy.length(), String.valueOf(text), 0, 1);
+ }
+
+ // Editable interface
+
+ @Override
+ public InputFilter[] getFilters() {
+ return mFilters;
+ }
+
+ @Override
+ public void setFilters(final InputFilter[] filters) {
+ mFilters = filters;
+ }
+
+ @Override
+ public void clearSpans() {
+ /* XXX this clears the selection spans too,
+ but there is no way to clear the corresponding selection in Gecko */
+ Log.w(LOGTAG, "selection cleared with clearSpans()");
+ icOfferAction(Action.newRemoveSpan(/* what */ null));
+ }
+
+ @Override
+ public Editable replace(
+ final int st, final int en, final CharSequence source, final int start, final int end) {
+ CharSequence text = source;
+ if (start < 0 || start > end || end > text.length()) {
+ Log.e(
+ LOGTAG,
+ "invalid replace offsets: " + start + " to " + end + ", length: " + text.length());
+ throw new IllegalArgumentException("invalid replace offsets");
+ }
+ if (start != 0 || end != text.length()) {
+ text = text.subSequence(start, end);
+ }
+ if (mFilters != null) {
+ // Filter text before sending the request to Gecko
+ for (int i = 0; i < mFilters.length; ++i) {
+ final CharSequence cs = mFilters[i].filter(text, 0, text.length(), mProxy, st, en);
+ if (cs != null) {
+ text = cs;
+ }
+ }
+ }
+ if (text == source) {
+ // Always create a copy
+ text = new SpannableString(source);
+ }
+ icOfferAction(Action.newReplaceText(text, Math.min(st, en), Math.max(st, en)));
+ return mProxy;
+ }
+
+ @Override
+ public void clear() {
+ replace(0, mProxy.length(), "", 0, 0);
+ }
+
+ @Override
+ public Editable delete(final int st, final int en) {
+ return replace(st, en, "", 0, 0);
+ }
+
+ @Override
+ public Editable insert(final int where, final CharSequence text, final int start, final int end) {
+ return replace(where, where, text, start, end);
+ }
+
+ @Override
+ public Editable insert(final int where, final CharSequence text) {
+ return replace(where, where, text, 0, text.length());
+ }
+
+ @Override
+ public Editable replace(final int st, final int en, final CharSequence text) {
+ return replace(st, en, text, 0, text.length());
+ }
+
+ /* GetChars interface */
+
+ @Override
+ public void getChars(final int start, final int end, final char[] dest, final int destoff) {
+ /* overridden Editable interface methods in GeckoEditable must not be called directly
+ outside of GeckoEditable. Instead, the call must go through mProxy, which ensures
+ that Java is properly synchronized with Gecko */
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ /* Spanned interface */
+
+ @Override
+ public int getSpanEnd(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int getSpanFlags(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int getSpanStart(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public <T> T[] getSpans(final int start, final int end, final Class<T> type) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes") // nextSpanTransition uses raw Class in its Android declaration
+ public int nextSpanTransition(final int start, final int limit, final Class type) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ /* CharSequence interface */
+
+ @Override
+ public char charAt(final int index) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int length() {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public CharSequence subSequence(final int start, final int end) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public String toString() {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ public boolean onKeyPreIme(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return false;
+ }
+
+ public boolean onKeyDown(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return processKey(view, KeyEvent.ACTION_DOWN, keyCode, event);
+ }
+
+ public boolean onKeyUp(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return processKey(view, KeyEvent.ACTION_UP, keyCode, event);
+ }
+
+ public boolean onKeyMultiple(
+ final @Nullable View view,
+ final int keyCode,
+ final int repeatCount,
+ final @NonNull KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
+ // KEYCODE_UNKNOWN means the characters are in KeyEvent.getCharacters()
+ final String str = event.getCharacters();
+ for (int i = 0; i < str.length(); i++) {
+ final KeyEvent charEvent = getCharKeyEvent(str.charAt(i));
+ if (!processKey(view, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, charEvent)
+ || !processKey(view, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, charEvent)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ for (int i = 0; i < repeatCount; i++) {
+ if (!processKey(view, KeyEvent.ACTION_DOWN, keyCode, event)
+ || !processKey(view, KeyEvent.ACTION_UP, keyCode, event)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public boolean onKeyLongPress(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return false;
+ }
+
+ /** Get a key that represents a given character. */
+ private static KeyEvent getCharKeyEvent(final char c) {
+ final long time = SystemClock.uptimeMillis();
+ return new KeyEvent(
+ time, time, KeyEvent.ACTION_MULTIPLE, KeyEvent.KEYCODE_UNKNOWN, /* repeat */ 0) {
+ @Override
+ public int getUnicodeChar() {
+ return c;
+ }
+
+ @Override
+ public int getUnicodeChar(final int metaState) {
+ return c;
+ }
+ };
+ }
+
+ private boolean processKey(
+ final @Nullable View view,
+ final int action,
+ final int keyCode,
+ final @NonNull KeyEvent event) {
+ if (keyCode > KeyEvent.getMaxKeyCode() || !shouldProcessKey(keyCode, event)) {
+ return false;
+ }
+
+ postToInputConnection(
+ new Runnable() {
+ @Override
+ public void run() {
+ sendKeyEvent(view, action, event);
+ }
+ });
+ return true;
+ }
+
+ private static boolean shouldProcessKey(final int keyCode, final KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_MENU:
+ case KeyEvent.KEYCODE_BACK:
+ case KeyEvent.KEYCODE_VOLUME_UP:
+ case KeyEvent.KEYCODE_VOLUME_DOWN:
+ case KeyEvent.KEYCODE_SEARCH:
+ // ignore HEADSETHOOK to allow hold-for-voice-search to work
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ return false;
+ }
+ return true;
+ }
+
+ private static boolean isComposing(final Spanned text) {
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static int getComposingStart(final Spanned text) {
+ int composingStart = Integer.MAX_VALUE;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ composingStart = Math.min(composingStart, text.getSpanStart(span));
+ }
+ }
+
+ return composingStart;
+ }
+
+ private static int getComposingEnd(final Spanned text) {
+ int composingEnd = -1;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ composingEnd = Math.max(composingEnd, text.getSpanEnd(span));
+ }
+ }
+
+ return composingEnd;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java
new file mode 100644
index 0000000000..ec53d2803a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java
@@ -0,0 +1,172 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.provider.Settings;
+import android.util.Log;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * A class that automatically adjusts font size settings for web content in Gecko in accordance with
+ * the device's OS font scale setting.
+ *
+ * @see android.provider.Settings.System#FONT_SCALE
+ */
+/* package */ final class GeckoFontScaleListener extends ContentObserver {
+ private static final String LOGTAG = "GeckoFontScaleListener";
+
+ private static final float DEFAULT_FONT_SCALE = 1.0f;
+
+ // We're referencing the *application* context, so this is in fact okay.
+ @SuppressLint("StaticFieldLeak")
+ private static final GeckoFontScaleListener sInstance = new GeckoFontScaleListener();
+
+ private Context mApplicationContext;
+ private GeckoRuntimeSettings mSettings;
+
+ private boolean mAttached;
+ private boolean mEnabled;
+ private boolean mRunning;
+
+ private float mPrevGeckoFontScale;
+
+ public static GeckoFontScaleListener getInstance() {
+ return sInstance;
+ }
+
+ private GeckoFontScaleListener() {
+ // Ensure the ContentObserver callback runs on the UI thread.
+ super(ThreadUtils.getUiHandler());
+ }
+
+ /**
+ * Prepare the GeckoFontScaleListener for usage. If it has been previously enabled, it will now
+ * start actively working.
+ */
+ public void attachToContext(final Context context, final GeckoRuntimeSettings settings) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAttached) {
+ Log.w(LOGTAG, "Already attached!");
+ return;
+ }
+
+ mAttached = true;
+ mSettings = settings;
+ mApplicationContext = context.getApplicationContext();
+ onEnabledChange();
+ }
+
+ /**
+ * Detaches the context and also stops the GeckoFontScaleListener if it was previously enabled.
+ * This will also restore the previously used font size settings.
+ */
+ public void detachFromContext() {
+ ThreadUtils.assertOnUiThread();
+
+ if (!mAttached) {
+ Log.w(LOGTAG, "Already detached!");
+ return;
+ }
+
+ stop();
+ mApplicationContext = null;
+ mSettings = null;
+ mAttached = false;
+ }
+
+ /**
+ * Controls whether the GeckoFontScaleListener should automatically adjust font sizes for web
+ * content in Gecko. When disabling, this will restore the previously used font size settings.
+ *
+ * <p>This method can be called at any time, but the GeckoFontScaleListener won't start actively
+ * adjusting font sizes until it has been attached to a context.
+ *
+ * @param enabled True if automatic font size setting should be enabled.
+ */
+ public void setEnabled(final boolean enabled) {
+ ThreadUtils.assertOnUiThread();
+ mEnabled = enabled;
+ onEnabledChange();
+ }
+
+ /**
+ * Get whether the GeckoFontScaleListener is currently enabled.
+ *
+ * @return True if the GeckoFontScaleListener is currently enabled.
+ */
+ public boolean getEnabled() {
+ return mEnabled;
+ }
+
+ private void onEnabledChange() {
+ if (!mAttached) {
+ return;
+ }
+
+ if (mEnabled) {
+ start();
+ } else {
+ stop();
+ }
+ }
+
+ private void start() {
+ if (mRunning) {
+ return;
+ }
+
+ mPrevGeckoFontScale = mSettings.getFontSizeFactor();
+ final ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ final Uri fontSizeSetting = Settings.System.getUriFor(Settings.System.FONT_SCALE);
+ contentResolver.registerContentObserver(fontSizeSetting, false, this);
+ onSystemFontScaleChange(contentResolver, false);
+
+ mRunning = true;
+ }
+
+ private void stop() {
+ if (!mRunning) {
+ return;
+ }
+
+ final ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ contentResolver.unregisterContentObserver(this);
+ onSystemFontScaleChange(contentResolver, /*stopping*/ true);
+
+ mRunning = false;
+ }
+
+ private void onSystemFontScaleChange(
+ final ContentResolver contentResolver, final boolean stopping) {
+ float fontScale;
+
+ if (!stopping) { // Either we were enabled, or else the system font scale changed.
+ fontScale =
+ Settings.System.getFloat(contentResolver, Settings.System.FONT_SCALE, DEFAULT_FONT_SCALE);
+ // Older Android versions don't sanitize the FONT_SCALE value. See Bug 1656078.
+ if (fontScale < 0) {
+ fontScale = DEFAULT_FONT_SCALE;
+ }
+ } else { // We were turned off.
+ fontScale = mPrevGeckoFontScale;
+ }
+
+ mSettings.setFontSizeFactorInternal(fontScale);
+ }
+
+ @UiThread // See constructor.
+ @Override
+ public void onChange(final boolean selfChange) {
+ onSystemFontScaleChange(mApplicationContext.getContentResolver(), false);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java
new file mode 100644
index 0000000000..5426adb501
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java
@@ -0,0 +1,819 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Matrix;
+import android.graphics.RectF;
+import android.media.AudioManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpannableString;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.CursorAnchorInfo;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputContentInfo;
+import androidx.annotation.NonNull;
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import org.mozilla.gecko.Clipboard;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/* package */ final class GeckoInputConnection extends BaseInputConnection
+ implements SessionTextInput.InputConnectionClient, SessionTextInput.EditableListener {
+
+ private static final boolean DEBUG = false;
+ protected static final String LOGTAG = "GeckoInputConnection";
+
+ private static final String CUSTOM_HANDLER_TEST_METHOD = "testInputConnection";
+ private static final String CUSTOM_HANDLER_TEST_CLASS =
+ "org.mozilla.gecko.tests.components.GeckoViewComponent$TextInput";
+
+ private static final int INLINE_IME_MIN_DISPLAY_SIZE = 480;
+
+ private static Handler sBackgroundHandler;
+
+ // Managed only by notifyIMEContext; see comments in notifyIMEContext
+ @IMEState private int mIMEState;
+ private String mIMEActionHint = "";
+ private int mLastSelectionStart;
+ private int mLastSelectionEnd;
+
+ private String mCurrentInputMethod = "";
+
+ private final GeckoSession mSession;
+ private final View mView;
+ private final SessionTextInput.EditableClient mEditableClient;
+ protected int mBatchEditCount;
+ private ExtractedTextRequest mUpdateRequest;
+ private final InputConnection mKeyInputConnection;
+ private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder;
+
+ public static SessionTextInput.InputConnectionClient create(
+ final GeckoSession session,
+ final View targetView,
+ final SessionTextInput.EditableClient editable) {
+ SessionTextInput.InputConnectionClient ic =
+ new GeckoInputConnection(session, targetView, editable);
+ if (DEBUG) {
+ ic = wrapForDebug(ic);
+ }
+ return ic;
+ }
+
+ private static SessionTextInput.InputConnectionClient wrapForDebug(
+ final SessionTextInput.InputConnectionClient ic) {
+ final InvocationHandler handler =
+ new InvocationHandler() {
+ private final StringBuilder mCallLevel = new StringBuilder();
+
+ @Override
+ public Object invoke(final Object proxy, final Method method, final Object[] args)
+ throws Throwable {
+ final StringBuilder log = new StringBuilder(mCallLevel);
+ log.append("> ").append(method.getName()).append("(");
+ if (args != null) {
+ for (int i = 0; i < args.length; i++) {
+ final Object arg = args[i];
+ // translate argument values to constant names
+ if ("notifyIME".equals(method.getName()) && i == 0) {
+ log.append(
+ GeckoEditable.getConstantName(
+ SessionTextInput.EditableListener.class, "NOTIFY_IME_", arg));
+ } else if ("notifyIMEContext".equals(method.getName()) && i == 0) {
+ log.append(
+ GeckoEditable.getConstantName(
+ SessionTextInput.EditableListener.class, "IME_STATE_", arg));
+ } else {
+ GeckoEditable.debugAppend(log, arg);
+ }
+ log.append(", ");
+ }
+ if (args.length > 0) {
+ log.setLength(log.length() - 2);
+ }
+ }
+ log.append(")");
+ Log.d(LOGTAG, log.toString());
+
+ mCallLevel.append(' ');
+ Object ret = method.invoke(ic, args);
+ if (ret == ic) {
+ ret = proxy;
+ }
+ mCallLevel.setLength(Math.max(0, mCallLevel.length() - 1));
+
+ log.setLength(mCallLevel.length());
+ log.append("< ").append(method.getName());
+ if (!method.getReturnType().equals(Void.TYPE)) {
+ GeckoEditable.debugAppend(log.append(": "), ret);
+ }
+ Log.d(LOGTAG, log.toString());
+ return ret;
+ }
+ };
+
+ return (SessionTextInput.InputConnectionClient)
+ Proxy.newProxyInstance(
+ GeckoInputConnection.class.getClassLoader(),
+ new Class<?>[] {
+ InputConnection.class,
+ SessionTextInput.InputConnectionClient.class,
+ SessionTextInput.EditableListener.class
+ },
+ handler);
+ }
+
+ protected GeckoInputConnection(
+ final GeckoSession session,
+ final View targetView,
+ final SessionTextInput.EditableClient editable) {
+ super(targetView, true);
+ mSession = session;
+ mView = targetView;
+ mEditableClient = editable;
+ mIMEState = IME_STATE_DISABLED;
+ // InputConnection that sends keys for plugins, which don't have full editors
+ mKeyInputConnection = new BaseInputConnection(targetView, false);
+ }
+
+ @Override
+ public synchronized boolean beginBatchEdit() {
+ mBatchEditCount++;
+ if (mBatchEditCount == 1) {
+ mEditableClient.setBatchMode(true);
+ }
+ return true;
+ }
+
+ @Override
+ public synchronized boolean endBatchEdit() {
+ if (mBatchEditCount <= 0) {
+ Log.w(LOGTAG, "endBatchEdit() called, but mBatchEditCount <= 0?!");
+ return true;
+ }
+
+ mBatchEditCount--;
+ if (mBatchEditCount != 0) {
+ return true;
+ }
+
+ // setBatchMode will call onTextChange and/or onSelectionChange for us.
+ mEditableClient.setBatchMode(false);
+ return true;
+ }
+
+ @Override
+ public Editable getEditable() {
+ return mEditableClient.getEditable();
+ }
+
+ @Override
+ public boolean performContextMenuAction(final int id) {
+ final View view = getView();
+ final Editable editable = getEditable();
+ if (view == null || editable == null) {
+ return false;
+ }
+ final int selStart = Selection.getSelectionStart(editable);
+ final int selEnd = Selection.getSelectionEnd(editable);
+
+ switch (id) {
+ case android.R.id.selectAll:
+ setSelection(0, editable.length());
+ break;
+ case android.R.id.cut:
+ // If selection is empty, we'll select everything
+ if (selStart == selEnd) {
+ // Fill the clipboard
+ Clipboard.setText(view.getContext(), editable);
+ editable.clear();
+ } else {
+ Clipboard.setText(
+ view.getContext(),
+ editable.subSequence(Math.min(selStart, selEnd), Math.max(selStart, selEnd)));
+ editable.delete(selStart, selEnd);
+ }
+ break;
+ case android.R.id.paste:
+ final String text = Clipboard.getText(view.getContext());
+ if (text != null) {
+ commitText(text, 1);
+ }
+ break;
+ case android.R.id.copy:
+ // Copy the current selection or the empty string if nothing is selected.
+ final String copiedText =
+ selStart == selEnd
+ ? ""
+ : editable
+ .toString()
+ .substring(Math.min(selStart, selEnd), Math.max(selStart, selEnd));
+ Clipboard.setText(view.getContext(), copiedText);
+ break;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean performEditorAction(final int editorAction) {
+ if (editorAction == EditorInfo.IME_ACTION_PREVIOUS && !mIMEActionHint.equals("previous")) {
+ // This action is [Previous] key on FireTV's keyboard.
+ // [Previous] closes software keyboard, and don't generate any keyboard event.
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().hideSoftInput(mSession);
+ }
+ });
+ return true;
+ }
+ return super.performEditorAction(editorAction);
+ }
+
+ @Override
+ public ExtractedText getExtractedText(final ExtractedTextRequest req, final int flags) {
+ if (req == null) return null;
+
+ if ((flags & GET_EXTRACTED_TEXT_MONITOR) != 0) mUpdateRequest = req;
+
+ final Editable editable = getEditable();
+ if (editable == null) {
+ return null;
+ }
+ final int selStart = Selection.getSelectionStart(editable);
+ final int selEnd = Selection.getSelectionEnd(editable);
+
+ final ExtractedText extract = new ExtractedText();
+ extract.flags = 0;
+ extract.partialStartOffset = -1;
+ extract.partialEndOffset = -1;
+ extract.selectionStart = selStart;
+ extract.selectionEnd = selEnd;
+ extract.startOffset = 0;
+ if ((req.flags & GET_TEXT_WITH_STYLES) != 0) {
+ extract.text = new SpannableString(editable);
+ } else {
+ extract.text = editable.toString();
+ }
+ return extract;
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public View getView() {
+ return mView;
+ }
+
+ @NonNull
+ /* package */ GeckoSession.TextInputDelegate getInputDelegate() {
+ return mSession.getTextInput().getDelegate();
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onTextChange() {
+ final Editable editable = getEditable();
+ if (mUpdateRequest == null || editable == null) {
+ return;
+ }
+
+ final ExtractedTextRequest request = mUpdateRequest;
+ final ExtractedText extractedText = new ExtractedText();
+ extractedText.flags = 0;
+ // Update the entire Editable range
+ extractedText.partialStartOffset = -1;
+ extractedText.partialEndOffset = -1;
+ extractedText.selectionStart = Selection.getSelectionStart(editable);
+ extractedText.selectionEnd = Selection.getSelectionEnd(editable);
+ extractedText.startOffset = 0;
+ if ((request.flags & GET_TEXT_WITH_STYLES) != 0) {
+ extractedText.text = new SpannableString(editable);
+ } else {
+ extractedText.text = editable.toString();
+ }
+
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().updateExtractedText(mSession, request, extractedText);
+ }
+ });
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onSelectionChange() {
+
+ final Editable editable = getEditable();
+ if (editable != null) {
+ mLastSelectionStart = Selection.getSelectionStart(editable);
+ mLastSelectionEnd = Selection.getSelectionEnd(editable);
+ notifySelectionChange(mLastSelectionStart, mLastSelectionEnd);
+ }
+ }
+
+ private void notifySelectionChange(final int start, final int end) {
+ final Editable editable = getEditable();
+ if (editable == null) {
+ return;
+ }
+
+ final int compositionStart = getComposingSpanStart(editable);
+ final int compositionEnd = getComposingSpanEnd(editable);
+
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate()
+ .updateSelection(mSession, start, end, compositionStart, compositionEnd);
+ }
+ });
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onDiscardComposition() {
+ final View view = getView();
+ if (view == null) {
+ return;
+ }
+
+ // InputMethodManager.updateSelection will remove composition
+ // on most IMEs. But ATOK series do nothing. So we have to
+ // restart input method to remove composition as workaround.
+ if (!InputMethods.needsRestartInput(InputMethods.getCurrentInputMethod(view.getContext()))) {
+ return;
+ }
+
+ view.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate()
+ .restartInput(
+ mSession, GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE);
+ }
+ });
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void updateCompositionRects(final RectF[] rects, final RectF caretRect) {
+ final View view = getView();
+ if (view == null) {
+ return;
+ }
+
+ final Editable content = getEditable();
+ if (content == null) {
+ return;
+ }
+
+ final int composingStart = getComposingSpanStart(content);
+ final int composingEnd = getComposingSpanEnd(content);
+ if (composingStart < 0 || composingEnd < 0) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "No composition for updates");
+ }
+ return;
+ }
+
+ final CharSequence composition = content.subSequence(composingStart, composingEnd);
+
+ view.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ updateCompositionRectsOnUi(view, rects, caretRect, composition);
+ }
+ });
+ }
+
+ /* package */ void updateCompositionRectsOnUi(
+ final View view, final RectF[] rects, final RectF caretRect, final CharSequence composition) {
+ if (mCursorAnchorInfoBuilder == null) {
+ mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder();
+ }
+ mCursorAnchorInfoBuilder.reset();
+
+ final Matrix matrix = new Matrix();
+ mSession.getClientToScreenOffsetMatrix(matrix);
+ mCursorAnchorInfoBuilder.setMatrix(matrix);
+
+ for (int i = 0; i < rects.length; i++) {
+ mCursorAnchorInfoBuilder.addCharacterBounds(
+ i,
+ rects[i].left,
+ rects[i].top,
+ rects[i].right,
+ rects[i].bottom,
+ CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION);
+ }
+
+ mCursorAnchorInfoBuilder.setComposingText(0, composition);
+
+ if (!caretRect.isEmpty()) {
+ // Gecko doesn't provide baseline information of caret.
+ mCursorAnchorInfoBuilder.setInsertionMarkerLocation(
+ caretRect.left,
+ caretRect.top,
+ caretRect.bottom,
+ caretRect.bottom,
+ CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION);
+ }
+
+ final CursorAnchorInfo info = mCursorAnchorInfoBuilder.build();
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().updateCursorAnchorInfo(mSession, info);
+ }
+ });
+ }
+
+ @Override
+ public boolean requestCursorUpdates(final int cursorUpdateMode) {
+
+ if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_IMMEDIATE) != 0) {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.ONE_SHOT);
+ }
+
+ if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_MONITOR) != 0) {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.START_MONITOR);
+ } else {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.END_MONITOR);
+ }
+ return true;
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onDefaultKeyEvent(final KeyEvent event) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ GeckoInputConnection.this.performDefaultKeyAction(event);
+ }
+ });
+ }
+
+ private static synchronized Handler getBackgroundHandler() {
+ if (sBackgroundHandler != null) {
+ return sBackgroundHandler;
+ }
+ // Don't use GeckoBackgroundThread because Gecko thread may block waiting on
+ // GeckoBackgroundThread. If we were to use GeckoBackgroundThread, due to IME,
+ // GeckoBackgroundThread may end up also block waiting on Gecko thread and a
+ // deadlock occurs
+ final Thread backgroundThread =
+ new Thread(
+ new Runnable() {
+ @Override
+ public void run() {
+ Looper.prepare();
+ synchronized (GeckoInputConnection.class) {
+ sBackgroundHandler = new Handler();
+ GeckoInputConnection.class.notify();
+ }
+ Looper.loop();
+ // We should never be exiting the thread loop.
+ throw new IllegalThreadStateException("unreachable code");
+ }
+ },
+ LOGTAG);
+ backgroundThread.setDaemon(true);
+ backgroundThread.start();
+ while (sBackgroundHandler == null) {
+ try {
+ // wait for new thread to set sBackgroundHandler
+ GeckoInputConnection.class.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ return sBackgroundHandler;
+ }
+
+ private synchronized boolean canReturnCustomHandler() {
+ if (mIMEState == IME_STATE_DISABLED) {
+ return false;
+ }
+ for (final StackTraceElement frame : Thread.currentThread().getStackTrace()) {
+ // We only return our custom Handler to InputMethodManager's InputConnection
+ // proxy. For all other purposes, we return the regular Handler.
+ // InputMethodManager retrieves the Handler for its InputConnection proxy
+ // inside its method startInputInner(), so we check for that here. This is
+ // valid from Android 2.2 to at least Android 4.2. If this situation ever
+ // changes, we gracefully fall back to using the regular Handler.
+ if ("startInputInner".equals(frame.getMethodName())
+ && "android.view.inputmethod.InputMethodManager".equals(frame.getClassName())) {
+ // Only return our own Handler to InputMethodManager and only prior to 24.
+ return Build.VERSION.SDK_INT < 24;
+ }
+ if (CUSTOM_HANDLER_TEST_METHOD.equals(frame.getMethodName())
+ && CUSTOM_HANDLER_TEST_CLASS.equals(frame.getClassName())) {
+ // InputConnection tests should also run on the custom handler
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isPhysicalKeyboardPresent() {
+ final View v = getView();
+ if (v == null) {
+ return false;
+ }
+ final Configuration config = v.getContext().getResources().getConfiguration();
+ return config.keyboard != Configuration.KEYBOARD_NOKEYS;
+ }
+
+ @Override // InputConnection
+ public Handler getHandler() {
+ final Handler handler;
+ if (isPhysicalKeyboardPresent()) {
+ handler = ThreadUtils.getUiHandler();
+ } else {
+ handler = getBackgroundHandler();
+ }
+ return mEditableClient.setInputConnectionHandler(handler);
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public Handler getHandler(final Handler defHandler) {
+ if (!canReturnCustomHandler()) {
+ return defHandler;
+ }
+
+ return getHandler();
+ }
+
+ @Override // InputConnection
+ public void closeConnection() {
+ if (mBatchEditCount != 0) {
+ // GBoard may call this into batch edit mode then it doesn't call endBatchEdit.
+ // Since we are recycle GeckoInputConnection, we have to reset
+ // batch count even if IME/keyboard bug.
+ if (DEBUG) {
+ Log.d(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount);
+ }
+ mBatchEditCount = 0;
+ // setBatchMode will call onTextChange and/or onSelectionChange for us.
+ mEditableClient.setBatchMode(false);
+ }
+ super.closeConnection();
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public synchronized InputConnection onCreateInputConnection(final EditorInfo outAttrs) {
+ if (mIMEState == IME_STATE_DISABLED) {
+ return null;
+ }
+
+ final Context context = getView().getContext();
+ final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ if (Math.min(metrics.widthPixels, metrics.heightPixels) > INLINE_IME_MIN_DISPLAY_SIZE) {
+ // prevent showing full-screen keyboard only when the screen is tall enough
+ // to show some reasonable amount of the page (see bug 752709)
+ outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN;
+ }
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "mapped IME states to: inputType = "
+ + Integer.toHexString(outAttrs.inputType)
+ + ", imeOptions = "
+ + Integer.toHexString(outAttrs.imeOptions));
+ }
+
+ final String prevInputMethod = mCurrentInputMethod;
+ mCurrentInputMethod = InputMethods.getCurrentInputMethod(context);
+ if (DEBUG) {
+ Log.d(LOGTAG, "IME: CurrentInputMethod=" + mCurrentInputMethod);
+ }
+
+ outAttrs.initialSelStart = mLastSelectionStart;
+ outAttrs.initialSelEnd = mLastSelectionEnd;
+ return this;
+ }
+
+ private boolean replaceComposingSpanWithSelection() {
+ final Editable content = getEditable();
+ if (content == null) {
+ return false;
+ }
+ final int a = getComposingSpanStart(content);
+ final int b = getComposingSpanEnd(content);
+ if (a != -1 && b != -1) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "removing composition at " + a + "-" + b);
+ }
+ removeComposingSpans(content);
+ Selection.setSelection(content, a, b);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean commitText(final CharSequence text, final int newCursorPosition) {
+ if (InputMethods.shouldCommitCharAsKey(mCurrentInputMethod)
+ && text.length() == 1
+ && newCursorPosition > 0) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "committing \"" + text + "\" as key");
+ }
+ // mKeyInputConnection is a BaseInputConnection that commits text as keys;
+ // but we first need to replace any composing span with a selection,
+ // so that the new key events will generate characters to replace
+ // text from the old composing span
+ return replaceComposingSpanWithSelection()
+ && mKeyInputConnection.commitText(text, newCursorPosition);
+ }
+ return super.commitText(text, newCursorPosition);
+ }
+
+ @Override
+ public boolean setSelection(final int start, final int end) {
+ if (start < 0 || end < 0) {
+ // Some keyboards (e.g. Samsung) can call setSelection with
+ // negative offsets. In that case we ignore the call, similar to how
+ // BaseInputConnection.setSelection ignores offsets that go past the length.
+ return true;
+ }
+ return super.setSelection(start, end);
+ }
+
+ @Override
+ public boolean sendKeyEvent(final @NonNull KeyEvent event) {
+ final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event);
+ mEditableClient.sendKeyEvent(getView(), event.getAction(), translatedEvent);
+ return false; // seems to always return false
+ }
+
+ private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_ENTER:
+ if ((event.getFlags() & KeyEvent.FLAG_EDITOR_ACTION) != 0
+ && mIMEActionHint.equals("maybenext")) {
+ // XXX It is not good to dispatch tab key for web compatibility.
+ // See https://github.com/w3c/uievents/issues/253 and bug 1600540.
+ return new KeyEvent(
+ event.getDownTime(),
+ event.getEventTime(),
+ event.getAction(),
+ KeyEvent.KEYCODE_TAB,
+ 0);
+ }
+ break;
+ }
+ return event;
+ }
+
+ // Called by OnDefaultKeyEvent handler, up from Gecko
+ /* package */ void performDefaultKeyAction(final KeyEvent event) {
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_MUTE:
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ case KeyEvent.KEYCODE_MEDIA_PLAY:
+ case KeyEvent.KEYCODE_MEDIA_PAUSE:
+ case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
+ case KeyEvent.KEYCODE_MEDIA_STOP:
+ case KeyEvent.KEYCODE_MEDIA_NEXT:
+ case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
+ case KeyEvent.KEYCODE_MEDIA_REWIND:
+ case KeyEvent.KEYCODE_MEDIA_RECORD:
+ case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
+ case KeyEvent.KEYCODE_MEDIA_CLOSE:
+ case KeyEvent.KEYCODE_MEDIA_EJECT:
+ case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK:
+ // Forward media keypresses to the registered handler so headset controls work
+ // Does the same thing as Chromium
+ // https://chromium.googlesource.com/chromium/src/+/49.0.2623.67/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsDelegateAndroid.java#445
+ // These are all the keys dispatchMediaKeyEvent supports.
+ final Context viewContext = getView().getContext();
+ final AudioManager am = (AudioManager) viewContext.getSystemService(Context.AUDIO_SERVICE);
+ am.dispatchMediaKeyEvent(event);
+ break;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.N_MR1)
+ @Override
+ public boolean commitContent(
+ final InputContentInfo inputContentInfo, final int flags, final Bundle opts) {
+ final boolean requestPermission =
+ ((flags & InputConnection.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0);
+ if (requestPermission) {
+ try {
+ inputContentInfo.requestPermission();
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "InputContentInfo.requestPermission() failed.", e);
+ return false;
+ }
+ }
+
+ try (final InputStream inputStream =
+ getView()
+ .getContext()
+ .getContentResolver()
+ .openInputStream(inputContentInfo.getContentUri());
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+ final byte[] data = new byte[4096];
+ int readed;
+ while ((readed = inputStream.read(data)) != -1) {
+ outputStream.write(data, 0, readed);
+ }
+ mEditableClient.insertImage(
+ outputStream.toByteArray(), inputContentInfo.getDescription().getMimeType(0));
+ } catch (final FileNotFoundException e) {
+ Log.e(LOGTAG, "Cannot open provider URI.", e);
+ return false;
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Cannot read/write provider URI.", e);
+ return false;
+ } finally {
+ if (requestPermission) {
+ inputContentInfo.releasePermission();
+ }
+ }
+
+ return true;
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void notifyIME(final @IMENotificationType int type) {
+ switch (type) {
+ case NOTIFY_IME_OF_FOCUS:
+ // Showing/hiding vkb is done in notifyIMEContext
+ if (mBatchEditCount != 0) {
+ Log.w(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount);
+ mBatchEditCount = 0;
+ }
+ break;
+
+ case NOTIFY_IME_OF_BLUR:
+ break;
+
+ case NOTIFY_IME_OF_TOKEN:
+ case NOTIFY_IME_OPEN_VKB:
+ case NOTIFY_IME_REPLY_EVENT:
+ case NOTIFY_IME_TO_CANCEL_COMPOSITION:
+ case NOTIFY_IME_TO_COMMIT_COMPOSITION:
+ default:
+ if (DEBUG) {
+ throw new IllegalArgumentException("Unexpected NOTIFY_IME=" + type);
+ }
+ break;
+ }
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public synchronized void notifyIMEContext(
+ @IMEState final int state,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ @IMEContextFlags final int flags) {
+ // mIMEState and the mIME*Hint fields should only be changed by notifyIMEContext,
+ // and not reset anywhere else. Usually, notifyIMEContext is called right after a
+ // focus or blur, so resetting mIMEState during the focus or blur seems harmless.
+ // However, this behavior is not guaranteed. Gecko may call notifyIMEContext
+ // independent of focus change; that is, a focus change may not be accompanied by
+ // a notifyIMEContext call. So if we reset mIMEState inside focus, there may not
+ // be another notifyIMEContext call to set mIMEState to a proper value (bug 829318)
+ /* When IME is 'disabled', IME processing is disabled.
+ In addition, the IME UI is hidden */
+ mIMEState = state;
+ mIMEActionHint = (actionHint == null) ? "" : actionHint;
+
+ // These fields are reset here and will be updated when restartInput is called below
+ mUpdateRequest = null;
+ mCurrentInputMethod = "";
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java
new file mode 100644
index 0000000000..72b8db01f0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java
@@ -0,0 +1,226 @@
+/* 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 androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.LinkedList;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/**
+ * This class provides an {@link InputStream} wrapper for a Gecko nsIChannel (or really,
+ * nsIRequest).
+ */
+@WrapForJNI
+@AnyThread
+/* package */ class GeckoInputStream extends InputStream {
+ private static final String LOGTAG = "GeckoInputStream";
+
+ private LinkedList<ByteBuffer> mBuffers = new LinkedList<>();
+ private boolean mEOF;
+ private boolean mClosed;
+ private boolean mHaveError;
+ private long mReadTimeout;
+ private boolean mResumed;
+ private Support mSupport;
+
+ /**
+ * This is only called via JNI. The support instance provides callbacks for the native
+ * counterpart.
+ *
+ * @param support An instance of {@link Support}, used for native callbacks.
+ */
+ /* package */ GeckoInputStream(final @Nullable Support support) {
+ mSupport = support;
+ }
+
+ public void setReadTimeoutMillis(final long millis) {
+ mReadTimeout = millis;
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ super.close();
+ mClosed = true;
+
+ if (mSupport != null) {
+ mSupport.close();
+ mSupport = null;
+ }
+ }
+
+ @Override
+ public synchronized int available() throws IOException {
+ if (mClosed) {
+ return 0;
+ }
+
+ final ByteBuffer buf = mBuffers.peekFirst();
+ return buf != null ? buf.remaining() : 0;
+ }
+
+ private void ensureNotClosed() throws IOException {
+ if (mClosed) {
+ throw new IOException("Stream is closed");
+ }
+ }
+
+ @Override
+ public synchronized int read() throws IOException {
+ ensureNotClosed();
+
+ final int expect = Integer.SIZE / 8;
+ final byte[] bytes = new byte[expect];
+
+ int count = 0;
+ while (count < expect) {
+ final long bytesRead = read(bytes, count, expect - count);
+ if (bytesRead < 0) {
+ return -1;
+ }
+
+ count += bytesRead;
+ }
+
+ final ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ return buffer.getInt();
+ }
+
+ @Override
+ public int read(final @NonNull byte[] b) throws IOException {
+ return read(b, 0, b.length);
+ }
+
+ @Override
+ public synchronized int read(final @NonNull byte[] dest, final int offset, final int length)
+ throws IOException {
+ ensureNotClosed();
+
+ final long startTime = System.currentTimeMillis();
+ while (!mEOF && mBuffers.size() == 0) {
+ if (mReadTimeout > 0 && (System.currentTimeMillis() - startTime) >= mReadTimeout) {
+ throw new IOException("Timed out");
+ }
+
+ // The underlying channel is suspended, so resume that before
+ // waiting for a buffer.
+ if (!mResumed) {
+ if (mSupport != null) {
+ mSupport.resume();
+ }
+ mResumed = true;
+ }
+
+ try {
+ wait(mReadTimeout);
+ } catch (final InterruptedException e) {
+ }
+ }
+
+ if (mEOF && mBuffers.size() == 0) {
+ if (mHaveError) {
+ throw new IOException("Unknown error");
+ }
+
+ // We have no data and we're not expecting more.
+ return -1;
+ }
+
+ final ByteBuffer buf = mBuffers.peekFirst();
+ final int readCount = Math.min(length, buf.remaining());
+ buf.get(dest, offset, readCount);
+
+ if (buf.remaining() == 0) {
+ // We're done with this buffer, advance the queue.
+ mBuffers.removeFirst();
+ }
+
+ return readCount;
+ }
+
+ /** Called by native code to indicate that no more data will be sent via {@link #appendBuffer}. */
+ @WrapForJNI(calledFrom = "gecko")
+ public synchronized void sendEof() {
+ if (mEOF) {
+ throw new IllegalStateException("Already have EOF");
+ }
+
+ mEOF = true;
+ notifyAll();
+ }
+
+ /** Called by native code to indicate that there was an error while reading the stream. */
+ @WrapForJNI(calledFrom = "gecko")
+ public synchronized void sendError() {
+ if (mEOF) {
+ throw new IllegalStateException("Already have EOF");
+ }
+
+ mEOF = true;
+ mHaveError = true;
+ notifyAll();
+ }
+
+ /**
+ * Called by native code to indicate that there was an issue during appending data to the stream.
+ * The writing stream should still report EoF. Setting this error during writing will cause an
+ * IOException if readers try to read from the stream.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ public synchronized void writeError() {
+ mHaveError = true;
+ notifyAll();
+ }
+
+ /**
+ * Called by native code to check if the stream is open.
+ *
+ * @return true if the stream is closed
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ synchronized boolean isStreamClosed() {
+ return mClosed || mEOF;
+ }
+
+ /**
+ * Called by native code to provide data for this stream.
+ *
+ * @param buf the bytes
+ * @throws IOException
+ */
+ @WrapForJNI(exceptionMode = "nsresult", calledFrom = "gecko")
+ /* package */ synchronized void appendBuffer(final byte[] buf) throws IOException {
+
+ if (mClosed) {
+ throw new IllegalStateException("Stream is closed");
+ }
+
+ if (mEOF) {
+ throw new IllegalStateException("EOF, no more data expected");
+ }
+
+ mBuffers.add(ByteBuffer.wrap(buf));
+ notifyAll();
+ }
+
+ @WrapForJNI
+ private static class Support extends JNIObject {
+ @WrapForJNI(dispatchTo = "gecko")
+ private native void resume();
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private native void close();
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java
new file mode 100644
index 0000000000..c991913b75
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java
@@ -0,0 +1,1072 @@
+/* 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.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.collection.SimpleArrayMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.TimeoutException;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.IXPCOMEventTarget;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.XPCOMEventTarget;
+
+/**
+ * GeckoResult is a class that represents an asynchronous result. The result is initially pending,
+ * and at a later time, the result may be completed with {@link #complete a value} or {@link
+ * #completeExceptionally an exception} depending on the outcome of the asynchronous operation. For
+ * example,
+ *
+ * <pre>
+ * public GeckoResult&lt;Integer&gt; divide(final int dividend, final int divisor) {
+ * final GeckoResult&lt;Integer&gt; result = new GeckoResult&lt;&gt;();
+ * (new Thread(() -&gt; {
+ * if (divisor != 0) {
+ * result.complete(dividend / divisor);
+ * } else {
+ * result.completeExceptionally(new ArithmeticException("Dividing by zero"));
+ * }
+ * })).start();
+ * return result;
+ * }</pre>
+ *
+ * <p>To retrieve the completed value or exception, use one of the {@link #then} methods to register
+ * listeners on the result. Listeners are run on the thread where the GeckoResult is created if a
+ * {@link Looper} is present. For example, to retrieve a completed value,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) {
+ * // value == 21
+ * }
+ * }, new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // Not called
+ * }
+ * });</pre>
+ *
+ * <p>And to retrieve a completed exception,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) {
+ * // Not called
+ * }
+ * }, new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // exception instanceof ArithmeticException
+ * }
+ * });</pre>
+ *
+ * <p>{@link #then} calls may be chained to complete multiple asynchonous operations in sequence.
+ * This example takes an integer, converts it to a String, and appends it to another String,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnValueListener&lt;Integer, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final Integer value) {
+ * return GeckoResult.fromValue(value.toString());
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final String value) {
+ * return GeckoResult.fromValue("42 / 2 = " + value);
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == "42 / 2 = 21"
+ * return null;
+ * }
+ * });</pre>
+ *
+ * <p>Chaining works with exception listeners as well. For example,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnExceptionListener&lt;String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * return "foo";
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == "foo"
+ * }
+ * });</pre>
+ *
+ * <p>A completed value/exception will propagate down the chain even if an intermediate step does
+ * not have a value/exception listener. For example,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnValueListener&lt;Integer, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final Integer value) {
+ * // Not called
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // exception instanceof ArithmeticException
+ * }
+ * });</pre>
+ *
+ * <p>However, any propagated value will be coerced to null. For example,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnExceptionListener&lt;String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onException(final Throwable exception) {
+ * // Not called
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == null
+ * }
+ * });</pre>
+ *
+ * <p>If a GeckoResult is created on a thread without a {@link Looper}, {@link
+ * #then(OnValueListener, OnExceptionListener)} is unusable (and will throw {@link
+ * IllegalThreadStateException}). In this scenario, the value is only available via {@link
+ * #poll(long)}. Alternatively, you may also chain the GeckoResult to one with a {@link Handler} via
+ * {@link #withHandler(Handler)}. You may then use {@link #then(OnValueListener,
+ * OnExceptionListener)} on the returned GeckoResult normally.
+ *
+ * <p>Any exception thrown by a listener are automatically used to complete the result. At the end
+ * of every chain, there is an implicit exception listener that rethrows any uncaught and unhandled
+ * exception as {@link UncaughtException}. The following example will cause {@link
+ * UncaughtException} to be thrown because {@code BazException} is uncaught and unhandled at the end
+ * of the chain,
+ *
+ * <pre>
+ * GeckoResult.fromValue(42).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) throws FooException {
+ * throw new FooException();
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) throws Exception {
+ * // exception instanceof FooException
+ * throw new BarException();
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) throws Throwable {
+ * // exception instanceof BarException
+ * return new BazException();
+ * }
+ * });</pre>
+ *
+ * @param <T> The type of the value delivered via the GeckoResult.
+ */
+@AnyThread
+public class GeckoResult<T> {
+ private static final String LOGTAG = "GeckoResult";
+
+ private interface Dispatcher {
+ void dispatch(Runnable r);
+ }
+
+ private static class HandlerDispatcher implements Dispatcher {
+ HandlerDispatcher(final Handler h) {
+ mHandler = h;
+ }
+
+ public void dispatch(final Runnable r) {
+ mHandler.post(r);
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof HandlerDispatcher)) {
+ return false;
+ }
+ return mHandler.equals(((HandlerDispatcher) other).mHandler);
+ }
+
+ @Override
+ public int hashCode() {
+ return mHandler.hashCode();
+ }
+
+ Handler mHandler;
+ }
+
+ private static class XPCOMEventTargetDispatcher implements Dispatcher {
+ private IXPCOMEventTarget mEventTarget;
+
+ public XPCOMEventTargetDispatcher(final IXPCOMEventTarget eventTarget) {
+ mEventTarget = eventTarget;
+ }
+
+ @Override
+ public void dispatch(final Runnable r) {
+ mEventTarget.execute(r);
+ }
+ }
+
+ private static class DirectDispatcher implements Dispatcher {
+ public void dispatch(final Runnable r) {
+ r.run();
+ }
+
+ static DirectDispatcher sInstance = new DirectDispatcher();
+
+ private DirectDispatcher() {}
+ }
+
+ public static final class UncaughtException extends RuntimeException {
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public UncaughtException(final Throwable cause) {
+ super(cause);
+ }
+ }
+
+ /** Interface used to delegate cancellation operations for a {@link GeckoResult}. */
+ @AnyThread
+ public interface CancellationDelegate {
+
+ /**
+ * This method should attempt to cancel the in-progress operation for the result to which this
+ * instance was attached. See {@link GeckoResult#cancel()} for more details.
+ *
+ * @return A {@link GeckoResult} resolving to "true" if cancellation was successful, "false"
+ * otherwise.
+ */
+ default @NonNull GeckoResult<Boolean> cancel() {
+ return GeckoResult.fromValue(false);
+ }
+ }
+
+ /**
+ * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#DENY}
+ */
+ @AnyThread
+ @NonNull
+ public static GeckoResult<AllowOrDeny> deny() {
+ return GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+
+ /**
+ * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#ALLOW}
+ */
+ @AnyThread
+ @NonNull
+ public static GeckoResult<AllowOrDeny> allow() {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+
+ // The default dispatcher for listeners on this GeckoResult. Other dispatchers can be specified
+ // when the listener is registered.
+ private final Dispatcher mDispatcher;
+ private boolean mComplete;
+ private T mValue;
+ private Throwable mError;
+ private boolean mIsUncaughtError;
+ private SimpleArrayMap<Dispatcher, ArrayList<Runnable>> mListeners = new SimpleArrayMap<>();
+
+ private GeckoResult<?> mParent;
+ private CancellationDelegate mCancellationDelegate;
+
+ /**
+ * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or {@link
+ * #completeExceptionally(Throwable)} in order to fulfill the result.
+ */
+ @WrapForJNI
+ public GeckoResult() {
+ if (ThreadUtils.isOnUiThread()) {
+ mDispatcher = new HandlerDispatcher(ThreadUtils.getUiHandler());
+ } else if (Looper.myLooper() != null) {
+ mDispatcher = new HandlerDispatcher(new Handler());
+ } else if (XPCOMEventTarget.launcherThread().isOnCurrentThread()) {
+ mDispatcher = new XPCOMEventTargetDispatcher(XPCOMEventTarget.launcherThread());
+ } else {
+ mDispatcher = null;
+ }
+ }
+
+ /**
+ * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or {@link
+ * #completeExceptionally(Throwable)} in order to fulfill the result.
+ *
+ * @param handler This {@link Handler} will be used for dispatching listeners registered via
+ * {@link #then(OnValueListener, OnExceptionListener)}.
+ */
+ public GeckoResult(final Handler handler) {
+ mDispatcher = new HandlerDispatcher(handler);
+ }
+
+ /**
+ * This constructs a result that is chained to the specified result.
+ *
+ * @param from The {@link GeckoResult} to copy.
+ */
+ public GeckoResult(final GeckoResult<T> from) {
+ this();
+ completeFrom(from);
+ }
+
+ /**
+ * Construct a result that is completed with the specified value.
+ *
+ * @param value The value used to complete the newly created result.
+ * @param <U> Type for the result.
+ * @return The completed {@link GeckoResult}
+ */
+ @WrapForJNI
+ public static @NonNull <U> GeckoResult<U> fromValue(@Nullable final U value) {
+ final GeckoResult<U> result = new GeckoResult<>();
+ result.complete(value);
+ return result;
+ }
+
+ /**
+ * Construct a result that is completed with the specified {@link Throwable}. May not be null.
+ *
+ * @param error The exception used to complete the newly created result.
+ * @param <T> Type for the result if the result had been completed without exception.
+ * @return The completed {@link GeckoResult}
+ */
+ @WrapForJNI
+ public static @NonNull <T> GeckoResult<T> fromException(@NonNull final Throwable error) {
+ final GeckoResult<T> result = new GeckoResult<>();
+ result.completeExceptionally(error);
+ return result;
+ }
+
+ @Override
+ public synchronized int hashCode() {
+ return Arrays.hashCode(new Object[] {mComplete, mValue, mError});
+ }
+
+ // This can go away once we can rely on java.util.Objects.equals() (API 19)
+ private static boolean objectEquals(final Object a, final Object b) {
+ return a == b || (a != null && a.equals(b));
+ }
+
+ @Override
+ public synchronized boolean equals(final Object other) {
+ if (other instanceof GeckoResult<?>) {
+ final GeckoResult<?> result = (GeckoResult<?>) other;
+ return result.mComplete == mComplete
+ && objectEquals(result.mError, mError)
+ && objectEquals(result.mValue, mValue);
+ }
+
+ return false;
+ }
+
+ /**
+ * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}.
+ *
+ * @param valueListener An instance of {@link OnValueListener}, called when the {@link
+ * GeckoResult} is completed with a value.
+ * @param <U> Type of the new result that is returned by the listener.
+ * @return A new {@link GeckoResult} that the listener will complete.
+ */
+ public @NonNull <U> GeckoResult<U> then(@NonNull final OnValueListener<T, U> valueListener) {
+ return then(valueListener, null);
+ }
+
+ /**
+ * Convenience method for {@link #map(OnValueMapper, OnExceptionMapper)}.
+ *
+ * @param valueMapper An instance of {@link OnValueMapper}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param <U> Type of the new value that is returned by the mapper.
+ * @return A new {@link GeckoResult} that will contain the mapped value.
+ */
+ public @NonNull <U> GeckoResult<U> map(@Nullable final OnValueMapper<T, U> valueMapper) {
+ return map(valueMapper, null);
+ }
+
+ /**
+ * Transform the value and error of this {@link GeckoResult}.
+ *
+ * @param valueMapper An instance of {@link OnValueMapper}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param exceptionMapper An instance of {@link OnExceptionMapper}, called when the {@link
+ * GeckoResult} is completed with an exception.
+ * @param <U> Type of the new value that is returned by the mapper.
+ * @return A new {@link GeckoResult} that will contain the mapped value.
+ */
+ public @NonNull <U> GeckoResult<U> map(
+ @Nullable final OnValueMapper<T, U> valueMapper,
+ @Nullable final OnExceptionMapper exceptionMapper) {
+ final OnValueListener<T, U> valueListener =
+ valueMapper != null ? value -> GeckoResult.fromValue(valueMapper.onValue(value)) : null;
+ final OnExceptionListener<U> exceptionListener =
+ exceptionMapper != null
+ ? error -> GeckoResult.fromException(exceptionMapper.onException(error))
+ : null;
+ return then(valueListener, exceptionListener);
+ }
+
+ /**
+ * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}.
+ *
+ * @param exceptionListener An instance of {@link OnExceptionListener}, called when the {@link
+ * GeckoResult} is completed with an {@link Exception}.
+ * @param <U> Type of the new result that is returned by the listener.
+ * @return A new {@link GeckoResult} that the listener will complete.
+ */
+ public @NonNull <U> GeckoResult<U> exceptionally(
+ @NonNull final OnExceptionListener<U> exceptionListener) {
+ return then(null, exceptionListener);
+ }
+
+ /**
+ * Replacement for {@link java.util.function.Consumer} for devices with minApi &lt; 24.
+ *
+ * @param <T> the type of the input for this consumer.
+ */
+ // TODO: Remove this when we move to min API 24
+ public interface Consumer<T> {
+ /**
+ * Run this consumer for the given input.
+ *
+ * @param t the input value.
+ */
+ @AnyThread
+ void accept(@Nullable T t);
+ }
+
+ /**
+ * Convenience method for {@link #accept(Consumer, Consumer)}.
+ *
+ * @param valueListener An instance of {@link Consumer}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> accept(@Nullable final Consumer<T> valueListener) {
+ return accept(valueListener, null);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed either with a value or
+ * {@link Throwable}. Listeners will be invoked on the {@link Looper} returned from {@link
+ * #getLooper()}. If null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param valueConsumer An instance of {@link Consumer}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param exceptionConsumer An instance of {@link Consumer}, called when the {@link GeckoResult}
+ * is completed with an {@link Throwable}.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> accept(
+ @Nullable final Consumer<T> valueConsumer,
+ @Nullable final Consumer<Throwable> exceptionConsumer) {
+ final OnValueListener<T, Void> valueListener =
+ valueConsumer == null
+ ? null
+ : value -> {
+ valueConsumer.accept(value);
+ return null;
+ };
+
+ final OnExceptionListener<Void> exceptionListener =
+ exceptionConsumer == null
+ ? null
+ : value -> {
+ exceptionConsumer.accept(value);
+ return null;
+ };
+
+ return then(valueListener, exceptionListener);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed regardless of success
+ * status. Listeners will be invoked on the {@link Looper} returned from {@link #getLooper()}. If
+ * null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param finallyRunnable An instance of {@link Runnable}, called when the {@link GeckoResult} is
+ * completed with a value or a {@link Throwable}.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> finally_(@NonNull final Runnable finallyRunnable) {
+ final OnValueListener<T, Void> valueListener =
+ value -> {
+ finallyRunnable.run();
+ return null;
+ };
+ final OnExceptionListener<Void> exceptionListener =
+ value -> {
+ finallyRunnable.run();
+ return null;
+ };
+ return then(valueListener, exceptionListener);
+ }
+
+ /* package */ @NonNull
+ GeckoResult<Void> getOrAccept(@Nullable final Consumer<T> valueConsumer) {
+ return getOrAccept(valueConsumer, null);
+ }
+
+ /* package */ @NonNull
+ GeckoResult<Void> getOrAccept(
+ @Nullable final Consumer<T> valueConsumer,
+ @Nullable final Consumer<Throwable> exceptionConsumer) {
+ if (haveValue() && valueConsumer != null) {
+ valueConsumer.accept(mValue);
+ return GeckoResult.fromValue(null);
+ }
+
+ if (haveError() && exceptionConsumer != null) {
+ exceptionConsumer.accept(mError);
+ return GeckoResult.fromValue(null);
+ }
+
+ return accept(valueConsumer, exceptionConsumer);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed either with a value or
+ * {@link Throwable}. Listeners will be invoked on the {@link Looper} returned from {@link
+ * #getLooper()}. If null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param valueListener An instance of {@link OnValueListener}, called when the {@link
+ * GeckoResult} is completed with a value.
+ * @param exceptionListener An instance of {@link OnExceptionListener}, called when the {@link
+ * GeckoResult} is completed with an {@link Throwable}.
+ * @param <U> Type of the new result that is returned by the listeners.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull <U> GeckoResult<U> then(
+ @Nullable final OnValueListener<T, U> valueListener,
+ @Nullable final OnExceptionListener<U> exceptionListener) {
+ if (mDispatcher == null) {
+ throw new IllegalThreadStateException("Must have a Handler");
+ }
+
+ return thenInternal(mDispatcher, valueListener, exceptionListener);
+ }
+
+ private @NonNull <U> GeckoResult<U> thenInternal(
+ @NonNull final Dispatcher dispatcher,
+ @Nullable final OnValueListener<T, U> valueListener,
+ @Nullable final OnExceptionListener<U> exceptionListener) {
+ if (valueListener == null && exceptionListener == null) {
+ throw new IllegalArgumentException("At least one listener should be non-null");
+ }
+
+ final GeckoResult<U> result = new GeckoResult<U>();
+ result.mParent = this;
+ thenInternal(
+ dispatcher,
+ () -> {
+ try {
+ if (haveValue()) {
+ result.completeFrom(valueListener != null ? valueListener.onValue(mValue) : null);
+ } else if (!haveError()) {
+ // Listener called without completion?
+ throw new AssertionError();
+ } else if (exceptionListener != null) {
+ result.completeFrom(exceptionListener.onException(mError));
+ } else {
+ result.mIsUncaughtError = mIsUncaughtError;
+ result.completeExceptionally(mError);
+ }
+ } catch (final Throwable e) {
+ if (!result.mComplete) {
+ result.mIsUncaughtError = true;
+ result.completeExceptionally(e);
+ } else if (e instanceof RuntimeException) {
+ // This should only be UncaughtException, but we rethrow all RuntimeExceptions
+ // to avoid squelching logic errors in GeckoResult itself.
+ throw (RuntimeException) e;
+ }
+ }
+ });
+ return result;
+ }
+
+ private synchronized void thenInternal(
+ @NonNull final Dispatcher dispatcher, @NonNull final Runnable listener) {
+ if (mComplete) {
+ dispatcher.dispatch(listener);
+ } else {
+ if (!mListeners.containsKey(dispatcher)) {
+ mListeners.put(dispatcher, new ArrayList<>(1));
+ }
+ mListeners.get(dispatcher).add(listener);
+ }
+ }
+
+ @WrapForJNI
+ private void nativeThen(
+ @NonNull final GeckoCallback accept, @NonNull final GeckoCallback reject) {
+ // NB: We could use the lambda syntax here, but given all the layers
+ // of abstraction it's helpful to see the types written explicitly.
+ thenInternal(
+ DirectDispatcher.sInstance,
+ new OnValueListener<T, Void>() {
+ @Override
+ public GeckoResult<Void> onValue(final T value) {
+ accept.call(value);
+ return null;
+ }
+ },
+ new OnExceptionListener<Void>() {
+ @Override
+ public GeckoResult<Void> onException(final Throwable exception) {
+ reject.call(exception);
+ return null;
+ }
+ });
+ }
+
+ /**
+ * @return Get the {@link Looper} that will be used to schedule listeners registered via {@link
+ * #then(OnValueListener, OnExceptionListener)}.
+ */
+ public @Nullable Looper getLooper() {
+ if (mDispatcher == null || !(mDispatcher instanceof HandlerDispatcher)) {
+ return null;
+ }
+
+ return ((HandlerDispatcher) mDispatcher).mHandler.getLooper();
+ }
+
+ /**
+ * Returns a new GeckoResult that will be completed by this instance. Listeners registered via
+ * {@link #then(OnValueListener, OnExceptionListener)} will be run on the specified {@link
+ * Handler}.
+ *
+ * @param handler A {@link Handler} where listeners will be run. May be null.
+ * @return A new GeckoResult.
+ */
+ public @NonNull GeckoResult<T> withHandler(final @Nullable Handler handler) {
+ final GeckoResult<T> result = new GeckoResult<>(handler);
+ result.completeFrom(this);
+ return result;
+ }
+
+ /**
+ * Returns a {@link GeckoResult} that is completed when the given {@link GeckoResult} instances
+ * are complete.
+ *
+ * <p>The returned {@link GeckoResult} will resolve with the list of values from the inputs. The
+ * list is guaranteed to be in the same order as the inputs.
+ *
+ * <p>If any of the {@link GeckoResult} fails, the returned result will fail.
+ *
+ * <p>If no inputs are provided, the returned {@link GeckoResult} will complete with the value
+ * <code>null</code>.
+ *
+ * @param pending the input {@link GeckoResult}s.
+ * @param <V> type of the {@link GeckoResult}'s values.
+ * @return a {@link GeckoResult} that will complete when all of the inputs are completed or when
+ * at least one of the inputs fail.
+ */
+ @SuppressWarnings("varargs")
+ @SafeVarargs
+ @NonNull
+ public static <V> GeckoResult<List<V>> allOf(final @NonNull GeckoResult<V>... pending) {
+ return allOf(Arrays.asList(pending));
+ }
+
+ /**
+ * Returns a {@link GeckoResult} that is completed when the given {@link GeckoResult} instances
+ * are complete.
+ *
+ * <p>The returned {@link GeckoResult} will resolve with the list of values from the inputs. The
+ * list is guaranteed to be in the same order as the inputs.
+ *
+ * <p>If any of the {@link GeckoResult} fails, the returned result will fail.
+ *
+ * <p>If no inputs are provided, the returned {@link GeckoResult} will complete with the value
+ * <code>null</code>.
+ *
+ * @param pending the input {@link GeckoResult}s.
+ * @param <V> type of the {@link GeckoResult}'s values.
+ * @return a {@link GeckoResult} that will complete when all of the inputs are completed or when
+ * at least one of the inputs fail.
+ */
+ @NonNull
+ public static <V> GeckoResult<List<V>> allOf(final @Nullable List<GeckoResult<V>> pending) {
+ if (pending == null) {
+ return GeckoResult.fromValue(null);
+ }
+
+ return new AllOfResult<>(pending);
+ }
+
+ private static class AllOfResult<V> extends GeckoResult<List<V>> {
+ private boolean mFailed = false;
+ private int mResultCount = 0;
+ private final List<V> mAccumulator;
+ private final List<GeckoResult<V>> mPending;
+
+ public AllOfResult(final @NonNull List<GeckoResult<V>> pending) {
+ // Initialize the list with nulls so we can fill it in the same order as the input list
+ mAccumulator = new ArrayList<>(Collections.nCopies(pending.size(), null));
+ mPending = pending;
+
+ // If the input list is empty, there's nothing to do
+ if (pending.size() == 0) {
+ complete(mAccumulator);
+ return;
+ }
+
+ // We use iterators so we can access the index and preserve the list order
+ final ListIterator<GeckoResult<V>> it = pending.listIterator();
+ while (it.hasNext()) {
+ final int index = it.nextIndex();
+ it.next().accept(value -> onResult(value, index), this::onError);
+ }
+ }
+
+ private void onResult(final V value, final int index) {
+ if (mFailed) {
+ // Some other element in the list already failed, nothing to do here
+ return;
+ }
+
+ mResultCount++;
+ mAccumulator.set(index, value);
+
+ if (mResultCount == mPending.size()) {
+ complete(mAccumulator);
+ }
+ }
+
+ private void onError(final Throwable error) {
+ mFailed = true;
+ completeExceptionally(error);
+ }
+ }
+
+ private void dispatchLocked() {
+ if (!mComplete) {
+ throw new IllegalStateException("Cannot dispatch unless result is complete");
+ }
+
+ if (mListeners.isEmpty()) {
+ if (mIsUncaughtError) {
+ // We have no listeners to forward the uncaught exception to;
+ // rethrow the exception to make it visible.
+ throw new UncaughtException(mError);
+ }
+ return;
+ }
+
+ if (mDispatcher == null) {
+ throw new AssertionError("Shouldn't have listeners with null dispatcher");
+ }
+
+ for (int i = 0; i < mListeners.size(); ++i) {
+ final Dispatcher dispatcher = mListeners.keyAt(i);
+ final ArrayList<Runnable> jobs = mListeners.valueAt(i);
+ dispatcher.dispatch(
+ () -> {
+ for (final Runnable job : jobs) {
+ job.run();
+ }
+ });
+ }
+ mListeners.clear();
+ }
+
+ /**
+ * Completes this result based on another result.
+ *
+ * @param other The result that this result should mirror
+ */
+ public void completeFrom(final @Nullable GeckoResult<T> other) {
+ if (other == null) {
+ complete(null);
+ return;
+ }
+
+ this.mCancellationDelegate = other.mCancellationDelegate;
+ other.thenInternal(
+ DirectDispatcher.sInstance,
+ () -> {
+ if (other.haveValue()) {
+ complete(other.mValue);
+ } else {
+ mIsUncaughtError = other.mIsUncaughtError;
+ completeExceptionally(other.mError);
+ }
+ });
+ }
+
+ /**
+ * Return the value of this result, waiting for it to be completed if necessary. If the result is
+ * completed with an exception it will be rethrown here.
+ *
+ * <p>You must not call this method if the current thread has a {@link Looper} due to the
+ * possibility of a deadlock. If this occurs, {@link IllegalStateException} is thrown.
+ *
+ * @return The value of this result.
+ * @throws Throwable The {@link Throwable} contained in this result, if any.
+ * @throws IllegalThreadStateException if this method is called on a thread that has a {@link
+ * Looper}.
+ */
+ public synchronized @Nullable T poll() throws Throwable {
+ if (Looper.myLooper() != null) {
+ throw new IllegalThreadStateException("Cannot poll indefinitely from thread with Looper");
+ }
+
+ return poll(Long.MAX_VALUE);
+ }
+
+ /**
+ * Return the value of this result, waiting for it to be completed if necessary. If the result is
+ * completed with an exception it will be rethrown here.
+ *
+ * <p>Caution is advised if the caller is on a thread with a {@link Looper}, as it's possible to
+ * effectively deadlock in cases when the work is being completed on the calling thread. It's
+ * preferable to use {@link #then(OnValueListener, OnExceptionListener)} in such circumstances,
+ * but if you must use this method consider a small timeout value.
+ *
+ * @param timeoutMillis Number of milliseconds to wait for the result to complete.
+ * @return The value of this result.
+ * @throws Throwable The {@link Throwable} contained in this result, if any.
+ * @throws TimeoutException if we wait more than timeoutMillis before the result is completed.
+ */
+ public synchronized @Nullable T poll(final long timeoutMillis) throws Throwable {
+ final long start = SystemClock.uptimeMillis();
+ long remaining = timeoutMillis;
+ while (!mComplete && remaining > 0) {
+ try {
+ wait(remaining);
+ } catch (final InterruptedException e) {
+ }
+
+ remaining = timeoutMillis - (SystemClock.uptimeMillis() - start);
+ }
+
+ if (!mComplete) {
+ throw new TimeoutException();
+ }
+
+ if (haveError()) {
+ throw mError;
+ }
+
+ return mValue;
+ }
+
+ /**
+ * Complete the result with the specified value. IllegalStateException is thrown if the result is
+ * already complete.
+ *
+ * @param value The value used to complete the result.
+ * @throws IllegalStateException If the result is already completed.
+ */
+ @WrapForJNI
+ public synchronized void complete(final @Nullable T value) {
+ if (mComplete) {
+ throw new IllegalStateException("result is already complete");
+ }
+
+ mValue = value;
+ mComplete = true;
+
+ dispatchLocked();
+ notifyAll();
+ }
+
+ /**
+ * Complete the result with the specified {@link Throwable}. IllegalStateException is thrown if
+ * the result is already complete.
+ *
+ * @param exception The {@link Throwable} used to complete the result.
+ * @throws IllegalStateException If the result is already completed.
+ */
+ @WrapForJNI
+ public synchronized void completeExceptionally(@NonNull final Throwable exception) {
+ if (mComplete) {
+ throw new IllegalStateException("result is already complete");
+ }
+
+ if (exception == null) {
+ throw new IllegalArgumentException("Throwable must not be null");
+ }
+
+ mError = exception;
+ mComplete = true;
+
+ dispatchLocked();
+ notifyAll();
+ }
+
+ /**
+ * An interface used to deliver values to listeners of a {@link GeckoResult}
+ *
+ * @param <T> Type of the value delivered via {@link #onValue(Object)}
+ * @param <U> Type of the value for the result returned from {@link #onValue(Object)}
+ */
+ public interface OnValueListener<T, U> {
+ /**
+ * Called when a {@link GeckoResult} is completed with a value. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param value The value of the {@link GeckoResult}
+ * @return Result used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ GeckoResult<U> onValue(@Nullable T value) throws Throwable;
+ }
+
+ /**
+ * An interface used to map {@link GeckoResult} values.
+ *
+ * @param <T> Type of the value delivered via {@link #onValue}
+ * @param <U> Type of the new value returned by {@link #onValue}
+ */
+ public interface OnValueMapper<T, U> {
+ /**
+ * Called when a {@link GeckoResult} is completed with a value. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param value The value of the {@link GeckoResult}
+ * @return Value used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ U onValue(@Nullable T value) throws Throwable;
+ }
+
+ /** An interface used to map {@link GeckoResult} exceptions. */
+ public interface OnExceptionMapper {
+ /**
+ * Called when a {@link GeckoResult} is completed with an exception. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param exception Exception that completed the result.
+ * @return Exception used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ Throwable onException(@NonNull Throwable exception) throws Throwable;
+ }
+
+ /**
+ * An interface used to deliver exceptions to listeners of a {@link GeckoResult}
+ *
+ * @param <V> Type of the vale for the result returned from {@link #onException(Throwable)}
+ */
+ public interface OnExceptionListener<V> {
+ /**
+ * Called when a {@link GeckoResult} is completed with an exception. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param exception Exception that completed the result.
+ * @return Result used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ GeckoResult<V> onException(@NonNull Throwable exception) throws Throwable;
+ }
+
+ @WrapForJNI
+ private static class GeckoCallback extends JNIObject {
+ private native void call(Object arg);
+
+ @Override
+ protected native void disposeNative();
+ }
+
+ private boolean haveValue() {
+ return mComplete && mError == null;
+ }
+
+ private boolean haveError() {
+ return mComplete && mError != null;
+ }
+
+ /**
+ * Attempts to cancel the operation associated with this result.
+ *
+ * <p>If this result has a {@link CancellationDelegate} attached via {@link
+ * #setCancellationDelegate(CancellationDelegate)}, the return value will be the result of calling
+ * {@link CancellationDelegate#cancel()} on that instance. Otherwise, if this result is chained to
+ * another result (via return value from {@link OnValueListener}), we will walk up the chain until
+ * a CancellationDelegate is found and run it. If no CancellationDelegate is found, a result
+ * resolving to "false" will be returned.
+ *
+ * <p>If this result is already complete, the returned result will always resolve to false.
+ *
+ * <p>If the returned result resolves to true, this result will be completed with a {@link
+ * CancellationException}.
+ *
+ * @return A GeckoResult resolving to a boolean indicating success or failure of the cancellation
+ * attempt.
+ */
+ public synchronized @NonNull GeckoResult<Boolean> cancel() {
+ if (haveValue() || haveError()) {
+ return GeckoResult.fromValue(false);
+ }
+
+ if (mCancellationDelegate != null) {
+ return mCancellationDelegate
+ .cancel()
+ .then(
+ value -> {
+ if (value) {
+ try {
+ this.completeExceptionally(new CancellationException());
+ } catch (final IllegalStateException e) {
+ // Can't really do anything about this.
+ }
+ }
+ return GeckoResult.fromValue(value);
+ });
+ }
+
+ if (mParent != null) {
+ return mParent.cancel();
+ }
+
+ return GeckoResult.fromValue(false);
+ }
+
+ /**
+ * Sets the instance of {@link CancellationDelegate} that will be invoked by {@link #cancel()}.
+ *
+ * @param delegate an instance of CancellationDelegate.
+ */
+ public void setCancellationDelegate(final @Nullable CancellationDelegate delegate) {
+ mCancellationDelegate = delegate;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
new file mode 100644
index 0000000000..e1e82a492d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
@@ -0,0 +1,1057 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.app.ActivityManager;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ServiceInfo;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.Process;
+import android.provider.Settings;
+import android.text.format.DateFormat;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
+import androidx.annotation.UiThread;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.OnLifecycleEvent;
+import androidx.lifecycle.ProcessLifecycleOwner;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+import java.util.Map;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoNetworkManager;
+import org.mozilla.gecko.GeckoScreenChangeListener;
+import org.mozilla.gecko.GeckoScreenOrientation;
+import org.mozilla.gecko.GeckoScreenOrientation.ScreenOrientation;
+import org.mozilla.gecko.GeckoSystemStateListener;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.process.MemoryController;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.DebugConfig;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public final class GeckoRuntime implements Parcelable {
+ private static final String LOGTAG = "GeckoRuntime";
+ private static final boolean DEBUG = false;
+
+ private static final String CONFIG_FILE_PATH_TEMPLATE =
+ "/data/local/tmp/%s-geckoview-config.yaml";
+
+ /**
+ * Intent action sent to the crash handler when a crash is encountered.
+ *
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ */
+ public static final String ACTION_CRASHED = "org.mozilla.gecko.ACTION_CRASHED";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. It refers to a String with the
+ * path to a Breakpad minidump file containing information about the crash. Several crash
+ * reporters are able to ingest this in a crash report, including <a
+ * href="https://sentry.io">Sentry</a> and Mozilla's <a
+ * href="https://wiki.mozilla.org/Socorro">Socorro</a>. <br>
+ * <br>
+ * Be aware, the minidump can contain personally identifiable information. Ensure you are obeying
+ * all applicable laws and policies before sending this to a remote server.
+ *
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ */
+ public static final String EXTRA_MINIDUMP_PATH = "minidumpPath";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. It refers to a string with the
+ * path to a file containing extra metadata about the crash. The file contains key-value pairs in
+ * the form
+ *
+ * <pre>Key=Value</pre>
+ *
+ * Be aware, it may contain sensitive data such as the URI that was loaded at the time of the
+ * crash.
+ */
+ public static final String EXTRA_EXTRAS_PATH = "extrasPath";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. The value is a String matching
+ * one of the `CRASHED_PROCESS_TYPE_*` constants, describing what type of process the crash
+ * occurred in.
+ *
+ * @see GeckoSession.ContentDelegate#onCrash(GeckoSession)
+ */
+ public static final String EXTRA_CRASH_PROCESS_TYPE = "processType";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. The value is a String
+ * containing the content process type, which might not be available even for child processes.
+ *
+ * @see GeckoSession.ContentDelegate#onCrash(GeckoSession)
+ */
+ public static final String EXTRA_CRASH_REMOTE_TYPE = "remoteType";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating the main application process was
+ * affected by the crash, which is therefore fatal.
+ */
+ public static final String CRASHED_PROCESS_TYPE_MAIN = "MAIN";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a foreground child process, such as a
+ * content process, crashed. The application may be able to recover from this crash, but it was
+ * likely noticable to the user.
+ */
+ public static final String CRASHED_PROCESS_TYPE_FOREGROUND_CHILD = "FOREGROUND_CHILD";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a background child process crashed. This
+ * should have been recovered from automatically, and will have had minimal impact to the user, if
+ * any.
+ */
+ public static final String CRASHED_PROCESS_TYPE_BACKGROUND_CHILD = "BACKGROUND_CHILD";
+
+ private final MemoryController mMemoryController = new MemoryController();
+
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef(
+ value = {
+ CRASHED_PROCESS_TYPE_MAIN,
+ CRASHED_PROCESS_TYPE_FOREGROUND_CHILD,
+ CRASHED_PROCESS_TYPE_BACKGROUND_CHILD
+ })
+ public @interface CrashedProcessType {}
+
+ private final class LifecycleListener implements LifecycleObserver {
+ private boolean mPaused = false;
+
+ public LifecycleListener() {}
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ void onCreate() {
+ Log.d(LOGTAG, "Lifecycle: onCreate");
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_START)
+ void onStart() {
+ Log.d(LOGTAG, "Lifecycle: onStart");
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ void onResume() {
+ Log.d(LOGTAG, "Lifecycle: onResume");
+ if (mPaused) {
+ // Do not trigger the first onResume event because it breaks nsAppShell::sPauseCount counter
+ // thresholds.
+ GeckoThread.onResume();
+ } else {
+ // Notify Gecko when the application has been moved in the foreground for the first time
+ // after being created and started (used by the ExtensionProcessCrashObserver on the Gecko
+ // side to adjust the appIsForeground property when the application-foreground or
+ // application-background topics are not notified).
+ EventDispatcher.getInstance().dispatch("GeckoView:InitialForeground", null);
+ }
+ mPaused = false;
+ // Can resume location services, checks if was in use before going to background
+ GeckoAppShell.resumeLocation();
+ // Monitor network status and send change notifications to Gecko
+ // while active.
+ GeckoNetworkManager.getInstance().start(GeckoAppShell.getApplicationContext());
+
+ // Set settings that may have changed between last app opening
+ GeckoAppShell.setIs24HourFormat(
+ DateFormat.is24HourFormat(GeckoAppShell.getApplicationContext()));
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ void onPause() {
+ Log.d(LOGTAG, "Lifecycle: onPause");
+ mPaused = true;
+ // Pause listening for locations when in background
+ GeckoAppShell.pauseLocation();
+ // Stop monitoring network status while inactive.
+ GeckoNetworkManager.getInstance().stop();
+ GeckoThread.onPause();
+ }
+ }
+
+ private static GeckoRuntime sDefaultRuntime;
+
+ /**
+ * Get the default runtime for the given context. This will create and initialize the runtime with
+ * the default settings.
+ *
+ * <p>Note: Only use this for session-less apps. For regular apps, use create() instead.
+ *
+ * @param context An application context for the default runtime.
+ * @return The (static) default runtime for the context.
+ */
+ @UiThread
+ public static synchronized @NonNull GeckoRuntime getDefault(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "getDefault");
+ }
+ if (sDefaultRuntime == null) {
+ sDefaultRuntime = new GeckoRuntime();
+ sDefaultRuntime.attachTo(context);
+ sDefaultRuntime.init(context, new GeckoRuntimeSettings());
+ }
+
+ return sDefaultRuntime;
+ }
+
+ private static GeckoRuntime sRuntime;
+ private GeckoRuntimeSettings mSettings;
+ private Delegate mDelegate;
+ private ServiceWorkerDelegate mServiceWorkerDelegate;
+ private WebNotificationDelegate mNotificationDelegate;
+ private ActivityDelegate mActivityDelegate;
+ private OrientationController mOrientationController;
+ private StorageController mStorageController;
+ private final WebExtensionController mWebExtensionController;
+ private WebPushController mPushController;
+ private final ContentBlockingController mContentBlockingController;
+ private final Autocomplete.StorageProxy mAutocompleteStorageProxy;
+ private final ProfilerController mProfilerController;
+ private final GeckoScreenChangeListener mScreenChangeListener;
+
+ private GeckoRuntime() {
+ mWebExtensionController = new WebExtensionController(this);
+ mContentBlockingController = new ContentBlockingController();
+ mAutocompleteStorageProxy = new Autocomplete.StorageProxy();
+ mProfilerController = new ProfilerController();
+ mScreenChangeListener = new GeckoScreenChangeListener();
+
+ if (sRuntime != null) {
+ throw new IllegalStateException("Only one GeckoRuntime instance is allowed");
+ }
+ sRuntime = this;
+ }
+
+ @WrapForJNI
+ @UiThread
+ /* package */ @Nullable
+ static GeckoRuntime getInstance() {
+ return sRuntime;
+ }
+
+ /**
+ * Called by mozilla::dom::ClientOpenWindow to retrieve the window id to use for a
+ * ServiceWorkerClients.openWindow() request.
+ *
+ * @param url validated Url being requested to be opened in a new window.
+ * @return SessionID to use for the request.
+ */
+ @SuppressLint("WrongThread") // for .isOpen() which is called on the UI thread
+ @WrapForJNI(calledFrom = "gecko")
+ private static @NonNull GeckoResult<String> serviceWorkerOpenWindow(final @NonNull String url) {
+ if (sRuntime != null && sRuntime.mServiceWorkerDelegate != null) {
+ final GeckoResult<String> result = new GeckoResult<>();
+ // perform the onOpenWindow call in the UI thread
+ ThreadUtils.runOnUiThread(
+ () -> {
+ sRuntime
+ .mServiceWorkerDelegate
+ .onOpenWindow(url)
+ .accept(
+ session -> {
+ if (session != null) {
+ if (!session.isOpen()) {
+ session.open(sRuntime);
+ }
+ result.complete(session.getId());
+ } else {
+ result.complete(null);
+ }
+ });
+ });
+ return result;
+ } else {
+ return GeckoResult.fromException(
+ new java.lang.RuntimeException("No available Service Worker delegate."));
+ }
+ }
+
+ /**
+ * Attach the runtime to the given context.
+ *
+ * @param context The new context to attach to.
+ */
+ @UiThread
+ public void attachTo(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "attachTo " + context.getApplicationContext());
+ }
+ final Context appContext = context.getApplicationContext();
+ if (!appContext.equals(GeckoAppShell.getApplicationContext())) {
+ GeckoAppShell.setApplicationContext(appContext);
+ }
+ }
+
+ private final BundleEventListener mEventListener =
+ new BundleEventListener() {
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ final Class<?> crashHandler = GeckoRuntime.this.getSettings().mCrashHandler;
+
+ if ("Gecko:Exited".equals(event) && mDelegate != null) {
+ mDelegate.onShutdown();
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(mEventListener, "Gecko:Exited");
+ } else if ("GeckoView:Test:NewTab".equals(event)) {
+ final String url = message.getString("url", "about:blank");
+ serviceWorkerOpenWindow(url)
+ .then(
+ (GeckoResult.OnValueListener<String, Void>)
+ value -> {
+ callback.sendSuccess(value);
+ return null;
+ })
+ .exceptionally(
+ (GeckoResult.OnExceptionListener<Void>)
+ error -> {
+ callback.sendError(error + " Could not open tab.");
+ return null;
+ });
+ } else if ("GeckoView:ChildCrashReport".equals(event) && crashHandler != null) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Intent i = new Intent(ACTION_CRASHED, null, context, crashHandler);
+ i.putExtra(EXTRA_MINIDUMP_PATH, message.getString(EXTRA_MINIDUMP_PATH));
+ i.putExtra(EXTRA_EXTRAS_PATH, message.getString(EXTRA_EXTRAS_PATH));
+ i.putExtra(EXTRA_CRASH_PROCESS_TYPE, message.getString(EXTRA_CRASH_PROCESS_TYPE));
+ i.putExtra(EXTRA_CRASH_REMOTE_TYPE, message.getString(EXTRA_CRASH_REMOTE_TYPE));
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(i);
+ } else {
+ context.startService(i);
+ }
+ }
+ }
+ };
+
+ private static String getProcessName(final Context context) {
+ final ActivityManager manager =
+ (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ final List<ActivityManager.RunningAppProcessInfo> infos = manager.getRunningAppProcesses();
+ if (infos == null) {
+ return null;
+ }
+ for (final ActivityManager.RunningAppProcessInfo info : infos) {
+ if (info.pid == Process.myPid()) {
+ return info.processName;
+ }
+ }
+
+ return null;
+ }
+
+ /* package */ boolean init(
+ final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "init");
+ }
+ int flags = GeckoThread.FLAG_PRELOAD_CHILD;
+
+ if (settings.getPauseForDebuggerEnabled()) {
+ flags |= GeckoThread.FLAG_DEBUGGING;
+ }
+
+ final Class<?> crashHandler = settings.getCrashHandler();
+ if (crashHandler != null) {
+ try {
+ final ServiceInfo info =
+ context.getPackageManager().getServiceInfo(new ComponentName(context, crashHandler), 0);
+ if (info.processName.equals(getProcessName(context))) {
+ throw new IllegalArgumentException(
+ "Crash handler service must run in a separate process");
+ }
+
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mEventListener, "GeckoView:ChildCrashReport");
+
+ flags |= GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER;
+ } catch (final PackageManager.NameNotFoundException e) {
+ throw new IllegalArgumentException("Crash handler must be registered as a service");
+ }
+ }
+
+ GeckoAppShell.useMaxScreenDepth(settings.getUseMaxScreenDepth());
+ GeckoAppShell.setDisplayDensityOverride(settings.getDisplayDensityOverride());
+ GeckoAppShell.setDisplayDpiOverride(settings.getDisplayDpiOverride());
+ GeckoAppShell.setScreenSizeOverride(settings.getScreenSizeOverride());
+ GeckoAppShell.setCrashHandlerService(settings.getCrashHandler());
+ GeckoFontScaleListener.getInstance().attachToContext(context, settings);
+
+ Bundle extras = settings.getExtras();
+ String[] args = settings.getArguments();
+ Map<String, Object> prefs = settings.getPrefsMap();
+
+ // Older versions have problems with SnakeYaml
+ String configFilePath = settings.getConfigFilePath();
+ if (configFilePath == null) {
+ // Default to /data/local/tmp/$PACKAGE-geckoview-config.yaml if android:debuggable="true"
+ // or if this application is the current Android "debug_app", and to not read configuration
+ // from a file otherwise.
+ if (isApplicationDebuggable(context) || isApplicationCurrentDebugApp(context)) {
+ configFilePath =
+ String.format(CONFIG_FILE_PATH_TEMPLATE, context.getApplicationInfo().packageName);
+ }
+ }
+
+ if (configFilePath != null && !configFilePath.isEmpty()) {
+ try {
+ final DebugConfig debugConfig = DebugConfig.fromFile(new File(configFilePath));
+ Log.i(LOGTAG, "Adding debug configuration from: " + configFilePath);
+ prefs = debugConfig.mergeIntoPrefs(prefs);
+ args = debugConfig.mergeIntoArgs(args);
+ extras = debugConfig.mergeIntoExtras(extras);
+ } catch (final DebugConfig.ConfigException e) {
+ Log.w(LOGTAG, "Failed to add debug configuration from: " + configFilePath, e);
+ } catch (final FileNotFoundException e) {
+ }
+ }
+
+ final GeckoThread.InitInfo info =
+ GeckoThread.InitInfo.builder()
+ .args(args)
+ .extras(extras)
+ .flags(flags)
+ .prefs(prefs)
+ .outFilePath(extras != null ? extras.getString("out_file") : null)
+ .build();
+
+ if (info.xpcshell
+ && !"org.mozilla.geckoview.test_runner"
+ .equals(context.getApplicationContext().getPackageName())) {
+ throw new IllegalArgumentException("Only the test app can run -xpcshell.");
+ }
+
+ if (info.xpcshell) {
+ // Xpcshell tests need multi-e10s to work properly
+ settings.setProcessCount(BuildConfig.MOZ_ANDROID_CONTENT_SERVICE_COUNT);
+ }
+
+ if (!GeckoThread.init(info)) {
+ Log.w(LOGTAG, "init failed (could not initiate GeckoThread)");
+ return false;
+ }
+
+ if (!GeckoThread.launch()) {
+ Log.w(LOGTAG, "init failed (GeckoThread already launched)");
+ return false;
+ }
+
+ mSettings = settings;
+
+ // Bug 1453062 -- the EventDispatcher should really live here (or in GeckoThread)
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mEventListener, "Gecko:Exited", "GeckoView:Test:NewTab");
+
+ // Attach and commit settings.
+ mSettings.attachTo(this);
+
+ // Initialize the system ClipboardManager by accessing it on the main thread.
+ GeckoAppShell.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE);
+
+ // Add process lifecycle listener to react to backgrounding events.
+ ProcessLifecycleOwner.get().getLifecycle().addObserver(new LifecycleListener());
+
+ // Add Display Manager listener to listen screen orientation change.
+ if (mScreenChangeListener != null) {
+ mScreenChangeListener.enable();
+ }
+
+ mProfilerController.addMarker(
+ "GeckoView Initialization START", mProfilerController.getProfilerTime());
+ return true;
+ }
+
+ private boolean isApplicationDebuggable(final @NonNull Context context) {
+ final ApplicationInfo applicationInfo = context.getApplicationInfo();
+ return (applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
+ }
+
+ private boolean isApplicationCurrentDebugApp(final @NonNull Context context) {
+ final ApplicationInfo applicationInfo = context.getApplicationInfo();
+
+ final String currentDebugApp =
+ Settings.Global.getString(context.getContentResolver(), Settings.Global.DEBUG_APP);
+ return applicationInfo.packageName.equals(currentDebugApp);
+ }
+
+ /* package */ void setDefaultPrefs(final GeckoBundle prefs) {
+ EventDispatcher.getInstance().dispatch("GeckoView:SetDefaultPrefs", prefs);
+ }
+
+ /**
+ * Create a new runtime with default settings and attach it to the given context.
+ *
+ * <p>Create will throw if there is already an active Gecko instance running, to prevent that,
+ * bind the runtime to the process lifetime instead of the activity lifetime.
+ *
+ * @param context The context of the runtime.
+ * @return An initialized runtime.
+ */
+ @UiThread
+ public static @NonNull GeckoRuntime create(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ return create(context, new GeckoRuntimeSettings());
+ }
+
+ /**
+ * Returns a WebExtensionController for this GeckoRuntime.
+ *
+ * @return an instance of {@link WebExtensionController}.
+ */
+ @UiThread
+ public @NonNull WebExtensionController getWebExtensionController() {
+ return mWebExtensionController;
+ }
+
+ /**
+ * Returns the ContentBlockingController for this GeckoRuntime.
+ *
+ * @return An instance of {@link ContentBlockingController}.
+ */
+ @UiThread
+ public @NonNull ContentBlockingController getContentBlockingController() {
+ return mContentBlockingController;
+ }
+
+ /**
+ * Returns a ProfilerController for this GeckoRuntime.
+ *
+ * @return an instance of {@link ProfilerController}.
+ */
+ @UiThread
+ public @NonNull ProfilerController getProfilerController() {
+ return mProfilerController;
+ }
+
+ /**
+ * Create a new runtime with the given settings and attach it to the given context.
+ *
+ * <p>Create will throw if there is already an active Gecko instance running, to prevent that,
+ * bind the runtime to the process lifetime instead of the activity lifetime.
+ *
+ * @param context The context of the runtime.
+ * @param settings The settings for the runtime.
+ * @return An initialized runtime.
+ */
+ @UiThread
+ public static @NonNull GeckoRuntime create(
+ final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "create " + context);
+ }
+
+ final GeckoRuntime runtime = new GeckoRuntime();
+ runtime.attachTo(context);
+
+ if (!runtime.init(context, settings)) {
+ throw new IllegalStateException("Failed to initialize GeckoRuntime");
+ }
+
+ context.registerComponentCallbacks(runtime.mMemoryController);
+
+ return runtime;
+ }
+
+ /** Shutdown the runtime. This will invalidate all attached sessions. */
+ @AnyThread
+ public void shutdown() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "shutdown");
+ }
+
+ GeckoSystemStateListener.getInstance().shutdown();
+
+ if (mScreenChangeListener != null) {
+ mScreenChangeListener.disable();
+ }
+
+ GeckoThread.forceQuit();
+ }
+
+ public interface Delegate {
+ /**
+ * This is called when the runtime shuts down. Any GeckoSession instances that were opened with
+ * this instance are now considered closed.
+ */
+ @UiThread
+ void onShutdown();
+ }
+
+ /**
+ * Set a delegate for receiving callbacks relevant to to this GeckoRuntime.
+ *
+ * @param delegate an implementation of {@link GeckoRuntime.Delegate}.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable Delegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Returns the current delegate, if any.
+ *
+ * @return an instance of {@link GeckoRuntime.Delegate} or null if no delegate has been set.
+ */
+ @UiThread
+ public @Nullable Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ /**
+ * Set the {@link Autocomplete.StorageDelegate} instance on this runtime. This delegate is
+ * required for handling autocomplete storage requests.
+ *
+ * @param delegate The {@link Autocomplete.StorageDelegate} handling autocomplete storage
+ * requests.
+ */
+ @UiThread
+ public void setAutocompleteStorageDelegate(
+ final @Nullable Autocomplete.StorageDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mAutocompleteStorageProxy.setDelegate(delegate);
+ }
+
+ /**
+ * Get the {@link Autocomplete.StorageDelegate} instance set on this runtime.
+ *
+ * @return The {@link Autocomplete.StorageDelegate} set on this runtime.
+ */
+ @UiThread
+ public @Nullable Autocomplete.StorageDelegate getAutocompleteStorageDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mAutocompleteStorageProxy.getDelegate();
+ }
+
+ @UiThread
+ public interface ServiceWorkerDelegate {
+
+ /**
+ * This is called when a service worker tries to open a new window using client.openWindow() The
+ * GeckoView application should provide an open {@link GeckoSession} to open the url.
+ *
+ * @param url Url which the Service Worker wishes to open in a new window.
+ * @return New or existing open {@link GeckoSession} in which to open the requested url.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service
+ * Worker API</a>
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow">openWindow()</a>
+ */
+ @UiThread
+ @NonNull
+ GeckoResult<GeckoSession> onOpenWindow(@NonNull String url);
+ }
+
+ /**
+ * Sets the {@link ServiceWorkerDelegate} to be used for Service Worker requests.
+ *
+ * @param serviceWorkerDelegate An instance of {@link ServiceWorkerDelegate}.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service
+ * Worker API</a>
+ */
+ @UiThread
+ public void setServiceWorkerDelegate(
+ final @Nullable ServiceWorkerDelegate serviceWorkerDelegate) {
+ mServiceWorkerDelegate = serviceWorkerDelegate;
+ }
+
+ /**
+ * Gets the {@link ServiceWorkerDelegate} to be used for Service Worker requests.
+ *
+ * @return the {@link ServiceWorkerDelegate} instance set by {@link #setServiceWorkerDelegate}
+ */
+ @UiThread
+ @Nullable
+ public ServiceWorkerDelegate getServiceWorkerDelegate() {
+ return mServiceWorkerDelegate;
+ }
+
+ /**
+ * Sets the delegate to be used for handling Web Notifications.
+ *
+ * @param delegate An instance of {@link WebNotificationDelegate}.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">Web
+ * Notifications</a>
+ */
+ @UiThread
+ public void setWebNotificationDelegate(final @Nullable WebNotificationDelegate delegate) {
+ mNotificationDelegate = delegate;
+ }
+
+ @WrapForJNI
+ /* package */ float textScaleFactor() {
+ return getSettings().getFontSizeFactor();
+ }
+
+ @WrapForJNI
+ /* package */ boolean usesDarkTheme() {
+ switch (getSettings().getPreferredColorScheme()) {
+ case GeckoRuntimeSettings.COLOR_SCHEME_SYSTEM:
+ return GeckoSystemStateListener.getInstance().isNightMode();
+ case GeckoRuntimeSettings.COLOR_SCHEME_DARK:
+ return true;
+ case GeckoRuntimeSettings.COLOR_SCHEME_LIGHT:
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Returns the current WebNotificationDelegate, if any
+ *
+ * @return an instance of WebNotificationDelegate or null if no delegate has been set
+ */
+ @WrapForJNI
+ @UiThread
+ public @Nullable WebNotificationDelegate getWebNotificationDelegate() {
+ return mNotificationDelegate;
+ }
+
+ @WrapForJNI
+ @AnyThread
+ private void notifyOnShow(final WebNotification notification) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ if (mNotificationDelegate != null) {
+ mNotificationDelegate.onShowNotification(notification);
+ }
+ });
+ }
+
+ @WrapForJNI
+ @AnyThread
+ private void notifyOnClose(final WebNotification notification) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ if (mNotificationDelegate != null) {
+ mNotificationDelegate.onCloseNotification(notification);
+ }
+ });
+ }
+
+ /**
+ * This is used to allow GeckoRuntime to start activities via the embedding application (and
+ * {@link android.app.Activity}). Currently this is used to invoke the Google Play FIDO Activity
+ * in order to integrate with the Web Authentication API.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API">Web
+ * Authentication API</a>
+ */
+ public interface ActivityDelegate {
+ /**
+ * Sometimes GeckoView needs the application to perform a {@link
+ * android.app.Activity#startActivityForResult(Intent, int)} on its behalf. Implementations of
+ * this method should call that based on the information in the passed {@link PendingIntent},
+ * collect the result, and resolve the returned {@link GeckoResult} with that data. If the
+ * Activity does not return {@link android.app.Activity#RESULT_OK}, the {@link GeckoResult} must
+ * be completed with an exception of your choosing.
+ *
+ * @param intent The {@link PendingIntent} to launch
+ * @return A {@link GeckoResult} that is eventually resolved with the Activity result.
+ */
+ @UiThread
+ @Nullable
+ GeckoResult<Intent> onStartActivityForResult(@NonNull PendingIntent intent);
+ }
+
+ /**
+ * Set the {@link ActivityDelegate} instance on this runtime. This delegate is used to provide
+ * GeckoView support for launching external activities and receiving results from those
+ * activities.
+ *
+ * @param delegate The {@link ActivityDelegate} handling intent launching requests.
+ */
+ @UiThread
+ public void setActivityDelegate(final @Nullable ActivityDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mActivityDelegate = delegate;
+ }
+
+ /**
+ * Get the {@link ActivityDelegate} instance set on this runtime, if any,
+ *
+ * @return The {@link ActivityDelegate} set on this runtime.
+ */
+ @UiThread
+ public @Nullable ActivityDelegate getActivityDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mActivityDelegate;
+ }
+
+ @AnyThread
+ /* package */ GeckoResult<Intent> startActivityForResult(final @NonNull PendingIntent intent) {
+ if (!ThreadUtils.isOnUiThread()) {
+ // Delegates expect to be called on the UI thread.
+ final GeckoResult<Intent> result = new GeckoResult<>();
+
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoResult<Intent> delegateResult = startActivityForResult(intent);
+ if (delegateResult != null) {
+ delegateResult.accept(
+ val -> result.complete(val), e -> result.completeExceptionally(e));
+ } else {
+ result.completeExceptionally(new IllegalStateException("No result"));
+ }
+ });
+
+ return result;
+ }
+
+ if (mActivityDelegate == null) {
+ return GeckoResult.fromException(new IllegalStateException("No delegate attached"));
+ }
+
+ @SuppressLint("WrongThread")
+ GeckoResult<Intent> result = mActivityDelegate.onStartActivityForResult(intent);
+ if (result == null) {
+ result = GeckoResult.fromException(new IllegalStateException("No result"));
+ }
+
+ return result;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull GeckoRuntimeSettings getSettings() {
+ return mSettings;
+ }
+
+ /** Notify Gecko that the screen orientation has changed. */
+ @UiThread
+ public void orientationChanged() {
+ ThreadUtils.assertOnUiThread();
+ GeckoScreenOrientation.getInstance().update();
+ }
+
+ /**
+ * Notify Gecko that the device configuration has changed.
+ *
+ * @param newConfig The new Configuration object, {@link android.content.res.Configuration}.
+ */
+ @UiThread
+ public void configurationChanged(final @NonNull Configuration newConfig) {
+ ThreadUtils.assertOnUiThread();
+ GeckoSystemStateListener.getInstance().updateNightMode(newConfig.uiMode);
+ }
+
+ /**
+ * Notify Gecko that the screen orientation has changed.
+ *
+ * @param newOrientation The new screen orientation, as retrieved e.g. from the current {@link
+ * android.content.res.Configuration}.
+ */
+ @UiThread
+ public void orientationChanged(final int newOrientation) {
+ ThreadUtils.assertOnUiThread();
+ GeckoScreenOrientation.getInstance().update(newOrientation);
+ }
+
+ /**
+ * Get the orientation controller for this runtime. The orientation controller can be used to
+ * manage changes to and locking of the screen orientation.
+ *
+ * @return The {@link OrientationController} for this instance.
+ */
+ @UiThread
+ public @NonNull OrientationController getOrientationController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mOrientationController == null) {
+ mOrientationController = new OrientationController();
+ }
+ return mOrientationController;
+ }
+
+ /**
+ * Converts GeckoScreenOrientation to ActivityInfo orientation
+ *
+ * @return A {@link ActivityInfo} orientation.
+ */
+ @AnyThread
+ private int toAndroidOrientation(final int geckoOrientation) {
+ if (geckoOrientation == ScreenOrientation.PORTRAIT_PRIMARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.PORTRAIT_SECONDARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_PRIMARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_SECONDARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.DEFAULT.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
+ } else if (geckoOrientation == ScreenOrientation.PORTRAIT.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.ANY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
+ }
+ return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ }
+
+ /**
+ * Lock screen orientation using OrientationController's onOrientationLock.
+ *
+ * @return A {@link GeckoResult} that resolves an orientation lock.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ private @NonNull GeckoResult<Boolean> lockScreenOrientation(final int aOrientation) {
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final OrientationController.OrientationDelegate delegate =
+ getOrientationController().getDelegate();
+ if (delegate == null) {
+ // Delegate is not set
+ res.completeExceptionally(new Exception("Not supported"));
+ return;
+ }
+ final GeckoResult<AllowOrDeny> response =
+ delegate.onOrientationLock(toAndroidOrientation(aOrientation));
+ if (response == null) {
+ // Delegate is default. So lock orientation is not implemented
+ res.completeExceptionally(new Exception("Not supported"));
+ return;
+ }
+ res.completeFrom(response.map(v -> v == AllowOrDeny.ALLOW));
+ });
+ return res;
+ }
+
+ /** Unlock screen orientation using OrientationController's onOrientationUnlock. */
+ @WrapForJNI(calledFrom = "gecko")
+ private void unlockScreenOrientation() {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final OrientationController.OrientationDelegate delegate =
+ getOrientationController().getDelegate();
+ if (delegate != null) {
+ delegate.onOrientationUnlock();
+ }
+ });
+ }
+
+ /**
+ * Get the storage controller for this runtime. The storage controller can be used to manage
+ * persistent storage data accumulated by {@link GeckoSession}.
+ *
+ * @return The {@link StorageController} for this instance.
+ */
+ @UiThread
+ public @NonNull StorageController getStorageController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mStorageController == null) {
+ mStorageController = new StorageController();
+ }
+ return mStorageController;
+ }
+
+ /**
+ * Get the Web Push controller for this runtime. The Web Push controller can be used to allow
+ * content to use the Web Push API.
+ *
+ * @return The {@link WebPushController} for this instance.
+ */
+ @UiThread
+ public @NonNull WebPushController getWebPushController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mPushController == null) {
+ mPushController = new WebPushController();
+ }
+
+ return mPushController;
+ }
+
+ /**
+ * Appends notes to crash report.
+ *
+ * @param notes The application notes to append to the crash report.
+ */
+ @AnyThread
+ public void appendAppNotesToCrashReport(@NonNull final String notes) {
+ final String notesWithNewLine = notes + "\n";
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ GeckoAppShell.nativeAppendAppNotesToCrashReport(notesWithNewLine);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ GeckoAppShell.class,
+ "nativeAppendAppNotesToCrashReport",
+ String.class,
+ notesWithNewLine);
+ }
+ // This function already adds a newline
+ GeckoAppShell.appendAppNotesToCrashReport(notes);
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeParcelable(mSettings, flags);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ mSettings = source.readParcelable(getClass().getClassLoader());
+ }
+
+ public static final Parcelable.Creator<GeckoRuntime> CREATOR =
+ new Parcelable.Creator<GeckoRuntime>() {
+ @Override
+ @AnyThread
+ public GeckoRuntime createFromParcel(final Parcel in) {
+ final GeckoRuntime runtime = new GeckoRuntime();
+ runtime.readFromParcel(in);
+ return runtime;
+ }
+
+ @Override
+ @AnyThread
+ public GeckoRuntime[] newArray(final int size) {
+ return new GeckoRuntime[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java
new file mode 100644
index 0000000000..3da044e603
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java
@@ -0,0 +1,1729 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import static android.os.Build.VERSION;
+
+import android.app.Service;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.os.LocaleList;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoSystemStateListener;
+import org.mozilla.gecko.util.GeckoBundle;
+
+@AnyThread
+public final class GeckoRuntimeSettings extends RuntimeSettings {
+ private static final String LOGTAG = "GeckoRuntimeSettings";
+
+ /** Settings builder used to construct the settings object. */
+ @AnyThread
+ public static final class Builder extends RuntimeSettings.Builder<GeckoRuntimeSettings> {
+ @Override
+ protected @NonNull GeckoRuntimeSettings newSettings(
+ final @Nullable GeckoRuntimeSettings settings) {
+ return new GeckoRuntimeSettings(settings);
+ }
+
+ /**
+ * Set the custom Gecko process arguments.
+ *
+ * @param args The Gecko process arguments.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder arguments(final @NonNull String[] args) {
+ if (args == null) {
+ throw new IllegalArgumentException("Arguments must not be null");
+ }
+ getSettings().mArgs = args;
+ return this;
+ }
+
+ /**
+ * Set the custom Gecko intent extras.
+ *
+ * @param extras The Gecko intent extras.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder extras(final @NonNull Bundle extras) {
+ if (extras == null) {
+ throw new IllegalArgumentException("Extras must not be null");
+ }
+ getSettings().mExtras = extras;
+ return this;
+ }
+
+ /**
+ * Path to configuration file from which GeckoView will read configuration options such as Gecko
+ * process arguments, environment variables, and preferences.
+ *
+ * <p>Note: this feature is only available for <code>{@link VERSION#SDK_INT} &gt; 21</code>, on
+ * older devices this will be silently ignored.
+ *
+ * @param configFilePath Configuration file path to read from, or <code>null</code> to use
+ * default location <code>/data/local/tmp/$PACKAGE-geckoview-config.yaml</code>.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder configFilePath(final @Nullable String configFilePath) {
+ getSettings().mConfigFilePath = configFilePath;
+ return this;
+ }
+
+ /**
+ * Set whether Extensions Process support should be enabled.
+ *
+ * @param flag A flag determining whether Extensions Process support should be enabled. Default
+ * is false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder extensionsProcessEnabled(final boolean flag) {
+ getSettings().mExtensionsProcess.set(flag);
+ return this;
+ }
+
+ /**
+ * Set the crash threshold within the timeframe before spawning is disabled for the remote
+ * extensions process.
+ *
+ * @param crashThreshold The crash threshold within the timeframe before spawning is disabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder extensionsProcessCrashThreshold(final @NonNull Integer crashThreshold) {
+ getSettings().mExtensionsProcessCrashThreshold.set(crashThreshold);
+ return this;
+ }
+
+ /**
+ * Set the crash threshold timeframe before spawning is disabled for the remote extensions
+ * process. Crashes that are older than the current time minus timeframeMs will not be counted
+ * towards meeting the threshold.
+ *
+ * @param timeframeMs The timeframe for the crash threshold in milliseconds. Any crashes older
+ * than the current time minus the timeframeMs are not counted.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder extensionsProcessCrashTimeframe(final @NonNull Long timeframeMs) {
+ getSettings().mExtensionsProcessCrashTimeframe.set(timeframeMs);
+ return this;
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param flag A flag determining whether JavaScript should be enabled. Default is true.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder javaScriptEnabled(final boolean flag) {
+ getSettings().mJavaScript.set(flag);
+ return this;
+ }
+
+ /**
+ * Set whether Global Privacy Control should be enabled. GPC is a mechanism for people to tell
+ * websites to respect their privacy rights. Once turned on, it sends a signal to the websites
+ * users visit telling them that the user doesn't want to be tracked and doesn't want their data
+ * to be sold.
+ *
+ * @param enabled A flag determining whether Global Privacy Control should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder globalPrivacyControlEnabled(final boolean enabled) {
+ getSettings().setGlobalPrivacyControl(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether remote debugging support should be enabled.
+ *
+ * @param enabled True if remote debugging should be enabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder remoteDebuggingEnabled(final boolean enabled) {
+ getSettings().mRemoteDebugging.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether support for web fonts should be enabled.
+ *
+ * @param flag A flag determining whether web fonts should be enabled. Default is true.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder webFontsEnabled(final boolean flag) {
+ getSettings().mWebFonts.set(flag ? 1 : 0);
+ return this;
+ }
+
+ /**
+ * Set whether there should be a pause during startup. This is useful if you need to wait for a
+ * debugger to attach.
+ *
+ * @param enabled A flag determining whether there will be a pause early in startup. Defaults to
+ * false.
+ * @return This Builder.
+ */
+ public @NonNull Builder pauseForDebugger(final boolean enabled) {
+ getSettings().mDebugPause = enabled;
+ return this;
+ }
+
+ /**
+ * Set whether the to report the full bit depth of the device.
+ *
+ * <p>By default, 24 bits are reported for high memory devices and 16 bits for low memory
+ * devices. If set to true, the device's maximum bit depth is reported. On most modern devices
+ * this will be 32 bit screen depth.
+ *
+ * @param enable A flag determining whether maximum screen depth should be used.
+ * @return This Builder.
+ */
+ public @NonNull Builder useMaxScreenDepth(final boolean enable) {
+ getSettings().mUseMaxScreenDepth = enable;
+ return this;
+ }
+
+ /**
+ * Set whether web manifest support is enabled.
+ *
+ * <p>This controls if Gecko actually downloads, or "obtains", web manifests and processes them.
+ * Without setting this pref, trying to obtain a manifest throws.
+ *
+ * @param enabled A flag determining whether Web Manifest processing support is enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder webManifest(final boolean enabled) {
+ getSettings().mWebManifest.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not web console messages should go to logcat.
+ *
+ * <p>Note: If enabled, Gecko performance may be negatively impacted if content makes heavy use
+ * of the console API.
+ *
+ * @param enabled A flag determining whether or not web console messages should be printed to
+ * logcat.
+ * @return The builder instance.
+ */
+ public @NonNull Builder consoleOutput(final boolean enabled) {
+ getSettings().mConsoleOutput.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not font sizes in web content should be automatically scaled according to the
+ * device's current system font scale setting.
+ *
+ * @param enabled A flag determining whether or not font sizes should be scaled automatically to
+ * match the device's system font scale.
+ * @return The builder instance.
+ */
+ public @NonNull Builder automaticFontSizeAdjustment(final boolean enabled) {
+ getSettings().setAutomaticFontSizeAdjustment(enabled);
+ return this;
+ }
+
+ /**
+ * Set a font size factor that will operate as a global text zoom. All font sizes will be
+ * multiplied by this factor.
+ *
+ * <p>The default factor is 1.0.
+ *
+ * <p>This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic
+ * font size adjustment} has already been enabled.
+ *
+ * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0
+ * disables both this feature and {@link Builder#fontInflation font inflation}.
+ * @return The builder instance.
+ */
+ public @NonNull Builder fontSizeFactor(final float fontSizeFactor) {
+ getSettings().setFontSizeFactor(fontSizeFactor);
+ return this;
+ }
+
+ /**
+ * Enable the Enterprise Roots feature.
+ *
+ * <p>When Enabled, GeckoView will fetch the third-party root certificates added to the Android
+ * OS CA store and will use them internally.
+ *
+ * @param enabled whether to enable this feature or not
+ * @return The builder instance
+ */
+ public @NonNull Builder enterpriseRootsEnabled(final boolean enabled) {
+ getSettings().setEnterpriseRootsEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not font inflation for non mobile-friendly pages should be enabled. The
+ * default value of this setting is <code>false</code>.
+ *
+ * <p>When enabled, font sizes will be increased on all pages that are lacking a &lt;meta&gt;
+ * viewport tag and have been loaded in a session using {@link
+ * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic
+ * will attempt to increase font sizes for the main text content of the page only.
+ *
+ * <p>The magnitude of font inflation applied depends on the {@link Builder#fontSizeFactor font
+ * size factor} currently in use.
+ *
+ * <p>This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic
+ * font size adjustment} has already been enabled.
+ *
+ * @param enabled A flag determining whether or not font inflation should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder fontInflation(final boolean enabled) {
+ getSettings().setFontInflationEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Set the display density override.
+ *
+ * @param density The display density value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder displayDensityOverride(final float density) {
+ getSettings().mDisplayDensityOverride = density;
+ return this;
+ }
+
+ /**
+ * Set the display DPI override.
+ *
+ * @param dpi The display DPI value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder displayDpiOverride(final int dpi) {
+ getSettings().mDisplayDpiOverride = dpi;
+ return this;
+ }
+
+ /**
+ * Set the screen size override.
+ *
+ * @param width The screen width value to use for overriding the system default.
+ * @param height The screen height value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder screenSizeOverride(final int width, final int height) {
+ getSettings().mScreenWidthOverride = width;
+ getSettings().mScreenHeightOverride = height;
+ return this;
+ }
+
+ /**
+ * Set whether login forms should be filled automatically if only one viable candidate is
+ * provided via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}.
+ *
+ * @param enabled A flag determining whether login autofill should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder loginAutofillEnabled(final boolean enabled) {
+ getSettings().setLoginAutofillEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether a candidate page should automatically offer a translation via a popup.
+ *
+ * @param enabled A flag determining whether the translations offer popup should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder translationsOfferPopup(final boolean enabled) {
+ getSettings().setTranslationsOfferPopup(enabled);
+ return this;
+ }
+
+ /**
+ * When set, the specified {@link android.app.Service} will be started by an {@link
+ * android.content.Intent} with action {@link GeckoRuntime#ACTION_CRASHED} when a crash is
+ * encountered. Crash details can be found in the Intent extras, such as {@link
+ * GeckoRuntime#EXTRA_MINIDUMP_PATH}. <br>
+ * <br>
+ * The crash handler Service must be declared to run in a different process from the {@link
+ * GeckoRuntime}. Additionally, the handler will be run as a foreground service, so the normal
+ * rules about activating a foreground service apply. <br>
+ * <br>
+ * In practice, you have one of three options once the crash handler is started:
+ *
+ * <ul>
+ * <li>Call {@link android.app.Service#startForeground(int, android.app.Notification)}. You
+ * can then take as much time as necessary to report the crash.
+ * <li>Start an activity. Unless you also call {@link android.app.Service#startForeground(int,
+ * android.app.Notification)} this should be in a different process from the crash
+ * handler, since Android will kill the crash handler process as part of the background
+ * execution limitations.
+ * <li>Schedule work via {@link android.app.job.JobScheduler}. This will allow you to do
+ * substantial work in the background without execution limits.
+ * </ul>
+ *
+ * <br>
+ * You can use {@link CrashReporter} to send the report to Mozilla, which provides Mozilla with
+ * data needed to fix the crash. Be aware that the minidump may contain personally identifiable
+ * information (PII). Consult Mozilla's <a href="https://www.mozilla.org/en-US/privacy/">privacy
+ * policy</a> for information on how this data will be handled.
+ *
+ * @param handler The class for the crash handler Service.
+ * @return This builder instance.
+ * @see <a href="https://developer.android.com/about/versions/oreo/background">Android
+ * Background Execution Limits</a>
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ public @NonNull Builder crashHandler(final @Nullable Class<? extends Service> handler) {
+ getSettings().mCrashHandler = handler;
+ return this;
+ }
+
+ /**
+ * Set the locale.
+ *
+ * @param requestedLocales List of locale codes in Gecko format ("en" or "en-US").
+ * @return The builder instance.
+ */
+ public @NonNull Builder locales(final @Nullable String[] requestedLocales) {
+ getSettings().mRequestedLocales = requestedLocales;
+ return this;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull Builder contentBlocking(final @NonNull ContentBlocking.Settings cb) {
+ getSettings().mContentBlocking = cb;
+ return this;
+ }
+
+ /**
+ * Sets the preferred color scheme override for web content.
+ *
+ * @param scheme The preferred color scheme. Must be one of the {@link
+ * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder preferredColorScheme(final @ColorScheme int scheme) {
+ getSettings().setPreferredColorScheme(scheme);
+ return this;
+ }
+
+ /**
+ * Set whether auto-zoom to editable fields should be enabled.
+ *
+ * @param flag True if auto-zoom should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder inputAutoZoomEnabled(final boolean flag) {
+ getSettings().mInputAutoZoom.set(flag);
+ return this;
+ }
+
+ /**
+ * Set whether double tap zooming should be enabled.
+ *
+ * @param flag True if double tap zooming should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder doubleTapZoomingEnabled(final boolean flag) {
+ getSettings().mDoubleTapZooming.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets the WebGL MSAA level.
+ *
+ * @param level number of MSAA samples, 0 if MSAA should be disabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder glMsaaLevel(final int level) {
+ getSettings().mGlMsaaLevel.set(level);
+ return this;
+ }
+
+ /**
+ * Add a {@link RuntimeTelemetry.Delegate} instance to this GeckoRuntime. This delegate can be
+ * used by the app to receive streaming telemetry data from GeckoView.
+ *
+ * @param delegate the delegate that will handle telemetry
+ * @return The builder instance.
+ */
+ public @NonNull Builder telemetryDelegate(final @NonNull RuntimeTelemetry.Delegate delegate) {
+ getSettings().mTelemetryProxy = new RuntimeTelemetry.Proxy(delegate);
+ getSettings().mTelemetryEnabled.set(true);
+ return this;
+ }
+
+ /**
+ * Set the {@link ExperimentDelegate} instance on this runtime, if any. This delegate is used to
+ * send and receive experiment information from Nimbus.
+ *
+ * @param delegate The {@link ExperimentDelegate} sending and retrieving experiment information.
+ * @return The builder instance.
+ */
+ @AnyThread
+ public @NonNull Builder experimentDelegate(final @Nullable ExperimentDelegate delegate) {
+ getSettings().mExperimentDelegate = delegate;
+ return this;
+ }
+
+ /**
+ * Enables GeckoView and Gecko Logging. Logging is on by default. Does not control all logging
+ * in Gecko. Logging done in Java code must be stripped out at build time.
+ *
+ * @param enable True if logging is enabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder debugLogging(final boolean enable) {
+ getSettings().mDevToolsConsoleToLogcat.set(enable);
+ getSettings().mConsoleServiceToLogcat.set(enable);
+ getSettings().mGeckoViewLogLevel.set(enable ? "Debug" : "Fatal");
+ return this;
+ }
+
+ /**
+ * Sets whether or not about:config should be enabled. This is a page that allows users to
+ * directly modify Gecko preferences. Modification of some preferences may cause the app to
+ * break in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc.
+ *
+ * @param flag True if about:config should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder aboutConfigEnabled(final boolean flag) {
+ getSettings().mAboutConfig.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets whether or not pinch-zooming should be enabled when <code>user-scalable=no</code> is set
+ * on the viewport.
+ *
+ * @param flag True if force user scalable zooming should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder forceUserScalableEnabled(final boolean flag) {
+ getSettings().mForceUserScalable.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder allowInsecureConnections(final @HttpsOnlyMode int level) {
+ getSettings().setAllowInsecureConnections(level);
+ return this;
+ }
+
+ /**
+ * Sets whether the Add-on Manager web API (`mozAddonManager`) is enabled.
+ *
+ * @param flag True if the web API should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder extensionsWebAPIEnabled(final boolean flag) {
+ getSettings().mExtensionsWebAPIEnabled.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets whether and how DNS-over-HTTPS (Trusted Recursive Resolver) is configured.
+ *
+ * @param mode One of the {@link GeckoRuntimeSettings#TRR_MODE_OFF TrustedRecursiveResolverMode}
+ * constants.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder trustedRecursiveResolverMode(
+ final @TrustedRecursiveResolverMode int mode) {
+ getSettings().setTrustedRecursiveResolverMode(mode);
+ return this;
+ }
+
+ /**
+ * Set the DNS-over-HTTPS server URI.
+ *
+ * @param uri URI of the DNS-over-HTTPS server.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder trustedRecursiveResolverUri(final @NonNull String uri) {
+ getSettings().setTrustedRecursiveResolverUri(uri);
+ return this;
+ }
+
+ /**
+ * Set the factor by which to increase the keepalive timeout when the NS_HTTP_LARGE_KEEPALIVE
+ * flag is used for a connection.
+ *
+ * @param factor FACTOR by which to increase the keepalive timeout.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder largeKeepaliveFactor(final int factor) {
+ getSettings().setLargeKeepaliveFactor(factor);
+ return this;
+ }
+ }
+
+ private GeckoRuntime mRuntime;
+ /* package */ String[] mArgs;
+ /* package */ Bundle mExtras;
+ /* package */ String mConfigFilePath;
+
+ /* package */ ContentBlocking.Settings mContentBlocking;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull ContentBlocking.Settings getContentBlocking() {
+ return mContentBlocking;
+ }
+
+ /* package */ final Pref<Boolean> mWebManifest = new Pref<Boolean>("dom.manifest.enabled", true);
+ /* package */ final Pref<Boolean> mJavaScript = new Pref<Boolean>("javascript.enabled", true);
+ /* package */ final Pref<Boolean> mRemoteDebugging =
+ new Pref<Boolean>("devtools.debugger.remote-enabled", false);
+ /* package */ final Pref<Integer> mWebFonts =
+ new Pref<Integer>("browser.display.use_document_fonts", 1);
+ /* package */ final Pref<Boolean> mConsoleOutput =
+ new Pref<Boolean>("geckoview.console.enabled", false);
+ /* package */ float mFontSizeFactor = 1f;
+ /* package */ final Pref<Boolean> mEnterpriseRootsEnabled =
+ new Pref<>("security.enterprise_roots.enabled", false);
+ /* package */ final Pref<Integer> mFontInflationMinTwips =
+ new Pref<>("font.size.inflation.minTwips", 0);
+ /* package */ final Pref<Boolean> mInputAutoZoom = new Pref<>("formhelper.autozoom", true);
+ /* package */ final Pref<Boolean> mDoubleTapZooming =
+ new Pref<>("apz.allow_double_tap_zooming", true);
+ /* package */ final Pref<Integer> mGlMsaaLevel = new Pref<>("webgl.msaa-samples", 4);
+ /* package */ final Pref<Boolean> mTelemetryEnabled =
+ new Pref<>("toolkit.telemetry.geckoview.streaming", false);
+ /* package */ final Pref<String> mGeckoViewLogLevel =
+ new Pref<>("geckoview.logging", BuildConfig.DEBUG_BUILD ? "Debug" : "Warn");
+ /* package */ final Pref<Boolean> mConsoleServiceToLogcat =
+ new Pref<>("consoleservice.logcat", true);
+ /* package */ final Pref<Boolean> mDevToolsConsoleToLogcat =
+ new Pref<>("devtools.console.stdout.chrome", true);
+ /* package */ final Pref<Boolean> mAboutConfig = new Pref<>("general.aboutConfig.enable", false);
+ /* package */ final Pref<Boolean> mForceUserScalable =
+ new Pref<>("browser.ui.zoom.force-user-scalable", false);
+ /* package */ final Pref<Boolean> mAutofillLogins =
+ new Pref<Boolean>("signon.autofillForms", true);
+ /* package */ final Pref<Boolean> mAutomaticallyOfferPopup =
+ new Pref<Boolean>("browser.translations.automaticallyPopup", true);
+ /* package */ final Pref<Boolean> mHttpsOnly =
+ new Pref<Boolean>("dom.security.https_only_mode", false);
+ /* package */ final Pref<Boolean> mHttpsOnlyPrivateMode =
+ new Pref<Boolean>("dom.security.https_only_mode_pbm", false);
+ /* package */ final PrefWithoutDefault<Integer> mTrustedRecursiveResolverMode =
+ new PrefWithoutDefault<>("network.trr.mode");
+ /* package */ final PrefWithoutDefault<String> mTrustedRecursiveResolverUri =
+ new PrefWithoutDefault<>("network.trr.uri");
+ /* package */ final PrefWithoutDefault<Integer> mLargeKeepalivefactor =
+ new PrefWithoutDefault<>("network.http.largeKeepaliveFactor");
+ /* package */ final Pref<Integer> mProcessCount = new Pref<>("dom.ipc.processCount", 2);
+ /* package */ final Pref<Boolean> mExtensionsWebAPIEnabled =
+ new Pref<>("extensions.webapi.enabled", false);
+ /* package */ final PrefWithoutDefault<Boolean> mExtensionsProcess =
+ new PrefWithoutDefault<Boolean>("extensions.webextensions.remote");
+ /* package */ final PrefWithoutDefault<Long> mExtensionsProcessCrashTimeframe =
+ new PrefWithoutDefault<Long>("extensions.webextensions.crash.timeframe");
+ /* package */ final PrefWithoutDefault<Integer> mExtensionsProcessCrashThreshold =
+ new PrefWithoutDefault<Integer>("extensions.webextensions.crash.threshold");
+ /* package */ final Pref<Boolean> mGlobalPrivacyControlEnabled =
+ new Pref<Boolean>("privacy.globalprivacycontrol.enabled", false);
+ /* package */ final Pref<Boolean> mGlobalPrivacyControlEnabledPrivateMode =
+ new Pref<Boolean>("privacy.globalprivacycontrol.pbmode.enabled", true);
+ /* package */ final Pref<Boolean> mGlobalPrivacyControlFunctionalityEnabled =
+ new Pref<Boolean>("privacy.globalprivacycontrol.functionality.enabled", true);
+
+ /* package */ int mPreferredColorScheme = COLOR_SCHEME_SYSTEM;
+
+ /* package */ boolean mForceEnableAccessibility;
+ /* package */ boolean mDebugPause;
+ /* package */ boolean mUseMaxScreenDepth;
+ /* package */ float mDisplayDensityOverride = -1.0f;
+ /* package */ int mDisplayDpiOverride;
+ /* package */ int mScreenWidthOverride;
+ /* package */ int mScreenHeightOverride;
+ /* package */ Class<? extends Service> mCrashHandler;
+ /* package */ String[] mRequestedLocales;
+ /* package */ RuntimeTelemetry.Proxy mTelemetryProxy;
+ /* package */ ExperimentDelegate mExperimentDelegate;
+
+ /**
+ * Attach and commit the settings to the given runtime.
+ *
+ * @param runtime The runtime to attach to.
+ */
+ /* package */ void attachTo(final @NonNull GeckoRuntime runtime) {
+ mRuntime = runtime;
+ commit();
+
+ if (mTelemetryProxy != null) {
+ mTelemetryProxy.attach();
+ }
+ }
+
+ @Override // RuntimeSettings
+ public @Nullable GeckoRuntime getRuntime() {
+ return mRuntime;
+ }
+
+ /* package */ GeckoRuntimeSettings() {
+ this(null);
+ }
+
+ /* package */ GeckoRuntimeSettings(final @Nullable GeckoRuntimeSettings settings) {
+ super(/* parent */ null);
+
+ if (settings == null) {
+ mArgs = new String[0];
+ mExtras = new Bundle();
+ mContentBlocking = new ContentBlocking.Settings(this /* parent */, null /* settings */);
+ return;
+ }
+
+ updateSettings(settings);
+ }
+
+ private void updateSettings(final @NonNull GeckoRuntimeSettings settings) {
+ updatePrefs(settings);
+
+ mArgs = settings.getArguments().clone();
+ mExtras = new Bundle(settings.getExtras());
+ mContentBlocking = new ContentBlocking.Settings(this /* parent */, settings.mContentBlocking);
+
+ mForceEnableAccessibility = settings.mForceEnableAccessibility;
+ mDebugPause = settings.mDebugPause;
+ mUseMaxScreenDepth = settings.mUseMaxScreenDepth;
+ mDisplayDensityOverride = settings.mDisplayDensityOverride;
+ mDisplayDpiOverride = settings.mDisplayDpiOverride;
+ mScreenWidthOverride = settings.mScreenWidthOverride;
+ mScreenHeightOverride = settings.mScreenHeightOverride;
+ mCrashHandler = settings.mCrashHandler;
+ mRequestedLocales = settings.mRequestedLocales;
+ mConfigFilePath = settings.mConfigFilePath;
+ mTelemetryProxy = settings.mTelemetryProxy;
+ mExperimentDelegate = settings.mExperimentDelegate;
+ }
+
+ /* package */ void commit() {
+ commitLocales();
+ commitResetPrefs();
+ }
+
+ /**
+ * Get the custom Gecko process arguments.
+ *
+ * @return The Gecko process arguments.
+ */
+ public @NonNull String[] getArguments() {
+ return mArgs;
+ }
+
+ /**
+ * Get the custom Gecko intent extras.
+ *
+ * @return The Gecko intent extras.
+ */
+ public @NonNull Bundle getExtras() {
+ return mExtras;
+ }
+
+ /**
+ * Path to configuration file from which GeckoView will read configuration options such as Gecko
+ * process arguments, environment variables, and preferences.
+ *
+ * <p>Note: this feature is only available for <code>{@link VERSION#SDK_INT} &gt; 21</code>.
+ *
+ * @return Path to configuration file from which GeckoView will read configuration options, or
+ * <code>null</code> for default location <code>/data/local/tmp/$PACKAGE-geckoview-config.yaml
+ * </code>.
+ */
+ public @Nullable String getConfigFilePath() {
+ return mConfigFilePath;
+ }
+
+ /**
+ * Get whether JavaScript support is enabled.
+ *
+ * @return Whether JavaScript support is enabled.
+ */
+ public boolean getJavaScriptEnabled() {
+ return mJavaScript.get();
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param flag A flag determining whether JavaScript should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setJavaScriptEnabled(final boolean flag) {
+ mJavaScript.commit(flag);
+ return this;
+ }
+
+ /**
+ * Enable the Global Privacy Control Feature.
+ *
+ * <p>Note: Global Privacy Control is always enabled in private mode.
+ *
+ * @param enabled A flag determining whether GPC should be enabled.
+ * @return This GeckoRuntimeSettings instance
+ */
+ public @NonNull GeckoRuntimeSettings setGlobalPrivacyControl(final boolean enabled) {
+ mGlobalPrivacyControlEnabled.commit(enabled);
+ // Global Privacy Control Feature is enabled by default in private browsing.
+ mGlobalPrivacyControlEnabledPrivateMode.commit(true);
+ mGlobalPrivacyControlFunctionalityEnabled.commit(true);
+ return this;
+ }
+
+ /**
+ * Get whether Extensions Process support is enabled.
+ *
+ * @return Whether Extensions Process support is enabled.
+ */
+ public @Nullable Boolean getExtensionsProcessEnabled() {
+ return mExtensionsProcess.get();
+ }
+
+ /**
+ * Set whether Extensions Process support should be enabled.
+ *
+ * @param flag A flag determining whether Extensions Process support should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setExtensionsProcessEnabled(final boolean flag) {
+ mExtensionsProcess.commit(flag);
+ return this;
+ }
+
+ /**
+ * Get the crash threshold before spawning is disabled for the remote extensions process.
+ *
+ * @return the crash threshold
+ */
+ public @Nullable Integer getExtensionsProcessCrashThreshold() {
+ return mExtensionsProcessCrashThreshold.get();
+ }
+
+ /**
+ * Get the timeframe in milliseconds for the threshold before spawning is disabled for the remote
+ * extensions process.
+ *
+ * @return the timeframe in milliseconds for the crash threshold
+ */
+ public @Nullable Long getExtensionsProcessCrashTimeframe() {
+ return mExtensionsProcessCrashTimeframe.get();
+ }
+
+ /**
+ * Set the crash threshold before disabling spawning of the extensions remote process.
+ *
+ * @param crashThreshold max crashes allowed
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setExtensionsProcessCrashThreshold(
+ final @NonNull Integer crashThreshold) {
+ mExtensionsProcessCrashThreshold.commit(crashThreshold);
+ return this;
+ }
+
+ /**
+ * Set the timeframe for the extensions process crash threshold. Any crashes older than the
+ * current time minus the timeframe are not included in the crash count.
+ *
+ * @param timeframeMs time in milliseconds
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setExtensionsProcessCrashTimeframe(
+ final @NonNull Long timeframeMs) {
+ mExtensionsProcessCrashTimeframe.commit(timeframeMs);
+ return this;
+ }
+
+ /**
+ * Get whether remote debugging support is enabled.
+ *
+ * @return True if remote debugging support is enabled.
+ */
+ public boolean getRemoteDebuggingEnabled() {
+ return mRemoteDebugging.get();
+ }
+
+ /**
+ * Set whether remote debugging support should be enabled.
+ *
+ * @param enabled True if remote debugging should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setRemoteDebuggingEnabled(final boolean enabled) {
+ mRemoteDebugging.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether web fonts support is enabled.
+ *
+ * @return Whether web fonts support is enabled.
+ */
+ public boolean getWebFontsEnabled() {
+ return mWebFonts.get() != 0;
+ }
+
+ /**
+ * Set whether support for web fonts should be enabled.
+ *
+ * @param flag A flag determining whether web fonts should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setWebFontsEnabled(final boolean flag) {
+ mWebFonts.commit(flag ? 1 : 0);
+ return this;
+ }
+
+ /**
+ * Gets whether the pause-for-debugger is enabled or not.
+ *
+ * @return True if the pause is enabled.
+ */
+ public boolean getPauseForDebuggerEnabled() {
+ return mDebugPause;
+ }
+
+ /**
+ * Gets whether accessibility is force enabled or not.
+ *
+ * @return true if accessibility is force enabled.
+ */
+ public boolean getForceEnableAccessibility() {
+ return mForceEnableAccessibility;
+ }
+
+ /**
+ * Sets whether accessibility is force enabled or not.
+ *
+ * <p>Useful when testing accessibility.
+ *
+ * @param value whether accessibility is force enabled or not
+ * @return this GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setForceEnableAccessibility(final boolean value) {
+ mForceEnableAccessibility = value;
+ SessionAccessibility.setForceEnabled(value);
+ return this;
+ }
+
+ /**
+ * Gets whether the compositor should use the maximum screen depth when rendering.
+ *
+ * @return True if the maximum screen depth should be used.
+ */
+ public boolean getUseMaxScreenDepth() {
+ return mUseMaxScreenDepth;
+ }
+
+ /**
+ * Gets the display density override value.
+ *
+ * @return Returns a positive number. Will return null if not set.
+ */
+ public @Nullable Float getDisplayDensityOverride() {
+ if (mDisplayDensityOverride > 0.0f) {
+ return mDisplayDensityOverride;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the display DPI override value.
+ *
+ * @return Returns a positive number. Will return null if not set.
+ */
+ public @Nullable Integer getDisplayDpiOverride() {
+ if (mDisplayDpiOverride > 0) {
+ return mDisplayDpiOverride;
+ }
+ return null;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable Class<? extends Service> getCrashHandler() {
+ return mCrashHandler;
+ }
+
+ /**
+ * Gets the screen size override value.
+ *
+ * @return Returns a Rect containing the dimensions to use for the window size. Will return null
+ * if not set.
+ */
+ public @Nullable Rect getScreenSizeOverride() {
+ if ((mScreenWidthOverride > 0) && (mScreenHeightOverride > 0)) {
+ return new Rect(0, 0, mScreenWidthOverride, mScreenHeightOverride);
+ }
+ return null;
+ }
+
+ /**
+ * Gets the list of requested locales.
+ *
+ * @return A list of locale codes in Gecko format ("en" or "en-US").
+ */
+ public @Nullable String[] getLocales() {
+ return mRequestedLocales;
+ }
+
+ /**
+ * Set the locale.
+ *
+ * @param requestedLocales An ordered list of locales in Gecko format ("en-US").
+ */
+ public void setLocales(final @Nullable String[] requestedLocales) {
+ mRequestedLocales = requestedLocales;
+ commitLocales();
+ }
+
+ /**
+ * Gets whether the Add-on Manager web API (`mozAddonManager`) is enabled.
+ *
+ * @return True when the web API is enabled, false otherwise.
+ */
+ public boolean getExtensionsWebAPIEnabled() {
+ return mExtensionsWebAPIEnabled.get();
+ }
+
+ /**
+ * Get whether or not Global Privacy Control is currently enabled for normal tabs.
+ *
+ * @return True if GPC is enabled in normal tabs.
+ */
+ public boolean getGlobalPrivacyControl() {
+ return mGlobalPrivacyControlEnabled.get();
+ }
+
+ /**
+ * Get whether or not Global Privacy Control is currently enabled for private tabs.
+ *
+ * @return True if GPC is enabled in private tabs.
+ */
+ public boolean getGlobalPrivacyControlPrivateMode() {
+ return mGlobalPrivacyControlEnabledPrivateMode.get();
+ }
+
+ /**
+ * Sets whether the Add-on Manager web API (`mozAddonManager`) is enabled.
+ *
+ * @param flag True if the web API should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setExtensionsWebAPIEnabled(final boolean flag) {
+ mExtensionsWebAPIEnabled.commit(flag);
+ return this;
+ }
+
+ private void commitLocales() {
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putStringArray("requestedLocales", mRequestedLocales);
+ data.putString("acceptLanguages", computeAcceptLanguages());
+ EventDispatcher.getInstance().dispatch("GeckoView:SetLocale", data);
+ }
+
+ private String computeAcceptLanguages() {
+ final LinkedHashMap<String, String> locales = new LinkedHashMap<>();
+
+ // Explicitly-set app prefs come first:
+ if (mRequestedLocales != null) {
+ for (final String locale : mRequestedLocales) {
+ locales.put(locale.toLowerCase(Locale.ROOT), locale);
+ }
+ }
+ // OS prefs come second:
+ for (final String locale : getDefaultLocales()) {
+ final String localeLowerCase = locale.toLowerCase(Locale.ROOT);
+ if (!locales.containsKey(localeLowerCase)) {
+ locales.put(localeLowerCase, locale);
+ }
+ }
+
+ return TextUtils.join(",", locales.values());
+ }
+
+ private static String[] getDefaultLocales() {
+ if (VERSION.SDK_INT >= 24) {
+ final LocaleList localeList = LocaleList.getDefault();
+ final String[] locales = new String[localeList.size()];
+ for (int i = 0; i < localeList.size(); i++) {
+ locales[i] = localeList.get(i).toLanguageTag();
+ }
+ return locales;
+ }
+ final String[] locales = new String[1];
+ final Locale locale = Locale.getDefault();
+ locales[0] = locale.toLanguageTag();
+ return locales;
+ }
+
+ private static String getLanguageTag(final Locale locale) {
+ final StringBuilder out = new StringBuilder(locale.getLanguage());
+ final String country = locale.getCountry();
+ final String variant = locale.getVariant();
+ if (!TextUtils.isEmpty(country)) {
+ out.append('-').append(country);
+ }
+ if (!TextUtils.isEmpty(variant)) {
+ out.append('-').append(variant);
+ }
+ // e.g. "en", "en-US", or "en-US-POSIX".
+ return out.toString();
+ }
+
+ /**
+ * Sets whether Web Manifest processing support is enabled.
+ *
+ * @param enabled A flag determining whether Web Manifest processing support is enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setWebManifestEnabled(final boolean enabled) {
+ mWebManifest.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not Web Manifest processing support is enabled.
+ *
+ * @return True if web manifest processing support is enabled.
+ */
+ public boolean getWebManifestEnabled() {
+ return mWebManifest.get();
+ }
+
+ /**
+ * Set whether or not web console messages should go to logcat.
+ *
+ * <p>Note: If enabled, Gecko performance may be negatively impacted if content makes heavy use of
+ * the console API.
+ *
+ * @param enabled A flag determining whether or not web console messages should be printed to
+ * logcat.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setConsoleOutputEnabled(final boolean enabled) {
+ mConsoleOutput.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not web console messages are sent to logcat.
+ *
+ * @return True if console output is enabled.
+ */
+ public boolean getConsoleOutputEnabled() {
+ return mConsoleOutput.get();
+ }
+
+ /**
+ * Set whether or not font sizes in web content should be automatically scaled according to the
+ * device's current system font scale setting. Enabling this will prevent modification of the
+ * {@link GeckoRuntimeSettings#setFontSizeFactor font size factor}. Disabling this setting will
+ * restore the previously used value for the {@link GeckoRuntimeSettings#getFontSizeFactor font
+ * size factor}.
+ *
+ * @param enabled A flag determining whether or not font sizes should be scaled automatically to
+ * match the device's system font scale.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAutomaticFontSizeAdjustment(final boolean enabled) {
+ GeckoFontScaleListener.getInstance().setEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not the font sizes for web content are automatically adjusted to match the
+ * device's system font scale setting.
+ *
+ * @return True if font sizes are automatically adjusted.
+ */
+ public boolean getAutomaticFontSizeAdjustment() {
+ return GeckoFontScaleListener.getInstance().getEnabled();
+ }
+
+ private static final int FONT_INFLATION_BASE_VALUE = 120;
+
+ /**
+ * Set a font size factor that will operate as a global text zoom. All font sizes will be
+ * multiplied by this factor.
+ *
+ * <p>The default factor is 1.0.
+ *
+ * <p>Currently, any changes only take effect after a reload of the session.
+ *
+ * <p>This setting cannot be modified while {@link
+ * GeckoRuntimeSettings#setAutomaticFontSizeAdjustment automatic font size adjustment} is enabled.
+ *
+ * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0 disables
+ * both this feature and {@link GeckoRuntimeSettings#setFontInflationEnabled font inflation}.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setFontSizeFactor(final float fontSizeFactor) {
+ if (getAutomaticFontSizeAdjustment()) {
+ throw new IllegalStateException("Not allowed when automatic font size adjustment is enabled");
+ }
+ return setFontSizeFactorInternal(fontSizeFactor);
+ }
+
+ /*
+ * Enable the Enteprise Roots feature.
+ *
+ * When Enabled, GeckoView will fetch the third-party root certificates added to the
+ * Android OS CA store and will use them internally.
+ *
+ * @param enabled whether to enable this feature or not
+ * @return This GeckoRuntimeSettings instance
+ */
+ public @NonNull GeckoRuntimeSettings setEnterpriseRootsEnabled(final boolean enabled) {
+ mEnterpriseRootsEnabled.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Gets whether the Enteprise Roots feature is enabled or not.
+ *
+ * @return true if the feature is enabled, false otherwise.
+ */
+ public boolean getEnterpriseRootsEnabled() {
+ return mEnterpriseRootsEnabled.get();
+ }
+
+ private static final float DEFAULT_FONT_SIZE_FACTOR = 1f;
+
+ private float sanitizeFontSizeFactor(final float fontSizeFactor) {
+ if (fontSizeFactor < 0) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new IllegalArgumentException("fontSizeFactor cannot be < 0");
+ } else {
+ Log.e(LOGTAG, "fontSizeFactor cannot be < 0");
+ return DEFAULT_FONT_SIZE_FACTOR;
+ }
+ }
+
+ return fontSizeFactor;
+ }
+
+ /* package */ @NonNull
+ GeckoRuntimeSettings setFontSizeFactorInternal(final float fontSizeFactor) {
+ final float newFactor = sanitizeFontSizeFactor(fontSizeFactor);
+ if (mFontSizeFactor == newFactor) {
+ return this;
+ }
+ mFontSizeFactor = newFactor;
+ if (getFontInflationEnabled()) {
+ final int scaledFontInflation = Math.round(FONT_INFLATION_BASE_VALUE * newFactor);
+ mFontInflationMinTwips.commit(scaledFontInflation);
+ }
+ GeckoSystemStateListener.onDeviceChanged();
+ return this;
+ }
+
+ /**
+ * Gets the currently applied font size factor.
+ *
+ * @return The currently applied font size factor.
+ */
+ public float getFontSizeFactor() {
+ return mFontSizeFactor;
+ }
+
+ /**
+ * Set whether or not font inflation for non mobile-friendly pages should be enabled. The default
+ * value of this setting is <code>false</code>.
+ *
+ * <p>When enabled, font sizes will be increased on all pages that are lacking a &lt;meta&gt;
+ * viewport tag and have been loaded in a session using {@link
+ * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic
+ * will attempt to increase font sizes for the main text content of the page only.
+ *
+ * <p>The magnitude of font inflation applied depends on the {@link
+ * GeckoRuntimeSettings#setFontSizeFactor font size factor} currently in use.
+ *
+ * <p>Currently, any changes only take effect after a reload of the session.
+ *
+ * @param enabled A flag determining whether or not font inflation should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setFontInflationEnabled(final boolean enabled) {
+ final int minTwips = enabled ? Math.round(FONT_INFLATION_BASE_VALUE * getFontSizeFactor()) : 0;
+ mFontInflationMinTwips.commit(minTwips);
+ return this;
+ }
+
+ /**
+ * Get whether or not font inflation for non mobile-friendly pages is currently enabled.
+ *
+ * @return True if font inflation is enabled.
+ */
+ public boolean getFontInflationEnabled() {
+ return mFontInflationMinTwips.get() > 0;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({COLOR_SCHEME_LIGHT, COLOR_SCHEME_DARK, COLOR_SCHEME_SYSTEM})
+ public @interface ColorScheme {}
+
+ /** A light theme for web content is preferred. */
+ public static final int COLOR_SCHEME_LIGHT = 0;
+
+ /** A dark theme for web content is preferred. */
+ public static final int COLOR_SCHEME_DARK = 1;
+
+ /** The preferred color scheme will be based on system settings. */
+ public static final int COLOR_SCHEME_SYSTEM = -1;
+
+ /**
+ * Gets the preferred color scheme override for web content.
+ *
+ * @return One of the {@link GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ */
+ public @ColorScheme int getPreferredColorScheme() {
+ return mPreferredColorScheme;
+ }
+
+ /**
+ * Sets the preferred color scheme override for web content.
+ *
+ * @param scheme The preferred color scheme. Must be one of the {@link
+ * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setPreferredColorScheme(final @ColorScheme int scheme) {
+ if (mPreferredColorScheme != scheme) {
+ mPreferredColorScheme = scheme;
+ GeckoSystemStateListener.onDeviceChanged();
+ }
+ return this;
+ }
+
+ /**
+ * Gets whether auto-zoom to editable fields is enabled.
+ *
+ * @return True if auto-zoom is enabled, false otherwise.
+ */
+ public boolean getInputAutoZoomEnabled() {
+ return mInputAutoZoom.get();
+ }
+
+ /**
+ * Set whether auto-zoom to editable fields should be enabled.
+ *
+ * @param flag True if auto-zoom should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setInputAutoZoomEnabled(final boolean flag) {
+ mInputAutoZoom.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets whether double-tap zooming is enabled.
+ *
+ * @return True if double-tap zooming is enabled, false otherwise.
+ */
+ public boolean getDoubleTapZoomingEnabled() {
+ return mDoubleTapZooming.get();
+ }
+
+ /**
+ * Sets whether double tap zooming is enabled.
+ *
+ * @param flag true if double tap zooming should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setDoubleTapZoomingEnabled(final boolean flag) {
+ mDoubleTapZooming.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets the current WebGL MSAA level.
+ *
+ * @return number of MSAA samples, 0 if MSAA is disabled.
+ */
+ public int getGlMsaaLevel() {
+ return mGlMsaaLevel.get();
+ }
+
+ /**
+ * Sets the WebGL MSAA level.
+ *
+ * @param level number of MSAA samples, 0 if MSAA should be disabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setGlMsaaLevel(final int level) {
+ mGlMsaaLevel.commit(level);
+ return this;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable RuntimeTelemetry.Delegate getTelemetryDelegate() {
+ return mTelemetryProxy.getDelegate();
+ }
+
+ /**
+ * Get the {@link ExperimentDelegate} instance set on this runtime, if any,
+ *
+ * @return The {@link ExperimentDelegate} set on this runtime.
+ */
+ @AnyThread
+ public @Nullable ExperimentDelegate getExperimentDelegate() {
+ return mExperimentDelegate;
+ }
+
+ /**
+ * Gets whether about:config is enabled or not.
+ *
+ * @return True if about:config is enabled, false otherwise.
+ */
+ public boolean getAboutConfigEnabled() {
+ return mAboutConfig.get();
+ }
+
+ /**
+ * Sets whether or not about:config should be enabled. This is a page that allows users to
+ * directly modify Gecko preferences. Modification of some preferences may cause the app to break
+ * in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc.
+ *
+ * @param flag True if about:config should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAboutConfigEnabled(final boolean flag) {
+ mAboutConfig.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets whether or not force user scalable zooming should be enabled or not.
+ *
+ * @return True if force user scalable zooming should be enabled, false otherwise.
+ */
+ public boolean getForceUserScalableEnabled() {
+ return mForceUserScalable.get();
+ }
+
+ /**
+ * Sets whether or not pinch-zooming should be enabled when <code>user-scalable=no</code> is set
+ * on the viewport.
+ *
+ * @param flag True if force user scalable zooming should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setForceUserScalableEnabled(final boolean flag) {
+ mForceUserScalable.commit(flag);
+ return this;
+ }
+
+ /**
+ * Get whether login form autofill is enabled.
+ *
+ * @return True if login autofill is enabled.
+ */
+ public boolean getLoginAutofillEnabled() {
+ return mAutofillLogins.get();
+ }
+
+ /**
+ * Set whether automatic popups should appear for offering translations on candidate pages.
+ *
+ * @param enabled A flag determining whether automatic offer popups should be enabled for
+ * translations.
+ * @return The builder instance.
+ */
+ public @NonNull GeckoRuntimeSettings setTranslationsOfferPopup(final boolean enabled) {
+ mAutomaticallyOfferPopup.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether automatic popups for translations is enabled.
+ *
+ * @return True if login automatic popups for translations are enabled.
+ */
+ public boolean getTranslationsOfferPopup() {
+ return mAutomaticallyOfferPopup.get();
+ }
+
+ /**
+ * Set whether login forms should be filled automatically if only one viable candidate is provided
+ * via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}.
+ *
+ * @param enabled A flag determining whether login autofill should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull GeckoRuntimeSettings setLoginAutofillEnabled(final boolean enabled) {
+ mAutofillLogins.commit(enabled);
+ return this;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ALLOW_ALL, HTTPS_ONLY_PRIVATE, HTTPS_ONLY})
+ public @interface HttpsOnlyMode {}
+
+ /** Allow all insecure connections */
+ public static final int ALLOW_ALL = 0;
+
+ /** Allow insecure connections in normal browsing, but only HTTPS in private browsing. */
+ public static final int HTTPS_ONLY_PRIVATE = 1;
+
+ /** Only allow HTTPS connections. */
+ public static final int HTTPS_ONLY = 2;
+
+ /**
+ * Get whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @return One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ */
+ public @HttpsOnlyMode int getAllowInsecureConnections() {
+ final boolean httpsOnly = mHttpsOnly.get();
+ final boolean httpsOnlyPrivate = mHttpsOnlyPrivateMode.get();
+ if (httpsOnly) {
+ return HTTPS_ONLY;
+ } else if (httpsOnlyPrivate) {
+ return HTTPS_ONLY_PRIVATE;
+ }
+ return ALLOW_ALL;
+ }
+
+ /**
+ * Set whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAllowInsecureConnections(final @HttpsOnlyMode int level) {
+ switch (level) {
+ case ALLOW_ALL:
+ mHttpsOnly.commit(false);
+ mHttpsOnlyPrivateMode.commit(false);
+ break;
+ case HTTPS_ONLY_PRIVATE:
+ mHttpsOnly.commit(false);
+ mHttpsOnlyPrivateMode.commit(true);
+ break;
+ case HTTPS_ONLY:
+ mHttpsOnly.commit(true);
+ mHttpsOnlyPrivateMode.commit(false);
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid setting for setAllowInsecureConnections");
+ }
+ return this;
+ }
+
+ /** The trusted recursive resolver (TRR) modes. */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TRR_MODE_OFF, TRR_MODE_FIRST, TRR_MODE_ONLY, TRR_MODE_DISABLED})
+ public @interface TrustedRecursiveResolverMode {}
+
+ /** Off (default). Use native DNS resolution by default. */
+ public static final int TRR_MODE_OFF = 0;
+
+ /**
+ * First. Use TRR first, and only if the name resolve fails use the native resolver as a fallback.
+ */
+ public static final int TRR_MODE_FIRST = 2;
+
+ /** Only. Only use TRR, never use the native resolver. */
+ public static final int TRR_MODE_ONLY = 3;
+
+ /**
+ * Off by choice. This is the same as 0 but marks it as done by choice and not done by default.
+ */
+ public static final int TRR_MODE_DISABLED = 5;
+
+ /**
+ * Get whether and how DNS-over-HTTPS (Trusted Recursive Resolver) is configured.
+ *
+ * @return One of the {@link GeckoRuntimeSettings#TRR_MODE_OFF TrustedRecursiveResolverMode}
+ * constants.
+ */
+ public @TrustedRecursiveResolverMode int getTrustedRecusiveResolverMode() {
+ final int mode = mTrustedRecursiveResolverMode.get();
+ switch (mode) {
+ case 2:
+ return TRR_MODE_FIRST;
+ case 3:
+ return TRR_MODE_ONLY;
+ case 5:
+ return TRR_MODE_DISABLED;
+ default:
+ case 0:
+ return TRR_MODE_OFF;
+ }
+ }
+
+ /**
+ * Get the factor by which to increase the keepalive timeout when the NS_HTTP_LARGE_KEEPALIVE flag
+ * is used for a connection.
+ *
+ * @return An integer factor.
+ */
+ public @NonNull int getLargeKeepaliveFactor() {
+ return mLargeKeepalivefactor.get();
+ }
+
+ /**
+ * Set whether and how DNS-over-HTTPS (Trusted Recursive Resolver) is configured.
+ *
+ * @param mode One of the {@link GeckoRuntimeSettings#TRR_MODE_OFF TrustedRecursiveResolverMode}
+ * constants.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setTrustedRecursiveResolverMode(
+ final @TrustedRecursiveResolverMode int mode) {
+ switch (mode) {
+ case TRR_MODE_OFF:
+ case TRR_MODE_FIRST:
+ case TRR_MODE_ONLY:
+ case TRR_MODE_DISABLED:
+ mTrustedRecursiveResolverMode.commit(mode);
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid setting for setTrustedRecursiveResolverMode");
+ }
+ return this;
+ }
+
+ private static final int DEFAULT_LARGE_KEEPALIVE_FACTOR = 1;
+
+ private int sanitizeLargeKeepaliveFactor(final int factor) {
+ if (factor < 1 || factor > 10) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new IllegalArgumentException(
+ "largeKeepaliveFactor must be between 1 to 10 inclusive");
+ } else {
+ Log.e(LOGTAG, "largeKeepaliveFactor must be between 1 to 10 inclusive");
+ return DEFAULT_LARGE_KEEPALIVE_FACTOR;
+ }
+ }
+
+ return factor;
+ }
+
+ /**
+ * Set the factor by which to increase the keepalive timeout when the NS_HTTP_LARGE_KEEPALIVE flag
+ * is used for a connection.
+ *
+ * @param factor FACTOR by which to increase the keepalive timeout.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setLargeKeepaliveFactor(final int factor) {
+ final int newFactor = sanitizeLargeKeepaliveFactor(factor);
+ mLargeKeepalivefactor.commit(newFactor);
+ return this;
+ }
+
+ /**
+ * Get the DNS-over-HTTPS (DoH) server URI.
+ *
+ * @return URI of the DoH server.
+ */
+ public @NonNull String getTrustedRecursiveResolverUri() {
+ return mTrustedRecursiveResolverUri.get();
+ }
+
+ /**
+ * Set the DNS-over-HTTPS server URI.
+ *
+ * @param uri URI of the DNS-over-HTTPS server.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setTrustedRecursiveResolverUri(final @NonNull String uri) {
+ mTrustedRecursiveResolverUri.commit(uri);
+ return this;
+ }
+
+ // For internal use only
+ /* protected */ @NonNull
+ GeckoRuntimeSettings setProcessCount(final int processCount) {
+ mProcessCount.commit(processCount);
+ return this;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final Parcel out, final int flags) {
+ super.writeToParcel(out, flags);
+
+ out.writeStringArray(mArgs);
+ mExtras.writeToParcel(out, flags);
+ ParcelableUtils.writeBoolean(out, mForceEnableAccessibility);
+ ParcelableUtils.writeBoolean(out, mDebugPause);
+ ParcelableUtils.writeBoolean(out, mUseMaxScreenDepth);
+ out.writeFloat(mDisplayDensityOverride);
+ out.writeInt(mDisplayDpiOverride);
+ out.writeInt(mScreenWidthOverride);
+ out.writeInt(mScreenHeightOverride);
+ out.writeString(mCrashHandler != null ? mCrashHandler.getName() : null);
+ out.writeStringArray(mRequestedLocales);
+ out.writeString(mConfigFilePath);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ super.readFromParcel(source);
+
+ mArgs = source.createStringArray();
+ mExtras.readFromParcel(source);
+ mForceEnableAccessibility = ParcelableUtils.readBoolean(source);
+ mDebugPause = ParcelableUtils.readBoolean(source);
+ mUseMaxScreenDepth = ParcelableUtils.readBoolean(source);
+ mDisplayDensityOverride = source.readFloat();
+ mDisplayDpiOverride = source.readInt();
+ mScreenWidthOverride = source.readInt();
+ mScreenHeightOverride = source.readInt();
+
+ final String crashHandlerName = source.readString();
+ if (crashHandlerName != null) {
+ try {
+ @SuppressWarnings("unchecked")
+ final Class<? extends Service> handler =
+ (Class<? extends Service>) Class.forName(crashHandlerName);
+
+ mCrashHandler = handler;
+ } catch (final ClassNotFoundException e) {
+ }
+ }
+
+ mRequestedLocales = source.createStringArray();
+ mConfigFilePath = source.readString();
+ }
+
+ public static final Parcelable.Creator<GeckoRuntimeSettings> CREATOR =
+ new Parcelable.Creator<GeckoRuntimeSettings>() {
+ @Override
+ public GeckoRuntimeSettings createFromParcel(final Parcel in) {
+ final GeckoRuntimeSettings settings = new GeckoRuntimeSettings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public GeckoRuntimeSettings[] newArray(final int size) {
+ return new GeckoRuntimeSettings[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
new file mode 100644
index 0000000000..f8f7f858e3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -0,0 +1,8425 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import static org.mozilla.geckoview.GeckoSession.GeckoPrintException.ERROR_NO_PRINT_DELEGATE;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Matrix;
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IInterface;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.util.Base64;
+import android.util.Log;
+import android.view.PointerIcon;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewStructure;
+import android.view.WindowManager;
+import android.view.inputmethod.CursorAnchorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.widget.Magnifier;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
+import androidx.annotation.UiThread;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.AbstractSequentialList;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoDragAndDrop;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.MagnifiableSurfaceView;
+import org.mozilla.gecko.NativeQueue;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.IntentUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt;
+
+public class GeckoSession {
+ private static final String LOGTAG = "GeckoSession";
+ private static final boolean DEBUG = false;
+
+ // Type of changes given to onWindowChanged.
+ // Window has been cleared due to the session being closed.
+ private static final int WINDOW_CLOSE = 0;
+ // Window has been set due to the session being opened.
+ private static final int WINDOW_OPEN = 1; // Window has been opened.
+ // Window has been cleared due to the session being transferred to another session.
+ private static final int WINDOW_TRANSFER_OUT = 2; // Window has been transfer.
+ // Window has been set due to another session being transferred to this one.
+ private static final int WINDOW_TRANSFER_IN = 3;
+
+ private static final int DATA_URI_MAX_LENGTH = 2 * 1024 * 1024;
+
+ // Delay running compositor memory pressure by 10s to avoid interfering with tab switching.
+ private static final int NOTIFY_MEMORY_PRESSURE_DELAY_MS = 10 * 1000;
+
+ private final Runnable mNotifyMemoryPressure =
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mCompositorReady) {
+ mCompositor.notifyMemoryPressure();
+ }
+ }
+ };
+
+ private enum State implements NativeQueue.State {
+ INITIAL(0),
+ READY(1);
+
+ private final int mRank;
+
+ State(final int rank) {
+ mRank = rank;
+ }
+
+ @Override
+ public boolean is(final NativeQueue.State other) {
+ return this == other;
+ }
+
+ @Override
+ public boolean isAtLeast(final NativeQueue.State other) {
+ return (other instanceof State) && mRank >= ((State) other).mRank;
+ }
+ }
+
+ private final NativeQueue mNativeQueue = new NativeQueue(State.INITIAL, State.READY);
+
+ private final EventDispatcher mEventDispatcher = new EventDispatcher(mNativeQueue);
+
+ private final SessionTextInput mTextInput = new SessionTextInput(this, mNativeQueue);
+ private SessionAccessibility mAccessibility;
+ private SessionFinder mFinder;
+ private SessionPdfFileSaver mPdfFileSaver;
+ private TranslationsController.SessionTranslation mTranslations =
+ new TranslationsController.SessionTranslation(this);
+
+ /** {@code SessionMagnifier} handles magnifying glass. */
+ /* package */ interface SessionMagnifier {
+ /**
+ * Get the current {@link android.view.View} for magnifying glass.
+ *
+ * @return Current View for magnifying glass or null if not set.
+ */
+ @UiThread
+ default @Nullable View getView() {
+ return null;
+ }
+
+ /**
+ * Set the current {@link android.view.View} for magnifying glass.
+ *
+ * @param view View for magnifying glass or null to clear current View.
+ */
+ @UiThread
+ default void setView(final @NonNull View view) {}
+
+ /**
+ * Show magnifying glass.
+ *
+ * @param sourceCenter The source center of view that magnifying glass is attached
+ */
+ @UiThread
+ default void show(final @NonNull PointF sourceCenter) {}
+
+ /** Dismiss magnifying glass. */
+ @UiThread
+ default void dismiss() {}
+ }
+
+ @TargetApi(Build.VERSION_CODES.P)
+ private class SessionMagnifierP implements GeckoSession.SessionMagnifier {
+ private @Nullable View mView;
+ private @Nullable Magnifier mMagnifier;
+ private final @NonNull Compositor mCompositor;
+
+ private SessionMagnifierP(final Compositor compositor) {
+ mCompositor = compositor;
+ }
+
+ @Override
+ @UiThread
+ public @Nullable View getView() {
+ ThreadUtils.assertOnUiThread();
+
+ return mView;
+ }
+
+ @Override
+ @UiThread
+ public void setView(final @NonNull View view) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mMagnifier != null) {
+ mMagnifier.dismiss();
+ mMagnifier = null;
+ }
+ mView = view;
+ }
+
+ @Override
+ @UiThread
+ public void show(final @NonNull PointF sourceCenter) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mView == null) {
+ return;
+ }
+ if (mMagnifier == null) {
+ mMagnifier = new Magnifier(mView);
+ }
+
+ if (mView instanceof MagnifiableSurfaceView) {
+ final MagnifiableSurfaceView view = (MagnifiableSurfaceView) mView;
+ view.setMagnifierSurface(mCompositor.getMagnifiableSurface());
+ }
+ mMagnifier.show(sourceCenter.x, sourceCenter.y);
+ if (mView instanceof MagnifiableSurfaceView) {
+ final MagnifiableSurfaceView view = (MagnifiableSurfaceView) mView;
+ view.setMagnifierSurface(null);
+ }
+ }
+
+ @Override
+ @UiThread
+ public void dismiss() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mMagnifier == null) {
+ return;
+ }
+
+ mMagnifier.dismiss();
+ mMagnifier = null;
+ }
+ }
+
+ private SessionMagnifier mMagnifier;
+
+ private String mId;
+
+ /* package */ String getId() {
+ return mId;
+ }
+
+ private boolean mShouldPinOnScreen;
+
+ // All fields are accessed on UI thread only.
+ private PanZoomController mPanZoomController = new PanZoomController(this);
+ private OverscrollEdgeEffect mOverscroll;
+ private CompositorController mController;
+ private Autofill.Support mAutofillSupport;
+
+ private boolean mAttachedCompositor;
+ private boolean mCompositorReady;
+ private SurfaceInfo mSurfaceInfo;
+ private GeckoDisplay.NewSurfaceProvider mNewSurfaceProvider;
+
+ // All fields of coordinates are in screen units.
+ private int mLeft;
+ private int mTop; // Top of the surface (including toolbar);
+ private int mClientTop; // Top of the client area (i.e. excluding toolbar);
+ private int mWidth;
+ private int mHeight; // Height of the surface (including toolbar);
+ private int mClientHeight; // Height of the client area (i.e. excluding toolbar);
+ private int mFixedBottomOffset =
+ 0; // The margin for fixed elements attached to the bottom of the viewport.
+ private int mDynamicToolbarMaxHeight = 0; // The maximum height of the dynamic toolbar
+ private float mViewportLeft;
+ private float mViewportTop;
+ private float mViewportZoom = 1.0f;
+
+ //
+ // NOTE: These values are also defined in
+ // gfx/layers/ipc/UiCompositorControllerMessageTypes.h and must be kept in sync. Any
+ // new AnimatorMessageType added here must also be added there.
+ //
+ // Sent from compositor after first paint
+ /* package */ static final int FIRST_PAINT = 0;
+ // Sent from compositor when a layer has been updated
+ /* package */ static final int LAYERS_UPDATED = 1;
+ // Special message sent from UiCompositorControllerChild once it is open
+ /* package */ static final int COMPOSITOR_CONTROLLER_OPEN = 2;
+ // Special message sent from controller to query if the compositor controller is open.
+ /* package */ static final int IS_COMPOSITOR_CONTROLLER_OPEN = 3;
+
+ /* protected */ class Compositor extends JNIObject {
+ public boolean isReady() {
+ return GeckoSession.this.isCompositorReady();
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void onCompositorAttached() {
+ GeckoSession.this.onCompositorAttached();
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void onCompositorDetached() {
+ // Clear out any pending calls on the UI thread.
+ GeckoSession.this.onCompositorDetached();
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ @Override
+ protected native void disposeNative();
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void attachNPZC(PanZoomController.NativeProvider npzc);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void onBoundsChanged(int left, int top, int width, int height);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void setDynamicToolbarMaxHeight(int height);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void notifyMemoryPressure();
+
+ // Gecko thread pauses compositor; blocks UI thread.
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void syncPauseCompositor();
+
+ // UI thread resumes compositor and notifies Gecko thread; does not block UI thread.
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void syncResumeResizeCompositor(
+ int x, int y, int width, int height, Object surface, Object surfaceControl);
+
+ // Returns a Surface that content has been rendered in to, which should be used when the
+ // magnifier is shown. This may differ from the Surface we have passed to
+ // syncResumeResizeCompositor().
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native Surface getMagnifiableSurface();
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void setMaxToolbarHeight(int height);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void setFixedBottomOffset(int offset);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void sendToolbarAnimatorMessage(int message);
+
+ @WrapForJNI(calledFrom = "ui")
+ private void recvToolbarAnimatorMessage(final int message) {
+ GeckoSession.this.handleCompositorMessage(message);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void requestNewSurface() {
+ final GeckoDisplay.NewSurfaceProvider provider = GeckoSession.this.mNewSurfaceProvider;
+ if (provider != null) {
+ provider.requestNewSurface();
+ } else {
+ Log.w(LOGTAG, "Cannot request new Surface: No NewSurfaceProvider set.");
+ }
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void setDefaultClearColor(int color);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ /* package */ native void requestScreenPixels(
+ final GeckoResult<Bitmap> result,
+ final Bitmap target,
+ final int x,
+ final int y,
+ final int srcWidth,
+ final int srcHeight,
+ final int outWidth,
+ final int outHeight);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void enableLayerUpdateNotifications(boolean enable);
+
+ // The compositor invokes this function just before compositing a frame where the
+ // document is different from the document composited on the last frame. In these
+ // cases, the viewport information we have in Java is no longer valid and needs to
+ // be replaced with the new viewport information provided.
+ @WrapForJNI(calledFrom = "ui")
+ private void updateRootFrameMetrics(
+ final float scrollX, final float scrollY, final float zoom) {
+ GeckoSession.this.onMetricsChanged(scrollX, scrollY, zoom);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void updateOverscrollVelocity(final float x, final float y) {
+ GeckoSession.this.updateOverscrollVelocity(x, y);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void updateOverscrollOffset(final float x, final float y) {
+ GeckoSession.this.updateOverscrollOffset(x, y);
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void onSafeAreaInsetsChanged(int top, int right, int bottom, int left);
+
+ @WrapForJNI(calledFrom = "ui")
+ public void setPointerIcon(
+ final int defaultCursor, final Bitmap customCursor, final float x, final float y) {
+ GeckoSession.this.setPointerIcon(defaultCursor, customCursor, x, y);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void startDragAndDrop(final Bitmap bitmap) {
+ GeckoSession.this.startDragAndDrop(bitmap);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void updateDragImage(final Bitmap bitmap) {
+ GeckoSession.this.updateDragImage(bitmap);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ disposeNative();
+ }
+ }
+
+ /* package */ final Compositor mCompositor = new Compositor();
+
+ @WrapForJNI(stubName = "GetCompositor", calledFrom = "ui")
+ private Object getCompositorFromNative() {
+ // Only used by native code.
+ return mCompositorReady ? mCompositor : null;
+ }
+
+ private final GeckoSessionHandler<HistoryDelegate> mHistoryHandler =
+ new GeckoSessionHandler<HistoryDelegate>(
+ "GeckoViewHistory",
+ this,
+ new String[] {
+ "GeckoView:OnVisited", "GeckoView:GetVisited", "GeckoView:StateUpdated",
+ }) {
+ @Override
+ public void handleMessage(
+ final HistoryDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:OnVisited".equals(event)) {
+ final GeckoResult<Boolean> result =
+ delegate.onVisited(
+ GeckoSession.this,
+ message.getString("url"),
+ message.getString("lastVisitedURL"),
+ message.getInt("flags"));
+
+ if (result == null) {
+ callback.sendSuccess(false);
+ return;
+ }
+
+ result.accept(
+ visited -> callback.sendSuccess(visited.booleanValue()),
+ exception -> callback.sendSuccess(false));
+ } else if ("GeckoView:GetVisited".equals(event)) {
+ final String[] urls = message.getStringArray("urls");
+
+ final GeckoResult<boolean[]> result = delegate.getVisited(GeckoSession.this, urls);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ result.accept(
+ visited -> callback.sendSuccess(visited),
+ exception -> callback.sendError("Failed to fetch visited statuses for URIs"));
+ } else if ("GeckoView:StateUpdated".equals(event)) {
+
+ final GeckoBundle update = message.getBundle("data");
+
+ if (update == null) {
+ return;
+ }
+ final int previousHistorySize = mStateCache.size();
+ mStateCache.updateSessionState(update);
+
+ final ProgressDelegate progressDelegate = getProgressDelegate();
+ if (progressDelegate != null) {
+ final SessionState state = new SessionState(mStateCache);
+ if (!state.isEmpty()) {
+ progressDelegate.onSessionStateChange(GeckoSession.this, state);
+ }
+ }
+
+ if (update.getBundle("historychange") != null) {
+ final SessionState state = new SessionState(mStateCache);
+
+ delegate.onHistoryStateChange(GeckoSession.this, state);
+
+ // If the previous history was larger than one entry and the new size is one, it means
+ // the
+ // History has been purged and the navigation delegate needs to be update.
+ if ((previousHistorySize > 1)
+ && (state.size() == 1)
+ && mNavigationHandler.getDelegate() != null) {
+ mNavigationHandler.getDelegate().onCanGoForward(GeckoSession.this, false);
+ mNavigationHandler.getDelegate().onCanGoBack(GeckoSession.this, false);
+ }
+ }
+ }
+ }
+ };
+
+ private final WebExtension.SessionController mWebExtensionController;
+
+ private final GeckoSessionHandler<ContentDelegate> mContentHandler =
+ new GeckoSessionHandler<ContentDelegate>(
+ "GeckoViewContent",
+ this,
+ new String[] {
+ "GeckoView:ContentCrash",
+ "GeckoView:ContentKill",
+ "GeckoView:ContextMenu",
+ "GeckoView:DOMMetaViewportFit",
+ "GeckoView:PageTitleChanged",
+ "GeckoView:DOMWindowClose",
+ "GeckoView:ExternalResponse",
+ "GeckoView:FocusRequest",
+ "GeckoView:FullScreenEnter",
+ "GeckoView:FullScreenExit",
+ "GeckoView:WebAppManifest",
+ "GeckoView:FirstContentfulPaint",
+ "GeckoView:PaintStatusReset",
+ "GeckoView:PreviewImage",
+ "GeckoView:CookieBannerEvent:Detected",
+ "GeckoView:CookieBannerEvent:Handled",
+ "GeckoView:SavePdf",
+ "GeckoView:GetNimbusFeature",
+ "GeckoView:OnProductUrl",
+ }) {
+ @Override
+ public void handleMessage(
+ final ContentDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:ContentCrash".equals(event)) {
+ close();
+ delegate.onCrash(GeckoSession.this);
+ } else if ("GeckoView:ContentKill".equals(event)) {
+ close();
+ delegate.onKill(GeckoSession.this);
+ } else if ("GeckoView:ContextMenu".equals(event)) {
+ final ContentDelegate.ContextElement elem =
+ new ContentDelegate.ContextElement(
+ message.getString("baseUri"),
+ message.getString("uri"),
+ message.getString("title"),
+ message.getString("alt"),
+ message.getString("elementType"),
+ message.getString("elementSrc"),
+ message.getString("textContent"));
+
+ delegate.onContextMenu(
+ GeckoSession.this, message.getInt("screenX"), message.getInt("screenY"), elem);
+
+ } else if ("GeckoView:DOMMetaViewportFit".equals(event)) {
+ delegate.onMetaViewportFitChange(GeckoSession.this, message.getString("viewportfit"));
+ } else if ("GeckoView:PageTitleChanged".equals(event)) {
+ delegate.onTitleChange(GeckoSession.this, message.getString("title"));
+ } else if ("GeckoView:FocusRequest".equals(event)) {
+ delegate.onFocusRequest(GeckoSession.this);
+ } else if ("GeckoView:DOMWindowClose".equals(event)) {
+ if (getSelectionActionDelegate() != null) {
+ getSelectionActionDelegate().onDismissClipboardPermissionRequest(GeckoSession.this);
+ }
+ delegate.onCloseRequest(GeckoSession.this);
+ } else if ("GeckoView:FullScreenEnter".equals(event)) {
+ delegate.onFullScreen(GeckoSession.this, true);
+ } else if ("GeckoView:FullScreenExit".equals(event)) {
+ delegate.onFullScreen(GeckoSession.this, false);
+ } else if ("GeckoView:WebAppManifest".equals(event)) {
+ final GeckoBundle manifest = message.getBundle("manifest");
+ if (manifest == null) {
+ return;
+ }
+
+ try {
+ delegate.onWebAppManifest(
+ GeckoSession.this, fixupWebAppManifest(manifest.toJSONObject()));
+ } catch (final JSONException e) {
+ Log.e(LOGTAG, "Failed to convert web app manifest to JSON", e);
+ }
+ } else if ("GeckoView:FirstContentfulPaint".equals(event)) {
+ delegate.onFirstContentfulPaint(GeckoSession.this);
+ } else if ("GeckoView:PaintStatusReset".equals(event)) {
+ delegate.onPaintStatusReset(GeckoSession.this);
+ } else if ("GeckoView:PreviewImage".equals(event)) {
+ delegate.onPreviewImage(GeckoSession.this, message.getString("previewImageUrl"));
+ } else if ("GeckoView:CookieBannerEvent:Detected".equals(event)) {
+ delegate.onCookieBannerDetected(GeckoSession.this);
+ } else if ("GeckoView:CookieBannerEvent:Handled".equals(event)) {
+ delegate.onCookieBannerHandled(GeckoSession.this);
+ } else if ("GeckoView:SavePdf".equals(event)) {
+ final GeckoResult<WebResponse> result =
+ SessionPdfFileSaver.createResponse(
+ GeckoSession.this,
+ message.getString("url"),
+ message.getString("filename"),
+ message.getString("originalUrl"),
+ message.getBoolean("skipConfirmation"),
+ message.getBoolean("requestExternalApp"));
+ if (result == null) {
+ if (callback != null) {
+ callback.sendError("Failed to create response");
+ }
+ return;
+ }
+ result.accept(
+ response ->
+ ThreadUtils.runOnUiThread(
+ () -> delegate.onExternalResponse(GeckoSession.this, response)),
+ exception -> {
+ if (callback != null) {
+ callback.sendError("Failed to create response");
+ }
+ });
+ } else if ("GeckoView:OnProductUrl".equals(event)) {
+ delegate.onProductUrl(GeckoSession.this);
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<NavigationDelegate> mNavigationHandler =
+ new GeckoSessionHandler<NavigationDelegate>(
+ "GeckoViewNavigation",
+ this,
+ new String[] {"GeckoView:LocationChange", "GeckoView:OnNewSession"},
+ new String[] {
+ "GeckoView:OnLoadError", "GeckoView:OnLoadRequest",
+ }) {
+ // This needs to match nsIBrowserDOMWindow.idl
+ private int convertGeckoTarget(final int geckoTarget) {
+ switch (geckoTarget) {
+ case 0: // OPEN_DEFAULTWINDOW
+ case 1: // OPEN_CURRENTWINDOW
+ return NavigationDelegate.TARGET_WINDOW_CURRENT;
+ default: // OPEN_NEWWINDOW, OPEN_NEWTAB, OPEN_NEWTAB_BACKGROUND
+ return NavigationDelegate.TARGET_WINDOW_NEW;
+ }
+ }
+
+ @Override
+ public void handleDefaultMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+
+ if ("GeckoView:OnLoadRequest".equals(event)) {
+ callback.sendSuccess(false);
+ } else if ("GeckoView:OnLoadError".equals(event)) {
+ callback.sendSuccess(null);
+ } else {
+ super.handleDefaultMessage(event, message, callback);
+ }
+ }
+
+ // For .isOpen(), the linter is not smart enough to figure out we're asserting that we're on
+ // the UI thread.
+ @SuppressLint("WrongThread")
+ @Override
+ public void handleMessage(
+ final NavigationDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+ if ("GeckoView:LocationChange".equals(event)) {
+ if (message.getBoolean("isTopLevel")) {
+ final GeckoBundle[] perms = message.getBundleArray("permissions");
+ final List<PermissionDelegate.ContentPermission> permList =
+ PermissionDelegate.ContentPermission.fromBundleArray(perms);
+ delegate.onLocationChange(GeckoSession.this, message.getString("uri"), permList);
+ }
+ delegate.onCanGoBack(GeckoSession.this, message.getBoolean("canGoBack"));
+ delegate.onCanGoForward(GeckoSession.this, message.getBoolean("canGoForward"));
+ } else if ("GeckoView:OnLoadRequest".equals(event)) {
+ final NavigationDelegate.LoadRequest request =
+ new NavigationDelegate.LoadRequest(
+ message.getString("uri"),
+ message.getString("triggerUri"),
+ message.getInt("where"),
+ message.getInt("flags"),
+ message.getBoolean("hasUserGesture"),
+ /* isDirectNavigation */ false);
+
+ if (!IntentUtils.isUriSafeForScheme(request.uri)) {
+ callback.sendError("Blocked unsafe intent URI");
+
+ delegate.onLoadError(
+ GeckoSession.this,
+ request.uri,
+ new WebRequestError(
+ WebRequestError.ERROR_MALFORMED_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ null));
+
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> result =
+ delegate.onLoadRequest(GeckoSession.this, request);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ value -> {
+ ThreadUtils.assertOnUiThread();
+ if (value == AllowOrDeny.ALLOW) {
+ return false;
+ }
+ if (value == AllowOrDeny.DENY) {
+ return true;
+ }
+ throw new IllegalArgumentException("Invalid response");
+ }));
+ } else if ("GeckoView:OnLoadError".equals(event)) {
+ final String uri = message.getString("uri");
+ final long errorCode = message.getLong("error");
+ final int errorModule = message.getInt("errorModule");
+ final int errorClass = message.getInt("errorClass");
+
+ final WebRequestError err =
+ WebRequestError.fromGeckoError(errorCode, errorModule, errorClass, null);
+
+ final GeckoResult<String> result = delegate.onLoadError(GeckoSession.this, uri, err);
+ if (result == null) {
+ callback.sendError("abort");
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ url -> {
+ if (url == null) {
+ throw new IllegalArgumentException("abort");
+ }
+ final String lowerCasedUri = url.toLowerCase(Locale.ROOT);
+ if (lowerCasedUri.startsWith("http") || lowerCasedUri.startsWith("https")) {
+ throw new IllegalArgumentException(
+ "Unsupported URI scheme for an error page");
+ }
+ return url;
+ }));
+ } else if ("GeckoView:OnNewSession".equals(event)) {
+ final String uri = message.getString("uri");
+ final GeckoResult<GeckoSession> result = delegate.onNewSession(GeckoSession.this, uri);
+ if (result == null) {
+ callback.sendSuccess(false);
+ return;
+ }
+
+ final String newSessionId = message.getString("newSessionId");
+ callback.resolveTo(
+ result.map(
+ session -> {
+ ThreadUtils.assertOnUiThread();
+ if (session == null) {
+ return false;
+ }
+
+ if (session.isOpen()) {
+ throw new AssertionError("Must use an unopened GeckoSession instance");
+ }
+
+ if (GeckoSession.this.mWindow == null) {
+ throw new IllegalArgumentException("Session is not attached to a window");
+ }
+
+ session.open(GeckoSession.this.mWindow.runtime, newSessionId);
+ return true;
+ }));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<PrintDelegate> mPrintHandler =
+ new GeckoSessionHandler<PrintDelegate>(
+ "GeckoViewPrint", this, new String[] {"GeckoView:DotPrintRequest"}) {
+ @Override
+ public void handleMessage(
+ final PrintDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+
+ if ("GeckoView:DotPrintRequest".equals(event)) {
+ final Long cbcId = message.getLong("canonicalBrowsingContextId");
+ final GeckoResult<InputStream> pdfResult = saveAsPdfByBrowsingContext(cbcId);
+ final GeckoBundle bundle = new GeckoBundle();
+ pdfResult
+ .accept(
+ pdfStream -> {
+ final GeckoResult<Boolean> dialogFinished =
+ delegate.onPrintWithStatus(pdfStream);
+ try {
+ dialogFinished
+ .accept(
+ isDialogFinished -> {
+ bundle.putBoolean("isPdfSuccessful", true);
+ mEventDispatcher.dispatch("GeckoView:DotPrintFinish", bundle);
+ })
+ .exceptionally(
+ e -> {
+ bundle.putBoolean("isPdfSuccessful", false);
+ if (e instanceof GeckoPrintException) {
+ bundle.putInt("errorReason", ((GeckoPrintException) e).code);
+ }
+ mEventDispatcher.dispatch("GeckoView:DotPrintFinish", bundle);
+ return null;
+ });
+ } catch (final Exception e) {
+ bundle.putBoolean("isPdfSuccessful", false);
+ mEventDispatcher.dispatch("GeckoView:DotPrintFinish", bundle);
+ Log.e(LOGTAG, "Print delegate needs to be fully implemented to print.", e);
+ }
+ })
+ .exceptionally(
+ e -> {
+ bundle.putBoolean("isPdfSuccessful", false);
+ if (e instanceof GeckoPrintException) {
+ bundle.putInt("errorReason", ((GeckoPrintException) e).code);
+ }
+ mEventDispatcher.dispatch("GeckoView:DotPrintFinish", bundle);
+ Log.e(LOGTAG, "Could not complete DotPrintRequest.", e);
+ return null;
+ });
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ExperimentDelegate> mExperimentHandler =
+ new GeckoSessionHandler<ExperimentDelegate>(
+ "GeckoViewExperiment",
+ this,
+ new String[] {
+ "GeckoView:GetExperimentFeature",
+ "GeckoView:RecordExposure",
+ "GeckoView:RecordExperimentExposure",
+ "GeckoView:RecordMalformedConfig"
+ }) {
+ @Override
+ public void handleMessage(
+ final ExperimentDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+
+ if (delegate == null) {
+ if (callback != null) {
+ callback.sendError("No experiment delegate registered.");
+ }
+ Log.w(LOGTAG, "No experiment delegate registered.");
+ return;
+ }
+ final String feature = message.getString("feature", "");
+ if ("GeckoView:GetExperimentFeature".equals(event) && callback != null) {
+ final GeckoResult<JSONObject> result = delegate.onGetExperimentFeature(feature);
+ result
+ .accept(
+ json -> {
+ try {
+ callback.sendSuccess(GeckoBundle.fromJSONObject(json));
+ } catch (final JSONException e) {
+ callback.sendError("An error occured when serializing the feature data.");
+ }
+ })
+ .exceptionally(
+ e -> {
+ callback.sendError("An error occurred while retrieving feature data.");
+ return null;
+ });
+
+ } else if ("GeckoView:RecordExposure".equals(event) && callback != null) {
+ final GeckoResult<Void> result = delegate.onRecordExposureEvent(feature);
+ result
+ .accept(
+ a -> {
+ callback.sendSuccess(true);
+ })
+ .exceptionally(
+ e -> {
+ callback.sendError("An error occurred while recording feature.");
+ return null;
+ });
+
+ } else if ("GeckoView:RecordExperimentExposure".equals(event) && callback != null) {
+ final String slug = message.getString("slug", "");
+ final GeckoResult<Void> result =
+ delegate.onRecordExperimentExposureEvent(feature, slug);
+ result
+ .accept(
+ a -> {
+ callback.sendSuccess(true);
+ })
+ .exceptionally(
+ e -> {
+ callback.sendError("An error occurred while recording experiment feature.");
+ return null;
+ });
+
+ } else if ("GeckoView:RecordMalformedConfig".equals(event) && callback != null) {
+ final String part = message.getString("part", "");
+ final GeckoResult<Void> result =
+ delegate.onRecordMalformedConfigurationEvent(feature, part);
+ result
+ .accept(
+ a -> {
+ callback.sendSuccess(true);
+ })
+ .exceptionally(
+ e -> {
+ callback.sendError(
+ "An error occurred while recording malformed feature config.");
+ return null;
+ });
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ContentDelegate> mProcessHangHandler =
+ new GeckoSessionHandler<ContentDelegate>(
+ "GeckoViewProcessHangMonitor", this, new String[] {"GeckoView:HangReport"}) {
+
+ @Override
+ protected void handleMessage(
+ final ContentDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback eventCallback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+
+ final GeckoResult<SlowScriptResponse> result =
+ delegate.onSlowScript(GeckoSession.this, message.getString("scriptFileName"));
+ if (result != null) {
+ final int mReportId = message.getInt("hangId");
+ result.accept(
+ stopOrContinue -> {
+ if (stopOrContinue != null) {
+ final GeckoBundle bundle = new GeckoBundle();
+ bundle.putInt("hangId", mReportId);
+ switch (stopOrContinue) {
+ case STOP:
+ mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle);
+ break;
+ case CONTINUE:
+ mEventDispatcher.dispatch("GeckoView:HangReportWait", bundle);
+ break;
+ }
+ }
+ });
+ } else {
+ // default to stopping the script
+ final GeckoBundle bundle = new GeckoBundle();
+ bundle.putInt("hangId", message.getInt("hangId"));
+ mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle);
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ProgressDelegate> mProgressHandler =
+ new GeckoSessionHandler<ProgressDelegate>(
+ "GeckoViewProgress",
+ this,
+ new String[] {
+ "GeckoView:PageStart",
+ "GeckoView:PageStop",
+ "GeckoView:ProgressChanged",
+ "GeckoView:SecurityChanged",
+ "GeckoView:StateUpdated",
+ }) {
+ @Override
+ public void handleMessage(
+ final ProgressDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+ if ("GeckoView:PageStart".equals(event)) {
+ if (getSelectionActionDelegate() != null) {
+ getSelectionActionDelegate().onDismissClipboardPermissionRequest(GeckoSession.this);
+ }
+ delegate.onPageStart(GeckoSession.this, message.getString("uri"));
+ } else if ("GeckoView:PageStop".equals(event)) {
+ delegate.onPageStop(GeckoSession.this, message.getBoolean("success"));
+ } else if ("GeckoView:ProgressChanged".equals(event)) {
+ delegate.onProgressChange(GeckoSession.this, message.getInt("progress"));
+ } else if ("GeckoView:SecurityChanged".equals(event)) {
+ final GeckoBundle identity = message.getBundle("identity");
+ delegate.onSecurityChange(
+ GeckoSession.this, new ProgressDelegate.SecurityInformation(identity));
+ } else if ("GeckoView:StateUpdated".equals(event)) {
+ final GeckoBundle update = message.getBundle("data");
+ if (update != null) {
+ if (getHistoryDelegate() == null) {
+ mStateCache.updateSessionState(update);
+ final SessionState state = new SessionState(mStateCache);
+ if (!state.isEmpty()) {
+ delegate.onSessionStateChange(GeckoSession.this, state);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ScrollDelegate> mScrollHandler =
+ new GeckoSessionHandler<ScrollDelegate>(
+ "GeckoViewScroll", this, new String[] {"GeckoView:ScrollChanged"}) {
+ @Override
+ public void handleMessage(
+ final ScrollDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+
+ if ("GeckoView:ScrollChanged".equals(event)) {
+ delegate.onScrollChanged(
+ GeckoSession.this, message.getInt("scrollX"), message.getInt("scrollY"));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ContentBlocking.Delegate> mContentBlockingHandler =
+ new GeckoSessionHandler<ContentBlocking.Delegate>(
+ "GeckoViewContentBlocking", this, new String[] {"GeckoView:ContentBlockingEvent"}) {
+ @Override
+ public void handleMessage(
+ final ContentBlocking.Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+
+ if ("GeckoView:ContentBlockingEvent".equals(event)) {
+ final ContentBlocking.BlockEvent be = ContentBlocking.BlockEvent.fromBundle(message);
+ if (be.isBlocking()) {
+ delegate.onContentBlocked(GeckoSession.this, be);
+ } else {
+ delegate.onContentLoaded(GeckoSession.this, be);
+ }
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<PermissionDelegate> mPermissionHandler =
+ new GeckoSessionHandler<PermissionDelegate>(
+ "GeckoViewPermission",
+ this,
+ new String[] {
+ "GeckoView:AndroidPermission",
+ "GeckoView:ContentPermission",
+ "GeckoView:MediaPermission"
+ }) {
+ @Override
+ public void handleMessage(
+ final PermissionDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage: " + event);
+ if (delegate == null) {
+ callback.sendSuccess(/* granted */ false);
+ return;
+ }
+ if ("GeckoView:AndroidPermission".equals(event)) {
+ delegate.onAndroidPermissionsRequest(
+ GeckoSession.this,
+ message.getStringArray("perms"),
+ new PermissionCallback("android", callback));
+ } else if ("GeckoView:ContentPermission".equals(event)) {
+ final GeckoResult<Integer> res =
+ delegate.onContentPermissionRequest(
+ GeckoSession.this, new PermissionDelegate.ContentPermission(message));
+ if (res == null) {
+ callback.sendSuccess(PermissionDelegate.ContentPermission.VALUE_PROMPT);
+ return;
+ }
+
+ callback.resolveTo(res);
+ } else if ("GeckoView:MediaPermission".equals(event)) {
+ final GeckoBundle[] videoBundles = message.getBundleArray("video");
+ final GeckoBundle[] audioBundles = message.getBundleArray("audio");
+ PermissionDelegate.MediaSource[] videos = null;
+ PermissionDelegate.MediaSource[] audios = null;
+
+ if (videoBundles != null) {
+ videos = new PermissionDelegate.MediaSource[videoBundles.length];
+ for (int i = 0; i < videoBundles.length; i++) {
+ videos[i] = new PermissionDelegate.MediaSource(videoBundles[i]);
+ }
+ }
+
+ if (audioBundles != null) {
+ audios = new PermissionDelegate.MediaSource[audioBundles.length];
+ for (int i = 0; i < audioBundles.length; i++) {
+ audios[i] = new PermissionDelegate.MediaSource(audioBundles[i]);
+ }
+ }
+
+ delegate.onMediaPermissionRequest(
+ GeckoSession.this,
+ message.getString("uri"),
+ videos,
+ audios,
+ new PermissionCallback("media", callback));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<SelectionActionDelegate> mSelectionActionDelegate =
+ new GeckoSessionHandler<SelectionActionDelegate>(
+ "GeckoViewSelectionAction",
+ this,
+ new String[] {
+ "GeckoView:HideSelectionAction",
+ "GeckoView:ShowSelectionAction",
+ "GeckoView:HideMagnifier",
+ "GeckoView:ShowMagnifier",
+ "GeckoView:ClipboardPermissionRequest",
+ "GeckoView:DismissClipboardPermissionRequest",
+ }) {
+ @Override
+ public void handleMessage(
+ final SelectionActionDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage: " + event);
+ if ("GeckoView:ShowSelectionAction".equals(event)) {
+ final @SelectionActionDelegateAction HashSet<String> actionsSet =
+ new HashSet<>(Arrays.asList(message.getStringArray("actions")));
+ final SelectionActionDelegate.Selection selection =
+ new SelectionActionDelegate.Selection(message, actionsSet, mEventDispatcher);
+
+ delegate.onShowActionRequest(GeckoSession.this, selection);
+
+ } else if ("GeckoView:HideSelectionAction".equals(event)) {
+ final String reasonString = message.getString("reason");
+ final int reason;
+ if ("invisibleselection".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION;
+ } else if ("presscaret".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION;
+ } else if ("scroll".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL;
+ } else if ("visibilitychange".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_NO_SELECTION;
+ } else {
+ throw new IllegalArgumentException();
+ }
+
+ delegate.onHideAction(GeckoSession.this, reason);
+ } else if ("GeckoView:ShowMagnifier".equals(event)) {
+ final PointF point = message.getPointF("screenPoint");
+ if (point == null) {
+ throw new IllegalArgumentException("Invalid argument");
+ }
+
+ // Magnifier is surface coordinate.
+ point.x -= GeckoSession.this.mLeft;
+ point.y -= GeckoSession.this.mClientTop;
+ GeckoSession.this.getMagnifier().show(point);
+ } else if ("GeckoView:HideMagnifier".equals(event)) {
+ GeckoSession.this.getMagnifier().dismiss();
+ } else if ("GeckoView:ClipboardPermissionRequest".equals(event)) {
+ final SelectionActionDelegate.ClipboardPermission permission =
+ new SelectionActionDelegate.ClipboardPermission(message);
+
+ final GeckoResult<AllowOrDeny> result =
+ delegate.onShowClipboardPermissionRequest(GeckoSession.this, permission);
+ callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return true;
+ }
+ if (value == AllowOrDeny.DENY) {
+ return false;
+ }
+ throw new IllegalArgumentException("Invalid response");
+ }));
+ } else if ("GeckoView:DismissClipboardPermissionRequest".equals(event)) {
+ delegate.onDismissClipboardPermissionRequest(GeckoSession.this);
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<MediaDelegate> mMediaHandler =
+ new GeckoSessionHandler<MediaDelegate>(
+ "GeckoViewMedia",
+ this,
+ new String[] {
+ "GeckoView:MediaRecordingStatusChanged",
+ }) {
+ @Override
+ public void handleMessage(
+ final MediaDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:MediaRecordingStatusChanged".equals(event)) {
+ final GeckoBundle[] deviceBundles = message.getBundleArray("devices");
+ final MediaDelegate.RecordingDevice[] devices =
+ new MediaDelegate.RecordingDevice[deviceBundles.length];
+ for (int i = 0; i < deviceBundles.length; i++) {
+ devices[i] = new MediaDelegate.RecordingDevice(deviceBundles[i]);
+ }
+ delegate.onRecordingStatusChanged(GeckoSession.this, devices);
+ return;
+ }
+ }
+ };
+
+ private final MediaSession.Handler mMediaSessionHandler = new MediaSession.Handler(this);
+ private final TranslationsController.SessionTranslation.Handler mTranslationsHandler =
+ mTranslations.getHandler();
+
+ /* package */ int handlersCount;
+
+ private final GeckoSessionHandler<?>[] mSessionHandlers =
+ new GeckoSessionHandler<?>[] {
+ mContentHandler,
+ mHistoryHandler,
+ mMediaHandler,
+ mNavigationHandler,
+ mPermissionHandler,
+ mPrintHandler,
+ mProcessHangHandler,
+ mProgressHandler,
+ mScrollHandler,
+ mSelectionActionDelegate,
+ mTranslationsHandler,
+ mContentBlockingHandler,
+ mMediaSessionHandler,
+ mExperimentHandler
+ };
+
+ private static class PermissionCallback
+ implements PermissionDelegate.Callback, PermissionDelegate.MediaCallback {
+
+ private final String mType;
+ private EventCallback mCallback;
+
+ public PermissionCallback(final String type, final EventCallback callback) {
+ mType = type;
+ mCallback = callback;
+ }
+
+ private void submit(final Object response) {
+ if (mCallback != null) {
+ mCallback.sendSuccess(response);
+ mCallback = null;
+ }
+ }
+
+ @Override // PermissionDelegate.Callback
+ public void grant() {
+ if ("media".equals(mType)) {
+ throw new UnsupportedOperationException();
+ }
+ submit(/* response */ true);
+ }
+
+ @Override // PermissionDelegate.Callback, PermissionDelegate.MediaCallback
+ public void reject() {
+ submit(/* response */ false);
+ }
+
+ @Override // PermissionDelegate.MediaCallback
+ public void grant(final String video, final String audio) {
+ if (!"media".equals(mType)) {
+ throw new UnsupportedOperationException();
+ }
+ final GeckoBundle response = new GeckoBundle(2);
+ response.putString("video", video);
+ response.putString("audio", audio);
+ submit(response);
+ }
+
+ @Override // PermissionDelegate.MediaCallback
+ public void grant(
+ final PermissionDelegate.MediaSource video, final PermissionDelegate.MediaSource audio) {
+ grant(video != null ? video.id : null, audio != null ? audio.id : null);
+ }
+ }
+
+ /**
+ * Get the current user agent string for this GeckoSession.
+ *
+ * @return a {@link GeckoResult} containing the UserAgent string
+ */
+ @AnyThread
+ public @NonNull GeckoResult<String> getUserAgent() {
+ return mEventDispatcher.queryString("GeckoView:GetUserAgent");
+ }
+
+ /**
+ * Get the default user agent for this GeckoView build.
+ *
+ * <p>This method does not account for any override that might have been applied to the user agent
+ * string.
+ *
+ * @return the default user agent string
+ */
+ @AnyThread
+ public static @NonNull String getDefaultUserAgent() {
+ return BuildConfig.USER_AGENT_GECKOVIEW_MOBILE;
+ }
+
+ /**
+ * Get the current permission delegate for this GeckoSession.
+ *
+ * @return PermissionDelegate instance or null if using default delegate.
+ */
+ @UiThread
+ public @Nullable PermissionDelegate getPermissionDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mPermissionHandler.getDelegate();
+ }
+
+ /**
+ * Set the current permission delegate for this GeckoSession.
+ *
+ * @param delegate PermissionDelegate instance or null to use the default delegate.
+ */
+ @UiThread
+ public void setPermissionDelegate(final @Nullable PermissionDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mPermissionHandler.setDelegate(delegate, this);
+ }
+
+ private PromptDelegate mPromptDelegate;
+
+ private final Listener mListener = new Listener();
+
+ /* package */ static final class Window extends JNIObject implements IInterface {
+ public final GeckoRuntime runtime;
+ private WeakReference<GeckoSession> mOwner;
+ private NativeQueue mNativeQueue;
+ private Binder mBinder;
+
+ public Window(
+ final @NonNull GeckoRuntime runtime,
+ final @NonNull GeckoSession owner,
+ final @NonNull NativeQueue nativeQueue) {
+ this.runtime = runtime;
+ mOwner = new WeakReference<>(owner);
+ mNativeQueue = nativeQueue;
+ }
+
+ @Override // IInterface
+ public Binder asBinder() {
+ if (mBinder == null) {
+ mBinder = new Binder();
+ mBinder.attachInterface(this, Window.class.getName());
+ }
+ return mBinder;
+ }
+
+ // Create a new Gecko window and assign an initial set of Java session objects to it.
+ @WrapForJNI(dispatchTo = "proxy")
+ public static native void open(
+ Window instance,
+ NativeQueue queue,
+ Compositor compositor,
+ EventDispatcher dispatcher,
+ SessionAccessibility.NativeProvider sessionAccessibility,
+ GeckoBundle initData,
+ String id,
+ String chromeUri,
+ boolean privateMode);
+
+ @Override // JNIObject
+ public void disposeNative() {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeDisposeNative();
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY, this, "nativeDisposeNative");
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "DisposeNative")
+ private native void nativeDisposeNative();
+
+ // Force the underlying Gecko window to close and release assigned Java objects.
+ public void close() {
+ // Reset our queue, so we don't end up with queued calls on a disposed object.
+ synchronized (this) {
+ if (mNativeQueue == null) {
+ // Already closed elsewhere.
+ return;
+ }
+ mNativeQueue.reset(State.INITIAL);
+ mNativeQueue = null;
+ mOwner = new WeakReference<>(null);
+ }
+
+ // Detach ourselves from the binder as well, to prevent this window from being
+ // read from any parcels.
+ asBinder().attachInterface(null, Window.class.getName());
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeClose();
+ } else {
+ GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, this, "nativeClose");
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "Close")
+ private native void nativeClose();
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "Transfer")
+ private native void nativeTransfer(
+ NativeQueue queue,
+ Compositor compositor,
+ EventDispatcher dispatcher,
+ SessionAccessibility.NativeProvider sessionAccessibility,
+ GeckoBundle initData);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void attachEditable(IGeckoEditableParent parent);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void attachAccessibility(
+ SessionAccessibility.NativeProvider sessionAccessibility);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void printToPdf(GeckoResult<InputStream> geckoResult);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ private native void printToPdf(GeckoResult<InputStream> geckoResult, long browserContextId);
+
+ @WrapForJNI(calledFrom = "gecko")
+ private synchronized void onReady(final @Nullable NativeQueue queue) {
+ // onReady is called the first time the Gecko window is ready, with a null queue
+ // argument. In this case, we simply set the current queue to ready state.
+ //
+ // After the initial call, onReady is called again every time Window.transfer()
+ // is called, with a non-null queue argument. In this case, we only set the
+ // current queue to ready state _if_ the current queue matches the given queue,
+ // because if the queues don't match, we know there is another onReady call coming.
+
+ if ((queue == null && mNativeQueue == null) || (queue != null && mNativeQueue != queue)) {
+ return;
+ }
+
+ if (mNativeQueue.checkAndSetState(State.INITIAL, State.READY) && queue == null) {
+ Log.i(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - chrome startup finished");
+ }
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ close();
+ disposeNative();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private GeckoResult<Boolean> onLoadRequest(
+ final @NonNull String uri,
+ final int windowType,
+ final int flags,
+ final @Nullable String triggeringUri,
+ final boolean hasUserGesture,
+ final boolean isTopLevel) {
+ final ProfilerController profilerController = runtime.getProfilerController();
+ final Double onLoadRequestProfilerStartTime = profilerController.getProfilerTime();
+ final Runnable addMarker =
+ () ->
+ profilerController.addMarker(
+ "GeckoSession.onLoadRequest", onLoadRequestProfilerStartTime);
+
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ // Don't handle any load request if we can't get the session for some reason.
+ return GeckoResult.fromValue(false);
+ }
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+
+ ThreadUtils.postToUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ final NavigationDelegate delegate = session.getNavigationDelegate();
+
+ if (delegate == null) {
+ res.complete(false);
+ addMarker.run();
+ return;
+ }
+
+ if (!IntentUtils.isUriSafeForScheme(uri)) {
+ delegate.onLoadError(
+ session,
+ uri,
+ new WebRequestError(
+ WebRequestError.ERROR_MALFORMED_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ null));
+ res.complete(true);
+ addMarker.run();
+ return;
+ }
+
+ final String trigger = TextUtils.isEmpty(triggeringUri) ? null : triggeringUri;
+ final NavigationDelegate.LoadRequest req =
+ new NavigationDelegate.LoadRequest(
+ uri,
+ trigger,
+ windowType,
+ flags,
+ hasUserGesture,
+ false /* isDirectNavigation */);
+ final GeckoResult<AllowOrDeny> reqResponse =
+ isTopLevel
+ ? delegate.onLoadRequest(session, req)
+ : delegate.onSubframeLoadRequest(session, req);
+
+ if (reqResponse == null) {
+ res.complete(false);
+ addMarker.run();
+ return;
+ }
+
+ reqResponse.accept(
+ value -> {
+ if (value == AllowOrDeny.DENY) {
+ res.complete(true);
+ } else {
+ res.complete(false);
+ }
+ addMarker.run();
+ },
+ ex -> {
+ res.complete(false);
+ addMarker.run();
+ });
+ }
+ });
+
+ return res;
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void passExternalWebResponse(final WebResponse response) {
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ return;
+ }
+ final ContentDelegate delegate = session.getContentDelegate();
+ if (delegate != null) {
+ delegate.onExternalResponse(session, response);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onShowDynamicToolbar() {
+ final Window self = this;
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoSession session = self.mOwner.get();
+ if (session == null) {
+ return;
+ }
+ final ContentDelegate delegate = session.getContentDelegate();
+ if (delegate != null) {
+ delegate.onShowDynamicToolbar(session);
+ }
+ });
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onUpdateSessionStore(final GeckoBundle aBundle) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ return;
+ }
+ GeckoBundle scroll = aBundle.getBundle("scroll");
+ if (scroll == null) {
+ scroll = new GeckoBundle();
+ aBundle.putBundle("scroll", scroll);
+ }
+
+ // Here we unfortunately need to do some re-mapping since `zoom` is passed in a separate
+ // bunds and we wish to keep the bundle format.
+ scroll.putBundle("zoom", aBundle.getBundle("zoom"));
+ final SessionState stateCache = session.mStateCache;
+ stateCache.updateSessionState(aBundle);
+ final SessionState state = new SessionState(stateCache);
+ if (!state.isEmpty()) {
+ final ProgressDelegate progressDelegate = session.getProgressDelegate();
+ if (progressDelegate != null) {
+ progressDelegate.onSessionStateChange(session, state);
+ } else {
+ }
+ }
+ });
+ }
+ }
+
+ private class Listener implements BundleEventListener {
+ /* package */ void registerListeners() {
+ getEventDispatcher()
+ .registerUiThreadListener(
+ this,
+ "GeckoView:PinOnScreen",
+ "GeckoView:Prompt",
+ "GeckoView:Prompt:Dismiss",
+ "GeckoView:Prompt:Update",
+ null);
+ }
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event);
+
+ if ("GeckoView:PinOnScreen".equals(event)) {
+ GeckoSession.this.setShouldPinOnScreen(message.getBoolean("pinned"));
+ } else if ("GeckoView:Prompt".equals(event)) {
+ mPromptController.handleEvent(GeckoSession.this, message.getBundle("prompt"), callback);
+ } else if ("GeckoView:Prompt:Dismiss".equals(event)) {
+ mPromptController.dismissPrompt(message.getString("id"));
+ } else if ("GeckoView:Prompt:Update".equals(event)) {
+ mPromptController.updatePrompt(message.getBundle("prompt"));
+ }
+ }
+ }
+
+ private final PromptController mPromptController;
+
+ protected @Nullable Window mWindow;
+ private GeckoSessionSettings mSettings;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSession() {
+ this(null);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSession(final @Nullable GeckoSessionSettings settings) {
+ mSettings = new GeckoSessionSettings(settings, this);
+ mListener.registerListeners();
+
+ mWebExtensionController = new WebExtension.SessionController(this);
+ mPromptController = new PromptController();
+
+ mAutofillSupport = new Autofill.Support(this);
+ mAutofillSupport.registerListeners();
+
+ if (BuildConfig.DEBUG_BUILD && handlersCount != mSessionHandlers.length) {
+ throw new AssertionError("Add new handler to handlers list");
+ }
+ }
+
+ /* package */ @Nullable
+ GeckoRuntime getRuntime() {
+ if (mWindow == null) {
+ return null;
+ }
+ return mWindow.runtime;
+ }
+
+ /* package */ synchronized void abandonWindow() {
+ if (mWindow == null) {
+ return;
+ }
+
+ onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ true);
+ mWindow = null;
+ onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ false);
+ }
+
+ /**
+ * Return whether this session is open.
+ *
+ * @return True if session is open.
+ * @see #open
+ * @see #close
+ */
+ @UiThread
+ public boolean isOpen() {
+ ThreadUtils.assertOnUiThread();
+ return mWindow != null;
+ }
+
+ /* package */ boolean isReady() {
+ return mNativeQueue.isReady();
+ }
+
+ private GeckoBundle createInitData() {
+ final GeckoBundle initData = new GeckoBundle(2);
+ initData.putBundle("settings", mSettings.toBundle());
+
+ final GeckoBundle modules = new GeckoBundle(mSessionHandlers.length);
+ for (final GeckoSessionHandler<?> handler : mSessionHandlers) {
+ modules.putBoolean(handler.getName(), handler.isEnabled());
+ }
+ initData.putBundle("modules", modules);
+ return initData;
+ }
+
+ /**
+ * Opens the session.
+ *
+ * <p>Call this when you are ready to use a GeckoSession instance.
+ *
+ * <p>The session is in a 'closed' state when first created. Opening it creates the underlying
+ * Gecko objects necessary to load a page, etc. Most GeckoSession methods only take affect on an
+ * open session, and are queued until the session is opened here. Opening a session is an
+ * asynchronous operation.
+ *
+ * @param runtime The Gecko runtime to attach this session to.
+ * @see #close
+ * @see #isOpen
+ */
+ @UiThread
+ public void open(final @NonNull GeckoRuntime runtime) {
+ open(runtime, UUID.randomUUID().toString().replace("-", ""));
+ }
+
+ /* package */ void open(final @NonNull GeckoRuntime runtime, final String id) {
+ ThreadUtils.assertOnUiThread();
+
+ if (isOpen()) {
+ // We will leak the existing Window if we open another one.
+ throw new IllegalStateException("Session is open");
+ }
+
+ final String chromeUri = mSettings.getChromeUri();
+ final boolean isPrivate = mSettings.getUsePrivateMode();
+
+ mId = id;
+ mWindow = new Window(runtime, this, mNativeQueue);
+ mWebExtensionController.setRuntime(runtime);
+ mExperimentHandler.setDelegate(getRuntimeExperimentDelegate(), this);
+
+ onWindowChanged(WINDOW_OPEN, /* inProgress */ true);
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ Window.open(
+ mWindow,
+ mNativeQueue,
+ mCompositor,
+ mEventDispatcher,
+ mAccessibility != null ? mAccessibility.nativeProvider : null,
+ createInitData(),
+ mId,
+ chromeUri,
+ isPrivate);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ Window.class,
+ "open",
+ Window.class,
+ mWindow,
+ NativeQueue.class,
+ mNativeQueue,
+ Compositor.class,
+ mCompositor,
+ EventDispatcher.class,
+ mEventDispatcher,
+ SessionAccessibility.NativeProvider.class,
+ mAccessibility != null ? mAccessibility.nativeProvider : null,
+ GeckoBundle.class,
+ createInitData(),
+ String.class,
+ mId,
+ String.class,
+ chromeUri,
+ isPrivate);
+ }
+
+ onWindowChanged(WINDOW_OPEN, /* inProgress */ false);
+ }
+
+ /**
+ * Closes the session.
+ *
+ * <p>This frees the underlying Gecko objects and unloads the current page. The session may be
+ * reopened later, but page state is not restored. Call this when you are finished using a
+ * GeckoSession instance.
+ *
+ * @see #open
+ * @see #isOpen
+ */
+ @UiThread
+ public void close() {
+ ThreadUtils.assertOnUiThread();
+
+ if (!isOpen()) {
+ Log.w(LOGTAG, "Attempted to close a GeckoSession that was already closed.");
+ return;
+ }
+
+ onWindowChanged(WINDOW_CLOSE, /* inProgress */ true);
+
+ // We need to ensure the compositor releases any Surface it currently holds.
+ onSurfaceDestroyed();
+
+ mWindow.close();
+ mWindow.disposeNative();
+ // Can't access the compositor after we dispose of the window
+ mCompositorReady = false;
+ mWindow = null;
+
+ onWindowChanged(WINDOW_CLOSE, /* inProgress */ false);
+ }
+
+ private void onWindowChanged(final int change, final boolean inProgress) {
+ if ((change == WINDOW_OPEN || change == WINDOW_TRANSFER_IN) && !inProgress) {
+ mTextInput.onWindowChanged(mWindow);
+ }
+ if ((change == WINDOW_CLOSE || change == WINDOW_TRANSFER_OUT) && !inProgress) {
+ getAutofillSupport().clear();
+ }
+ }
+
+ /**
+ * Get the SessionTextInput instance for this session. May be called on any thread.
+ *
+ * @return SessionTextInput instance.
+ */
+ @AnyThread
+ public @NonNull SessionTextInput getTextInput() {
+ // May be called on any thread.
+ return mTextInput;
+ }
+
+ /**
+ * Get the SessionAccessibility instance for this session.
+ *
+ * @return SessionAccessibility instance.
+ */
+ @UiThread
+ public @NonNull SessionAccessibility getAccessibility() {
+ ThreadUtils.assertOnUiThread();
+ if (mAccessibility != null) {
+ return mAccessibility;
+ }
+
+ mAccessibility = new SessionAccessibility(this);
+ if (mWindow != null) {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ mWindow.attachAccessibility(mAccessibility.nativeProvider);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ mWindow,
+ "attachAccessibility",
+ SessionAccessibility.NativeProvider.class,
+ mAccessibility.nativeProvider);
+ }
+ }
+ return mAccessibility;
+ }
+
+ /**
+ * Get the SessionMagnifier instance for this session.
+ *
+ * @return SessionMagnifier instance.
+ */
+ @UiThread
+ /* package */ @NonNull
+ SessionMagnifier getMagnifier() {
+ ThreadUtils.assertOnUiThread();
+ if (mMagnifier == null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ mMagnifier = new SessionMagnifierP(mCompositor);
+ } else {
+ mMagnifier = new SessionMagnifier() {};
+ }
+ }
+
+ return mMagnifier;
+ }
+
+ // The priority of the GeckoSession, either default or high.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({PRIORITY_DEFAULT, PRIORITY_HIGH})
+ public @interface Priority {}
+
+ /** Value for Priority when it is default. */
+ public static final int PRIORITY_DEFAULT = 0;
+
+ /** Value for Priority when it is high. */
+ public static final int PRIORITY_HIGH = 1;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ LOAD_FLAGS_NONE,
+ LOAD_FLAGS_BYPASS_CACHE,
+ LOAD_FLAGS_BYPASS_PROXY,
+ LOAD_FLAGS_EXTERNAL,
+ LOAD_FLAGS_ALLOW_POPUPS,
+ LOAD_FLAGS_FORCE_ALLOW_DATA_URI,
+ LOAD_FLAGS_REPLACE_HISTORY,
+ LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
+ })
+ public @interface LoadFlags {}
+
+ // These flags follow similarly named ones in Gecko's nsIWebNavigation.idl
+ // https://searchfox.org/mozilla-central/source/docshell/base/nsIWebNavigation.idl
+ //
+ // We do not use the same values directly in order to insulate ourselves from
+ // changes in Gecko. Instead, the flags are converted in GeckoViewNavigation.jsm.
+
+ /** Default load flag, no special considerations. */
+ public static final int LOAD_FLAGS_NONE = 0;
+
+ /** Bypass the cache. */
+ public static final int LOAD_FLAGS_BYPASS_CACHE = 1 << 0;
+
+ /** Bypass the proxy, if one has been configured. */
+ public static final int LOAD_FLAGS_BYPASS_PROXY = 1 << 1;
+
+ /** The load is coming from an external app. Perform additional checks. */
+ public static final int LOAD_FLAGS_EXTERNAL = 1 << 2;
+
+ /** Popup blocking will be disabled for this load */
+ public static final int LOAD_FLAGS_ALLOW_POPUPS = 1 << 3;
+
+ /** Bypass the URI classifier (content blocking and Safe Browsing). */
+ public static final int LOAD_FLAGS_BYPASS_CLASSIFIER = 1 << 4;
+
+ /**
+ * Allows a top-level data: navigation to occur. E.g. view-image is an explicit user action which
+ * should be allowed.
+ */
+ public static final int LOAD_FLAGS_FORCE_ALLOW_DATA_URI = 1 << 5;
+
+ /** This flag specifies that any existing history entry should be replaced. */
+ public static final int LOAD_FLAGS_REPLACE_HISTORY = 1 << 6;
+
+ /** This load should bypass the NavigationDelegate.onLoadRequest. */
+ public static final int LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE = 1 << 7;
+
+ /**
+ * Filter headers according to the CORS safelisted rules.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header">
+ * CORS-safelisted request header </a>.
+ */
+ public static final int HEADER_FILTER_CORS_SAFELISTED = 1;
+
+ /**
+ * Allows most headers.
+ *
+ * <p>Note: the <code>Host</code> and <code>Connection</code> headers are still ignored.
+ *
+ * <p>This should only be used when input is hard-coded from the app or when properly sanitized,
+ * as some headers could cause unexpected consequences and security issues.
+ *
+ * <p>Only use this if you know what you're doing.
+ */
+ public static final int HEADER_FILTER_UNRESTRICTED_UNSAFE = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(value = {HEADER_FILTER_CORS_SAFELISTED, HEADER_FILTER_UNRESTRICTED_UNSAFE})
+ public @interface HeaderFilter {}
+
+ /**
+ * Main entry point for loading URIs into a {@link GeckoSession}.
+ *
+ * <p>The simplest use case is loading a URIs with no extra options, this can be accomplished by
+ * specifying the URI in {@link #uri} and then calling {@link #load}, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader().uri("http://mozilla.org"));
+ * </code></pre>
+ *
+ * This class can also be used to load <code>data:</code> URIs, either from a <code>byte[]</code>
+ * array or a <code>String</code> using {@link #data}, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader().data("the data:1234,5678", "text/plain"));
+ * </code></pre>
+ *
+ * This class also allows you to specify some extra data, e.g. you can set a referrer using {@link
+ * #referrer} which can either be a {@link GeckoSession} or a plain URL string. You can also
+ * specify some Load Flags using {@link #flags}.
+ *
+ * <p>The class is structured as a Builder, so method calls can be easily chained, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader()
+ * .url("http://mozilla.org")
+ * .referrer("http://my-referrer.com")
+ * .flags(...));
+ * </code></pre>
+ */
+ @AnyThread
+ public static class Loader {
+ private String mUri;
+ private GeckoSession mReferrerSession;
+ private String mReferrerUri;
+ private GeckoBundle mHeaders;
+ private @LoadFlags int mLoadFlags = LOAD_FLAGS_NONE;
+ private boolean mIsDataUri;
+ private @HeaderFilter int mHeaderFilter = HEADER_FILTER_CORS_SAFELISTED;
+
+ private static @NonNull String createDataUri(
+ @NonNull final byte[] bytes, @Nullable final String mimeType) {
+ return String.format(
+ "data:%s;base64,%s",
+ mimeType != null ? mimeType : "", Base64.encodeToString(bytes, Base64.NO_WRAP));
+ }
+
+ private static @NonNull String createDataUri(
+ @NonNull final String data, @Nullable final String mimeType) {
+ return String.format("data:%s,%s", mimeType != null ? mimeType : "", data);
+ }
+
+ @Override
+ public int hashCode() {
+ // Move to Objects.hashCode once our MIN_SDK >= 19
+ return Arrays.hashCode(
+ new Object[] {
+ mUri, mReferrerSession, mReferrerUri, mHeaders, mLoadFlags, mIsDataUri, mHeaderFilter
+ });
+ }
+
+ private static boolean equals(final Object a, final Object b) {
+ return Objects.equals(a, b);
+ }
+
+ @Override
+ public boolean equals(final @Nullable Object obj) {
+ if (!(obj instanceof Loader)) {
+ return false;
+ }
+
+ final Loader other = (Loader) obj;
+ return equals(mUri, other.mUri)
+ && equals(mReferrerSession, other.mReferrerSession)
+ && equals(mReferrerUri, other.mReferrerUri)
+ && equals(mHeaders, other.mHeaders)
+ && equals(mLoadFlags, other.mLoadFlags)
+ && equals(mIsDataUri, other.mIsDataUri)
+ && equals(mHeaderFilter, other.mHeaderFilter);
+ }
+
+ /**
+ * Set the URI of the resource to load.
+ *
+ * @param uri a String containg the URI
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader uri(final @NonNull String uri) {
+ mUri = uri;
+ mIsDataUri = false;
+ return this;
+ }
+
+ /**
+ * Set the URI of the resource to load.
+ *
+ * @param uri a {@link Uri} instance
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader uri(final @NonNull Uri uri) {
+ mUri = uri.toString();
+ mIsDataUri = false;
+ return this;
+ }
+
+ /**
+ * Set the data URI of the resource to load.
+ *
+ * @param bytes a <code>byte</code> array containing the data to load.
+ * @param mimeType a <code>String</code> containing the mime type for this data URI, e.g.
+ * "text/plain"
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader data(final @NonNull byte[] bytes, final @Nullable String mimeType) {
+ mUri = createDataUri(bytes, mimeType);
+ mIsDataUri = true;
+ return this;
+ }
+
+ /**
+ * Set the data URI of the resource to load.
+ *
+ * @param data a <code>String</code> array containing the data to load.
+ * @param mimeType a <code>String</code> containing the mime type for this data URI, e.g.
+ * "text/plain"
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader data(final @NonNull String data, final @Nullable String mimeType) {
+ mUri = createDataUri(data, mimeType);
+ mIsDataUri = true;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrer a <code>GeckoSession</code> that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull GeckoSession referrer) {
+ mReferrerSession = referrer;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrerUri a {@link Uri} that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull Uri referrerUri) {
+ mReferrerUri = referrerUri != null ? referrerUri.toString() : null;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrerUri a <code>String</code> containing the URI that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull String referrerUri) {
+ mReferrerUri = referrerUri;
+ return this;
+ }
+
+ /**
+ * Add headers for this load.
+ *
+ * <p>Note: only CORS safelisted headers are allowed by default. To modify this behavior use
+ * {@link #headerFilter}.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header">
+ * CORS-safelisted request header </a>.
+ *
+ * @param headers a <code>Map</code> containing headers that will be added to this load.
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader additionalHeaders(final @NonNull Map<String, String> headers) {
+ final GeckoBundle bundle = new GeckoBundle(headers.size());
+ for (final Map.Entry<String, String> entry : headers.entrySet()) {
+ if (entry.getKey() == null) {
+ // Ignore null keys
+ continue;
+ }
+ bundle.putString(entry.getKey(), entry.getValue());
+ }
+ mHeaders = bundle;
+ return this;
+ }
+
+ /**
+ * Modify the header filter behavior. By default only CORS safelisted headers are allowed.
+ *
+ * @param filter one of the {@link GeckoSession#HEADER_FILTER_CORS_SAFELISTED HEADER_FILTER_*}
+ * constants.
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader headerFilter(final @HeaderFilter int filter) {
+ mHeaderFilter = filter;
+ return this;
+ }
+
+ /**
+ * Set the load flags for this load.
+ *
+ * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*}
+ * that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader flags(final @LoadFlags int flags) {
+ mLoadFlags = flags;
+ return this;
+ }
+ }
+
+ /**
+ * Load page using the {@link Loader} specified.
+ *
+ * @param request Loader for this request.
+ * @see Loader
+ */
+ @AnyThread
+ public void load(final @NonNull Loader request) {
+ if (request.mUri == null) {
+ throw new IllegalArgumentException(
+ "You need to specify at least one between `uri` and `data`.");
+ }
+
+ if (request.mReferrerUri != null && request.mReferrerSession != null) {
+ throw new IllegalArgumentException(
+ "Cannot specify both a referrer session and a referrer URI.");
+ }
+
+ final NavigationDelegate navDelegate = mNavigationHandler.getDelegate();
+ final boolean isDataUriTooLong = !maybeCheckDataUriLength(request);
+ if (navDelegate == null && isDataUriTooLong) {
+ throw new IllegalArgumentException("data URI is too long");
+ }
+
+ final int loadFlags =
+ request.mIsDataUri
+ // If this is a data: load then we need to force allow it.
+ ? request.mLoadFlags | LOAD_FLAGS_FORCE_ALLOW_DATA_URI
+ : request.mLoadFlags;
+
+ // For performance reasons we short-circuit the delegate here
+ // instead of making Gecko call it for direct load calls.
+ final NavigationDelegate.LoadRequest loadRequest =
+ new NavigationDelegate.LoadRequest(
+ request.mUri,
+ null, /* triggerUri */
+ 1, /* geckoTarget: OPEN_CURRENTWINDOW */
+ 0, /* flags */
+ false, /* hasUserGesture */
+ true /* isDirectNavigation */);
+
+ shouldLoadUri(loadRequest, loadFlags)
+ .getOrAccept(
+ allowOrDeny -> {
+ if (allowOrDeny == AllowOrDeny.DENY) {
+ return;
+ }
+
+ if (isDataUriTooLong) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ navDelegate.onLoadError(
+ this,
+ request.mUri,
+ new WebRequestError(
+ WebRequestError.ERROR_DATA_URI_TOO_LONG,
+ WebRequestError.ERROR_CATEGORY_URI,
+ null));
+ });
+ return;
+ }
+
+ final GeckoBundle msg = new GeckoBundle();
+ msg.putString("uri", request.mUri);
+ msg.putInt("flags", loadFlags);
+ msg.putInt("headerFilter", request.mHeaderFilter);
+
+ if (request.mReferrerUri != null) {
+ msg.putString("referrerUri", request.mReferrerUri);
+ }
+
+ if (request.mReferrerSession != null) {
+ msg.putString("referrerSessionId", request.mReferrerSession.mId);
+ }
+
+ if (request.mHeaders != null) {
+ msg.putBundle("headers", request.mHeaders);
+ }
+
+ mEventDispatcher.dispatch("GeckoView:LoadUri", msg);
+ });
+ }
+
+ /**
+ * Load the given URI.
+ *
+ * <p>Convenience method for
+ *
+ * <pre><code>
+ * session.load(new Loader().uri(uri));
+ * </code></pre>
+ *
+ * @param uri The URI of the resource to load.
+ */
+ @AnyThread
+ public void loadUri(final @NonNull String uri) {
+ load(new Loader().uri(uri));
+ }
+
+ private GeckoResult<AllowOrDeny> shouldLoadUri(
+ final NavigationDelegate.LoadRequest request, final int loadFlags) {
+ final NavigationDelegate delegate = mNavigationHandler.getDelegate();
+ if (delegate == null || (loadFlags & LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE) != 0) {
+ return GeckoResult.allow();
+ }
+
+ // Always run the callback on the UI thread regardless of what thread we were called in.
+ final GeckoResult<AllowOrDeny> result = new GeckoResult<>(ThreadUtils.getUiHandler());
+
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoResult<AllowOrDeny> delegateResult = delegate.onLoadRequest(this, request);
+
+ if (delegateResult == null) {
+ result.complete(AllowOrDeny.ALLOW);
+ } else {
+ delegateResult.getOrAccept(
+ allowOrDeny -> result.complete(allowOrDeny),
+ error -> result.completeExceptionally(error));
+ }
+ });
+
+ return result;
+ }
+
+ /** Reload the current URI. */
+ @AnyThread
+ public void reload() {
+ reload(LOAD_FLAGS_NONE);
+ }
+
+ /**
+ * Reload the current URI.
+ *
+ * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*}
+ */
+ @AnyThread
+ public void reload(final @LoadFlags int flags) {
+ final GeckoBundle msg = new GeckoBundle();
+ msg.putInt("flags", flags);
+ mEventDispatcher.dispatch("GeckoView:Reload", msg);
+ }
+
+ /** Stop loading. */
+ @AnyThread
+ public void stop() {
+ mEventDispatcher.dispatch("GeckoView:Stop", null);
+ }
+
+ /**
+ * Go back in history and assumes the call was based on a user interaction.
+ *
+ * @see #goBack(boolean)
+ */
+ @AnyThread
+ public void goBack() {
+ goBack(true);
+ }
+
+ /**
+ * Go back in history.
+ *
+ * @param userInteraction Whether the action was invoked by a user interaction.
+ */
+ @AnyThread
+ public void goBack(final boolean userInteraction) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("userInteraction", userInteraction);
+ mEventDispatcher.dispatch("GeckoView:GoBack", msg);
+ }
+
+ /**
+ * Go forward in history and assumes the call was based on a user interaction.
+ *
+ * @see #goForward(boolean)
+ */
+ @AnyThread
+ public void goForward() {
+ goForward(true);
+ }
+
+ /**
+ * Go forward in history.
+ *
+ * @param userInteraction Whether the action was invoked by a user interaction.
+ */
+ @AnyThread
+ public void goForward(final boolean userInteraction) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("userInteraction", userInteraction);
+ mEventDispatcher.dispatch("GeckoView:GoForward", msg);
+ }
+
+ /**
+ * Navigate to an index in browser history; the index of the currently viewed page can be
+ * retrieved from an up-to-date HistoryList by calling {@link
+ * HistoryDelegate.HistoryList#getCurrentIndex()}.
+ *
+ * @param index The index of the location in browser history you want to navigate to.
+ */
+ @AnyThread
+ public void gotoHistoryIndex(final int index) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putInt("index", index);
+ mEventDispatcher.dispatch("GeckoView:GotoHistoryIndex", msg);
+ }
+
+ /**
+ * Returns a WebExtensionController for this GeckoSession. Delegates attached to this controller
+ * will receive events specific to this session.
+ *
+ * @return an instance of {@link WebExtension.SessionController}.
+ */
+ @UiThread
+ public @NonNull WebExtension.SessionController getWebExtensionController() {
+ return mWebExtensionController;
+ }
+
+ /**
+ * Purge history for the session. The session history is used for back and forward history.
+ * Purging the session history means {@link NavigationDelegate#onCanGoBack(GeckoSession, boolean)}
+ * and {@link NavigationDelegate#onCanGoForward(GeckoSession, boolean)} will be false.
+ */
+ @AnyThread
+ public void purgeHistory() {
+ mEventDispatcher.dispatch("GeckoView:PurgeHistory", null);
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ FINDER_FIND_BACKWARDS,
+ FINDER_FIND_LINKS_ONLY,
+ FINDER_FIND_MATCH_CASE,
+ FINDER_FIND_WHOLE_WORD
+ })
+ public @interface FinderFindFlags {}
+
+ /** Go backwards when finding the next match. */
+ public static final int FINDER_FIND_BACKWARDS = 1;
+
+ /** Perform case-sensitive match; default is to perform a case-insensitive match. */
+ public static final int FINDER_FIND_MATCH_CASE = 1 << 1;
+
+ /** Must match entire words; default is to allow matching partial words. */
+ public static final int FINDER_FIND_WHOLE_WORD = 1 << 2;
+
+ /** Limit matches to links on the page. */
+ public static final int FINDER_FIND_LINKS_ONLY = 1 << 3;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ FINDER_DISPLAY_HIGHLIGHT_ALL,
+ FINDER_DISPLAY_DIM_PAGE,
+ FINDER_DISPLAY_DRAW_LINK_OUTLINE
+ })
+ public @interface FinderDisplayFlags {}
+
+ /** Highlight all find-in-page matches. */
+ public static final int FINDER_DISPLAY_HIGHLIGHT_ALL = 1;
+
+ /** Dim the rest of the page when showing a find-in-page match. */
+ public static final int FINDER_DISPLAY_DIM_PAGE = 1 << 1;
+
+ /** Draw outlines around matching links. */
+ public static final int FINDER_DISPLAY_DRAW_LINK_OUTLINE = 1 << 2;
+
+ /** Represent the result of a find-in-page operation. */
+ @AnyThread
+ public static class FinderResult {
+ /** Whether a match was found. */
+ public final boolean found;
+
+ /** Whether the search wrapped around the top or bottom of the page. */
+ public final boolean wrapped;
+
+ /** Ordinal number of the current match starting from 1, or 0 if no match. */
+ public final int current;
+
+ /** Total number of matches found so far, or -1 if unknown. */
+ public final int total;
+
+ /** Search string. */
+ @NonNull public final String searchString;
+
+ /**
+ * Flags used for the search; either 0 or a combination of {@link #FINDER_FIND_BACKWARDS
+ * FINDER_FIND_*} flags.
+ */
+ @FinderFindFlags public final int flags;
+
+ /** URI of the link, if the current match is a link, or null otherwise. */
+ @Nullable public final String linkUri;
+
+ /** Bounds of the current match in client coordinates, or null if unknown. */
+ @Nullable public final RectF clientRect;
+
+ /* package */ FinderResult(@NonNull final GeckoBundle bundle) {
+ found = bundle.getBoolean("found");
+ wrapped = bundle.getBoolean("wrapped");
+ current = bundle.getInt("current", 0);
+ total = bundle.getInt("total", -1);
+ searchString = bundle.getString("searchString");
+ flags = SessionFinder.getFlagsFromBundle(bundle.getBundle("flags"));
+ linkUri = bundle.getString("linkURL");
+ clientRect = bundle.getRectF("clientRect");
+ }
+
+ /** Empty constructor for tests */
+ protected FinderResult() {
+ found = false;
+ wrapped = false;
+ current = 0;
+ total = 0;
+ flags = 0;
+ searchString = "";
+ linkUri = "";
+ clientRect = null;
+ }
+ }
+
+ /**
+ * Get the SessionFinder instance for this session, to perform find-in-page operations.
+ *
+ * @return SessionFinder instance.
+ */
+ @AnyThread
+ public @NonNull SessionFinder getFinder() {
+ if (mFinder == null) {
+ mFinder = new SessionFinder(getEventDispatcher());
+ }
+ return mFinder;
+ }
+
+ /**
+ * Checks whether we have a rule for this session. Uses the browsing context or any of its
+ * children, calls nsICookieBannerService.hasRuleForBrowsingContextTree
+ *
+ * @return {@link GeckoResult} with boolean
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> hasCookieBannerRuleForBrowsingContextTree() {
+ return mEventDispatcher.queryBoolean("GeckoView:HasCookieBannerRuleForBrowsingContextTree");
+ }
+
+ /**
+ * Get the SessionPdfFileSaver instance for this session, to save a pdf document.
+ *
+ * @return SessionPdfFileSaver instance.
+ */
+ @AnyThread
+ public @NonNull SessionPdfFileSaver getPdfFileSaver() {
+ if (mPdfFileSaver == null) {
+ mPdfFileSaver = new SessionPdfFileSaver(this);
+ }
+ return mPdfFileSaver;
+ }
+
+ /** Represent the result of a save-pdf operation. */
+ @AnyThread
+ public static class PdfSaveResult {
+ /** Binary data representing a PDF. */
+ @NonNull public final byte[] bytes;
+
+ /** PDF file name. */
+ @NonNull public final String filename;
+
+ public final boolean isPrivate;
+
+ /* package */ PdfSaveResult(@NonNull final GeckoBundle bundle) {
+ filename = bundle.getString("filename");
+ isPrivate = bundle.getBoolean("isPrivate");
+ bytes = bundle.getByteArray("bytes");
+ }
+
+ /** Empty constructor for tests */
+ protected PdfSaveResult() {
+ filename = "";
+ isPrivate = false;
+ bytes = new byte[0];
+ }
+ }
+
+ /**
+ * Check if the document being viewed is a pdf.
+ *
+ * @return Result of the check operation as a {@link GeckoResult} object.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> isPdfJs() {
+ return mEventDispatcher.queryBoolean("GeckoView:IsPdfJs");
+ }
+
+ /**
+ * Set this GeckoSession as active or inactive, which represents if the session is currently
+ * visible or not. Setting a GeckoSession to inactive will significantly reduce its memory
+ * footprint, but should only be done if the GeckoSession is not currently visible. Note that a
+ * session can be active (i.e. visible) but not focused. When a session is set inactive, it will
+ * flush the session state and trigger a `ProgressDelegate.onSessionStateChange` callback.
+ *
+ * @param active A boolean determining whether the GeckoSession is active.
+ * @see #setFocused
+ */
+ @AnyThread
+ public void setActive(final boolean active) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("active", active);
+ mEventDispatcher.dispatch("GeckoView:SetActive", msg);
+
+ if (!active) {
+ mEventDispatcher.dispatch("GeckoView:FlushSessionState", null);
+ ThreadUtils.postToUiThreadDelayed(mNotifyMemoryPressure, NOTIFY_MEMORY_PRESSURE_DELAY_MS);
+ } else {
+ // Delete any pending memory pressure events since we're active again.
+ ThreadUtils.removeUiThreadCallbacks(mNotifyMemoryPressure);
+ }
+
+ ThreadUtils.runOnUiThread(() -> getAutofillSupport().onActiveChanged(active));
+ }
+
+ /**
+ * Move focus to this session or away from this session. Only one session has focus at a given
+ * time. Note that a session can be unfocused but still active (i.e. visible).
+ *
+ * @param focused True if the session should gain focus or false if the session should lose focus.
+ * @see #setActive
+ */
+ @AnyThread
+ public void setFocused(final boolean focused) {
+ mEventDispatcher.dispatch("GeckoView:DismissClipboardPermissionRequest", null);
+
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("focused", focused);
+ mEventDispatcher.dispatch("GeckoView:SetFocused", msg);
+ }
+
+ /**
+ * Notify GeckoView of the priority for this GeckoSession.
+ *
+ * <p>Set this GeckoSession to high priority (PRIORITY_HIGH) whenever the app wants to signal to
+ * GeckoView that this GeckoSession is important to the app. GeckoView will keep the session state
+ * as long as possible. Set this to default priority (PRIORITY_DEFAULT) in any other case.
+ *
+ * @param priorityHint Priority of the geckosession, either high priority or default.
+ */
+ @AnyThread
+ public void setPriorityHint(final @Priority int priorityHint) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putInt("priorityHint", priorityHint);
+ mEventDispatcher.dispatch("GeckoView:SetPriorityHint", msg);
+ }
+
+ /** Class representing a saved session state. */
+ @AnyThread
+ public static class SessionState extends AbstractSequentialList<HistoryDelegate.HistoryItem>
+ implements HistoryDelegate.HistoryList, Parcelable {
+ private GeckoBundle mState;
+
+ private class SessionStateItem implements HistoryDelegate.HistoryItem {
+ private final GeckoBundle mItem;
+
+ private SessionStateItem(final @NonNull GeckoBundle item) {
+ mItem = item;
+ }
+
+ @Override /* HistoryItem */
+ public String getUri() {
+ return mItem.getString("url");
+ }
+
+ @Override /* HistoryItem */
+ public String getTitle() {
+ return mItem.getString("title");
+ }
+ }
+
+ private class SessionStateIterator implements ListIterator<HistoryDelegate.HistoryItem> {
+ private final SessionState mState;
+ private int mIndex;
+
+ private SessionStateIterator(final @NonNull SessionState state) {
+ this(state, 0);
+ }
+
+ private SessionStateIterator(final @NonNull SessionState state, final int index) {
+ mIndex = index;
+ mState = state;
+ }
+
+ @Override /* ListIterator */
+ public void add(final HistoryDelegate.HistoryItem item) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override /* ListIterator */
+ public boolean hasNext() {
+ final GeckoBundle[] entries = mState.getHistoryEntries();
+
+ if (entries == null) {
+ Log.w(LOGTAG, "No history entries found.");
+ return false;
+ }
+
+ return mIndex < mState.getHistoryEntries().length;
+ }
+
+ @Override /* ListIterator */
+ public boolean hasPrevious() {
+ return mIndex > 0;
+ }
+
+ @Override /* ListIterator */
+ public HistoryDelegate.HistoryItem next() {
+ if (hasNext()) {
+ mIndex++;
+ return new SessionStateItem(mState.getHistoryEntries()[mIndex - 1]);
+ } else {
+ throw new NoSuchElementException();
+ }
+ }
+
+ @Override /* ListIterator */
+ public int nextIndex() {
+ return mIndex;
+ }
+
+ @Override /* ListIterator */
+ public HistoryDelegate.HistoryItem previous() {
+ if (hasPrevious()) {
+ mIndex--;
+ return new SessionStateItem(mState.getHistoryEntries()[mIndex]);
+ } else {
+ throw new NoSuchElementException();
+ }
+ }
+
+ @Override /* ListIterator */
+ public int previousIndex() {
+ return mIndex - 1;
+ }
+
+ @Override /* ListIterator */
+ public void remove() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override /* ListIterator */
+ public void set(final @NonNull HistoryDelegate.HistoryItem item) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private SessionState() {
+ mState = new GeckoBundle(3);
+ }
+
+ private SessionState(final @NonNull GeckoBundle state) {
+ mState = new GeckoBundle(state);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SessionState(final @NonNull SessionState state) {
+ mState = new GeckoBundle(state.mState);
+ }
+
+ /* package */ void updateSessionState(final @NonNull GeckoBundle updateData) {
+ if (updateData == null) {
+ Log.w(LOGTAG, "Session state update has no data field.");
+ return;
+ }
+
+ final GeckoBundle history = updateData.getBundle("historychange");
+ final GeckoBundle scroll = updateData.getBundle("scroll");
+ final GeckoBundle formdata = updateData.getBundle("formdata");
+
+ if (history != null) {
+ mState.putBundle("history", history);
+ }
+
+ if (scroll != null) {
+ mState.putBundle("scrolldata", scroll);
+ }
+
+ if (formdata != null) {
+ mState.putBundle("formdata", formdata);
+ }
+
+ return;
+ }
+
+ @Override
+ public int hashCode() {
+ return mState.hashCode();
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof SessionState)) {
+ return false;
+ }
+
+ final SessionState otherState = (SessionState) other;
+
+ return this.mState.equals(otherState.mState);
+ }
+
+ /**
+ * Creates a new SessionState instance from a value previously returned by {@link #toString()}.
+ *
+ * @param value The serialized SessionState in String form.
+ * @return A new SessionState instance if input is valid; otherwise null.
+ */
+ public static @Nullable SessionState fromString(final @Nullable String value) {
+ final GeckoBundle bundleState;
+ try {
+ bundleState = GeckoBundle.fromJSONObject(new JSONObject(value));
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "String does not represent valid session state.");
+ return null;
+ }
+
+ if (bundleState == null) {
+ return null;
+ }
+
+ return new SessionState(bundleState);
+ }
+
+ @Override
+ public @Nullable String toString() {
+ if (mState == null) {
+ Log.w(LOGTAG, "Can't convert SessionState with null state to string");
+ return null;
+ }
+
+ String res;
+ try {
+ res = mState.toJSONObject().toString();
+ } catch (final JSONException e) {
+ Log.e(LOGTAG, "Could not convert session state to string.");
+ res = null;
+ }
+
+ return res;
+ }
+
+ @Override // Parcelable
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(toString());
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ if (source.readString() == null) {
+ Log.w(LOGTAG, "Can't reproduce session state from Parcel");
+ }
+
+ try {
+ mState = GeckoBundle.fromJSONObject(new JSONObject(source.readString()));
+ } catch (final JSONException e) {
+ Log.e(LOGTAG, "Could not convert string to session state.");
+ mState = null;
+ }
+ }
+
+ public static final Parcelable.Creator<SessionState> CREATOR =
+ new Parcelable.Creator<SessionState>() {
+ @Override
+ public SessionState createFromParcel(final Parcel source) {
+ if (source.readString() == null) {
+ Log.w(LOGTAG, "Can't create session state from Parcel");
+ }
+
+ GeckoBundle res;
+ try {
+ res = GeckoBundle.fromJSONObject(new JSONObject(source.readString()));
+ } catch (final JSONException e) {
+ Log.e(LOGTAG, "Could not convert parcel to session state.");
+ res = null;
+ }
+
+ return new SessionState(res);
+ }
+
+ @Override
+ public SessionState[] newArray(final int size) {
+ return new SessionState[size];
+ }
+ };
+
+ @Override /* AbstractSequentialList */
+ public @NonNull HistoryDelegate.HistoryItem get(final int index) {
+ final GeckoBundle[] entries = getHistoryEntries();
+
+ if (entries == null || index < 0 || index >= entries.length) {
+ throw new NoSuchElementException();
+ }
+
+ return new SessionStateItem(entries[index]);
+ }
+
+ @Override /* AbstractSequentialList */
+ public @NonNull Iterator<HistoryDelegate.HistoryItem> iterator() {
+ return listIterator(0);
+ }
+
+ @Override /* AbstractSequentialList */
+ public @NonNull ListIterator<HistoryDelegate.HistoryItem> listIterator(final int index) {
+ return new SessionStateIterator(this, index);
+ }
+
+ @Override /* AbstractSequentialList */
+ public int size() {
+ final GeckoBundle[] entries = getHistoryEntries();
+
+ if (entries == null) {
+ Log.w(LOGTAG, "No history entries found.");
+ return 0;
+ }
+
+ return entries.length;
+ }
+
+ @Override /* HistoryList */
+ public int getCurrentIndex() {
+ final GeckoBundle history = getHistory();
+
+ if (history == null) {
+ throw new IllegalStateException("No history state exists.");
+ }
+
+ return history.getInt("index") + history.getInt("fromIdx");
+ }
+
+ // Some helpers for common code.
+ private GeckoBundle getHistory() {
+ if (mState == null) {
+ return null;
+ }
+
+ return mState.getBundle("history");
+ }
+
+ private GeckoBundle[] getHistoryEntries() {
+ final GeckoBundle history = getHistory();
+
+ if (history == null) {
+ return null;
+ }
+
+ return history.getBundleArray("entries");
+ }
+ }
+
+ private SessionState mStateCache = new SessionState();
+
+ /**
+ * Restore a saved state to this GeckoSession; only data that is saved (history, scroll position,
+ * zoom, and form data) will be restored. These will overwrite the corresponding state of this
+ * GeckoSession.
+ *
+ * @param state A saved session state; this should originate from onSessionStateChange().
+ */
+ @AnyThread
+ public void restoreState(final @NonNull SessionState state) {
+ mEventDispatcher.dispatch("GeckoView:RestoreState", state.mState);
+ }
+
+ /**
+ * Get whether this GeckoSession has form data.
+ *
+ * @return a {@link GeckoResult} result of if there is existing form data.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> containsFormData() {
+ return mEventDispatcher.queryBoolean("GeckoView:ContainsFormData");
+ }
+
+ /**
+ * Request analysis of product's reviews for a given product URL.
+ *
+ * @param url The URL of the product page.
+ * @return a {@link GeckoResult} result of review analysis object.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<ReviewAnalysis> requestAnalysis(@NonNull final String url) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("url", url);
+ return mEventDispatcher
+ .queryBundle("GeckoView:RequestAnalysis", bundle)
+ .map(analysisBundle -> new ReviewAnalysis(analysisBundle.getBundle("analysis")));
+ }
+
+ /**
+ * Request the creation of an analysis of product's reviews for a given product URL.
+ *
+ * @param url The URL of the product page.
+ * @return a {@link GeckoResult} result of status of analysis.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<String> requestCreateAnalysis(@NonNull final String url) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("url", url);
+ return mEventDispatcher.queryString("GeckoView:RequestCreateAnalysis", bundle);
+ }
+
+ /**
+ * Request the status of the current analysis of product's reviews for a given product URL.
+ *
+ * @param url The URL of the product page.
+ * @return a {@link GeckoResult} result of status of analysis.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<AnalysisStatusResponse> requestAnalysisStatus(
+ @NonNull final String url) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("url", url);
+ return mEventDispatcher
+ .queryBundle("GeckoView:RequestAnalysisStatus", bundle)
+ .map(statusBundle -> new AnalysisStatusResponse(statusBundle.getBundle("status")));
+ }
+
+ /**
+ * Poll for the status of the current analysis of product's reviews for a given product URL.
+ *
+ * @param url The URL of the product page.
+ * @return a {@link GeckoResult} result of status of analysis.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<String> pollForAnalysisCompleted(@NonNull final String url) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("url", url);
+ return mEventDispatcher.queryString("GeckoView:PollForAnalysisCompleted", bundle);
+ }
+
+ /**
+ * Send a click event to the Ad Attribution API.
+ *
+ * @param aid Ad id of the recommended product.
+ * @return a {@link GeckoResult} result of whether or not sending the event was successful.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> sendClickAttributionEvent(@NonNull final String aid) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("aid", aid);
+ return mEventDispatcher.queryBoolean("GeckoView:SendClickAttributionEvent", bundle);
+ }
+
+ /**
+ * Send an impression event to the Ad Attribution API.
+ *
+ * @param aid Ad id of the recommended product.
+ * @return a {@link GeckoResult} result of whether or not sending the event was successful.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> sendImpressionAttributionEvent(@NonNull final String aid) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("aid", aid);
+ return mEventDispatcher.queryBoolean("GeckoView:SendImpressionAttributionEvent", bundle);
+ }
+
+ /**
+ * Send a placement event to the Ad Attribution API.
+ *
+ * @param aid Ad id of the recommended product.
+ * @return a {@link GeckoResult} result of whether or not sending the event was successful.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> sendPlacementAttributionEvent(@NonNull final String aid) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("aid", aid);
+ return mEventDispatcher.queryBoolean("GeckoView:SendPlacementAttributionEvent", bundle);
+ }
+
+ /**
+ * Request product recommendations given a specific product url.
+ *
+ * @param url The URL of the product page.
+ * @return a {@link GeckoResult} result of product recommendations.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<Recommendation>> requestRecommendations(
+ @NonNull final String url) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("url", url);
+ return mEventDispatcher
+ .queryBundle("GeckoView:RequestRecommendations", bundle)
+ .map(
+ recommendationsBundle -> {
+ final GeckoBundle[] bundles = recommendationsBundle.getBundleArray("recommendations");
+ final ArrayList<Recommendation> recArray = new ArrayList<>(bundles.length);
+ if (recArray != null) {
+ for (final GeckoBundle b : bundles) {
+ recArray.add(new Recommendation(b));
+ }
+ }
+ return recArray;
+ });
+ }
+
+ /**
+ * Report that a product is back in stock.
+ *
+ * @param url The URL of the product page.
+ * @return a {@link GeckoResult} result of whether reporting a product is back in stock was
+ * successful.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<String> reportBackInStock(@NonNull final String url) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("url", url);
+ return mEventDispatcher.queryString("GeckoView:ReportBackInStock", bundle);
+ }
+
+ // This is the GeckoDisplay acquired via acquireDisplay(), if any.
+ private GeckoDisplay mDisplay;
+
+ /* package */ interface Owner {
+ void onRelease();
+ }
+
+ private static final WeakReference<Owner> NO_OWNER = new WeakReference<>(null);
+ private WeakReference<Owner> mOwner = NO_OWNER;
+
+ @UiThread
+ /* package */ void releaseOwner() {
+ ThreadUtils.assertOnUiThread();
+ mOwner = NO_OWNER;
+ }
+
+ @UiThread
+ /* package */ void setOwner(final Owner owner) {
+ ThreadUtils.assertOnUiThread();
+ final Owner oldOwner = mOwner.get();
+ if (oldOwner != null && owner != oldOwner) {
+ oldOwner.onRelease();
+ }
+ mOwner = new WeakReference<>(owner);
+ }
+
+ /* package */ GeckoDisplay getDisplay() {
+ return mDisplay;
+ }
+
+ /**
+ * Acquire the GeckoDisplay instance for providing the session with a drawing Surface. Be sure to
+ * call {@link GeckoDisplay#surfaceChanged(SurfaceInfo)} on the acquired display if there is
+ * already a valid Surface.
+ *
+ * @return GeckoDisplay instance.
+ * @see #releaseDisplay(GeckoDisplay)
+ */
+ @UiThread
+ public @NonNull GeckoDisplay acquireDisplay() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDisplay != null) {
+ throw new IllegalStateException("Display already acquired");
+ }
+
+ mDisplay = new GeckoDisplay(this);
+ return mDisplay;
+ }
+
+ /**
+ * Release an acquired GeckoDisplay instance. Be sure to call {@link
+ * GeckoDisplay#surfaceDestroyed()} before releasing the display if it still has a valid Surface.
+ *
+ * @param display Acquired GeckoDisplay instance.
+ * @see #acquireDisplay()
+ */
+ @UiThread
+ public void releaseDisplay(final @NonNull GeckoDisplay display) {
+ ThreadUtils.assertOnUiThread();
+
+ if (display != mDisplay) {
+ throw new IllegalArgumentException("Display not attached");
+ }
+
+ mDisplay = null;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull GeckoSessionSettings getSettings() {
+ return mSettings;
+ }
+
+ /** Exits fullscreen mode */
+ @AnyThread
+ public void exitFullScreen() {
+ mEventDispatcher.dispatch("GeckoViewContent:ExitFullScreen", null);
+ }
+
+ /**
+ * Set the content callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ContentDelegate.
+ */
+ @UiThread
+ public void setContentDelegate(final @Nullable ContentDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mContentHandler.setDelegate(delegate, this);
+ mProcessHangHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the content callback handler.
+ *
+ * @return The current content callback handler.
+ */
+ @UiThread
+ public @Nullable ContentDelegate getContentDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mContentHandler.getDelegate();
+ }
+
+ /**
+ * Set the progress callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ProgressDelegate.
+ */
+ @UiThread
+ public void setProgressDelegate(final @Nullable ProgressDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mProgressHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the progress callback handler.
+ *
+ * @return The current progress callback handler.
+ */
+ @UiThread
+ public @Nullable ProgressDelegate getProgressDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mProgressHandler.getDelegate();
+ }
+
+ /**
+ * Set the navigation callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of NavigationDelegate.
+ */
+ @UiThread
+ public void setNavigationDelegate(final @Nullable NavigationDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mNavigationHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the navigation callback handler.
+ *
+ * @return The current navigation callback handler.
+ */
+ @UiThread
+ public @Nullable NavigationDelegate getNavigationDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mNavigationHandler.getDelegate();
+ }
+
+ /**
+ * Set the content scroll callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ScrollDelegate.
+ */
+ @UiThread
+ public void setScrollDelegate(final @Nullable ScrollDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mScrollHandler.setDelegate(delegate, this);
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable ScrollDelegate getScrollDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mScrollHandler.getDelegate();
+ }
+
+ /**
+ * Set the history tracking delegate for this session, replacing the current delegate if one is
+ * set.
+ *
+ * @param delegate The history tracking delegate, or {@code null} to unset.
+ */
+ @AnyThread
+ public void setHistoryDelegate(final @Nullable HistoryDelegate delegate) {
+ mHistoryHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * @return The history tracking delegate for this session.
+ */
+ @AnyThread
+ public @Nullable HistoryDelegate getHistoryDelegate() {
+ return mHistoryHandler.getDelegate();
+ }
+
+ /**
+ * Set the content blocking callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of {@link ContentBlocking.Delegate}.
+ */
+ @AnyThread
+ public void setContentBlockingDelegate(final @Nullable ContentBlocking.Delegate delegate) {
+ mContentBlockingHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the content blocking callback handler.
+ *
+ * @return The current content blocking callback handler.
+ */
+ @AnyThread
+ public @Nullable ContentBlocking.Delegate getContentBlockingDelegate() {
+ return mContentBlockingHandler.getDelegate();
+ }
+
+ /**
+ * Set the current prompt delegate for this GeckoSession.
+ *
+ * @param delegate PromptDelegate instance or null to use the built-in delegate.
+ */
+ @AnyThread
+ public void setPromptDelegate(final @Nullable PromptDelegate delegate) {
+ mPromptDelegate = delegate;
+ }
+
+ /**
+ * Get the current prompt delegate for this GeckoSession.
+ *
+ * @return PromptDelegate instance or null if using built-in delegate.
+ */
+ @AnyThread
+ public @Nullable PromptDelegate getPromptDelegate() {
+ return mPromptDelegate;
+ }
+
+ /**
+ * Set the current selection action delegate for this GeckoSession.
+ *
+ * @param delegate SelectionActionDelegate instance or null to unset.
+ */
+ @UiThread
+ public void setSelectionActionDelegate(final @Nullable SelectionActionDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+
+ if (getSelectionActionDelegate() != null) {
+ // When the delegate is changed or cleared, make sure onHideAction is called
+ // one last time to hide any existing selection action UI. Gecko doesn't keep
+ // track of the old delegate, so we can't rely on Gecko to do that for us.
+ getSelectionActionDelegate()
+ .onHideAction(this, GeckoSession.SelectionActionDelegate.HIDE_REASON_NO_SELECTION);
+ }
+ mSelectionActionDelegate.setDelegate(delegate, this);
+ }
+
+ /**
+ * Set the media callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of MediaDelegate.
+ */
+ @AnyThread
+ public void setMediaDelegate(final @Nullable MediaDelegate delegate) {
+ mMediaHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the Media callback handler.
+ *
+ * @return The current Media callback handler.
+ */
+ @AnyThread
+ public @Nullable MediaDelegate getMediaDelegate() {
+ return mMediaHandler.getDelegate();
+ }
+
+ /**
+ * Set the media session delegate. This will replace the current handler.
+ *
+ * @param delegate An implementation of {@link MediaSession.Delegate}.
+ */
+ @AnyThread
+ public void setMediaSessionDelegate(final @Nullable MediaSession.Delegate delegate) {
+ mMediaSessionHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the media session delegate.
+ *
+ * @return The current media session delegate.
+ */
+ @AnyThread
+ public @Nullable MediaSession.Delegate getMediaSessionDelegate() {
+ return mMediaSessionHandler.getDelegate();
+ }
+
+ /**
+ * The session translation object coordinates receiving and sending session messages with the
+ * translations toolkit. Notably, it can be used to request translations.
+ *
+ * @return The current translation session coordinator.
+ */
+ @AnyThread
+ public @Nullable TranslationsController.SessionTranslation getSessionTranslation() {
+ return mTranslations;
+ }
+
+ /**
+ * Set the translation delegate, which receives translations events.
+ *
+ * @param delegate An implementation of @link{TranslationsController.SessionTranslation.Delegate}.
+ */
+ @AnyThread
+ public void setTranslationsSessionDelegate(
+ final @Nullable TranslationsController.SessionTranslation.Delegate delegate) {
+ mTranslationsHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the translations delegate. The application embedder must initially set the translations
+ * delegate for use.
+ *
+ * @return The current translations delegate.
+ */
+ @AnyThread
+ public @Nullable TranslationsController.SessionTranslation.Delegate
+ getTranslationsSessionDelegate() {
+ return mTranslationsHandler.getDelegate();
+ }
+
+ /**
+ * Get the current selection action delegate for this GeckoSession.
+ *
+ * @return SelectionActionDelegate instance or null if not set.
+ */
+ @AnyThread
+ public @Nullable SelectionActionDelegate getSelectionActionDelegate() {
+ return mSelectionActionDelegate.getDelegate();
+ }
+
+ @UiThread
+ protected void setShouldPinOnScreen(final boolean pinned) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mShouldPinOnScreen = pinned;
+ }
+
+ /* package */ boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+ return mShouldPinOnScreen;
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ EventDispatcher getEventDispatcher() {
+ return mEventDispatcher;
+ }
+
+ public interface ProgressDelegate {
+ /** Class representing security information for a site. */
+ class SecurityInformation {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({SECURITY_MODE_UNKNOWN, SECURITY_MODE_IDENTIFIED, SECURITY_MODE_VERIFIED})
+ public @interface SecurityMode {}
+
+ public static final int SECURITY_MODE_UNKNOWN = 0;
+ public static final int SECURITY_MODE_IDENTIFIED = 1;
+ public static final int SECURITY_MODE_VERIFIED = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({CONTENT_UNKNOWN, CONTENT_BLOCKED, CONTENT_LOADED})
+ public @interface ContentType {}
+
+ public static final int CONTENT_UNKNOWN = 0;
+ public static final int CONTENT_BLOCKED = 1;
+ public static final int CONTENT_LOADED = 2;
+
+ /** Indicates whether or not the site is secure. */
+ public final boolean isSecure;
+
+ /** Indicates whether or not the site is a security exception. */
+ public final boolean isException;
+
+ /** Contains the origin of the certificate. */
+ public final @Nullable String origin;
+
+ /** Contains the host associated with the certificate. */
+ public final @NonNull String host;
+
+ /** The server certificate in use, if any. */
+ public final @Nullable X509Certificate certificate;
+
+ /**
+ * Indicates the security level of the site; possible values are SECURITY_MODE_UNKNOWN,
+ * SECURITY_MODE_IDENTIFIED, and SECURITY_MODE_VERIFIED. SECURITY_MODE_IDENTIFIED indicates
+ * domain validation only, while SECURITY_MODE_VERIFIED indicates extended validation.
+ */
+ public final @SecurityMode int securityMode;
+
+ /**
+ * Indicates the presence of passive mixed content; possible values are CONTENT_UNKNOWN,
+ * CONTENT_BLOCKED, and CONTENT_LOADED.
+ */
+ public final @ContentType int mixedModePassive;
+
+ /**
+ * Indicates the presence of active mixed content; possible values are CONTENT_UNKNOWN,
+ * CONTENT_BLOCKED, and CONTENT_LOADED.
+ */
+ public final @ContentType int mixedModeActive;
+
+ /* package */ SecurityInformation(final GeckoBundle identityData) {
+ final GeckoBundle mode = identityData.getBundle("mode");
+
+ mixedModePassive = mode.getInt("mixed_display");
+ mixedModeActive = mode.getInt("mixed_active");
+
+ securityMode = mode.getInt("identity");
+
+ isSecure = identityData.getBoolean("secure");
+ isException = identityData.getBoolean("securityException");
+ origin = identityData.getString("origin");
+ host = identityData.getString("host");
+
+ X509Certificate decodedCert = null;
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ final String certString = identityData.getString("certificate");
+ if (certString != null) {
+ final byte[] certBytes = Base64.decode(certString, Base64.NO_WRAP);
+ decodedCert =
+ (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certBytes));
+ }
+ } catch (final CertificateException e) {
+ Log.e(LOGTAG, "Failed to decode certificate", e);
+ }
+
+ certificate = decodedCert;
+ }
+
+ /** Empty constructor for tests */
+ protected SecurityInformation() {
+ mixedModePassive = CONTENT_UNKNOWN;
+ mixedModeActive = CONTENT_UNKNOWN;
+ securityMode = SECURITY_MODE_UNKNOWN;
+ isSecure = false;
+ isException = false;
+ origin = "";
+ host = "";
+ certificate = null;
+ }
+ }
+
+ /**
+ * A View has started loading content from the network.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param url The resource being loaded.
+ */
+ @UiThread
+ default void onPageStart(@NonNull final GeckoSession session, @NonNull final String url) {}
+
+ /**
+ * A View has finished loading content from the network.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param success Whether the page loaded successfully or an error occurred.
+ */
+ @UiThread
+ default void onPageStop(@NonNull final GeckoSession session, final boolean success) {}
+
+ /**
+ * Page loading has progressed.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param progress Current page load progress value [0, 100].
+ */
+ @UiThread
+ default void onProgressChange(@NonNull final GeckoSession session, final int progress) {}
+
+ /**
+ * The security status has been updated.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param securityInfo The new security information.
+ */
+ @UiThread
+ default void onSecurityChange(
+ @NonNull final GeckoSession session, @NonNull final SecurityInformation securityInfo) {}
+
+ /**
+ * The browser session state has changed. This can happen in response to navigation, scrolling,
+ * or form data changes; the session state passed includes the most up to date information on
+ * all of these.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param sessionState SessionState representing the latest browser state.
+ */
+ @UiThread
+ default void onSessionStateChange(
+ @NonNull final GeckoSession session, @NonNull final SessionState sessionState) {}
+ }
+
+ /** WebResponseInfo contains information about a single web response. */
+ @AnyThread
+ public static class WebResponseInfo {
+ /** The URI of the response. Cannot be null. */
+ @NonNull public final String uri;
+
+ /** The content type (mime type) of the response. May be null. */
+ @Nullable public final String contentType;
+
+ /** The content length of the response. May be 0 if unknokwn. */
+ @Nullable public final long contentLength;
+
+ /** The filename obtained from the content disposition, if any. May be null. */
+ @Nullable public final String filename;
+
+ /* package */ WebResponseInfo(final GeckoBundle message) {
+ uri = message.getString("uri");
+ if (uri == null) {
+ throw new IllegalArgumentException("URI cannot be null");
+ }
+
+ contentType = message.getString("contentType");
+ contentLength = message.getLong("contentLength");
+ filename = message.getString("filename");
+ }
+
+ /** Empty constructor for tests. */
+ protected WebResponseInfo() {
+ uri = "";
+ contentType = "";
+ contentLength = 0;
+ filename = "";
+ }
+ }
+
+ /** Contains information about the analysis of a product's reviews. */
+ @AnyThread
+ public static class ReviewAnalysis {
+ /** Analysis URL. */
+ @Nullable public final String analysisURL;
+
+ /** Product identifier (ASIN/SKU). */
+ @Nullable public final String productId;
+
+ /** Reliability grade for the product's reviews. */
+ @Nullable public final String grade;
+
+ /** Product rating adjusted to exclude untrusted reviews. */
+ @Nullable public final Double adjustedRating;
+
+ /** Boolean indicating if the analysis is stale. */
+ public final boolean needsAnalysis;
+
+ /** Boolean indicating if the page is not supported. */
+ public final boolean pageNotSupported;
+
+ /** Boolean indicating if there are not enough reviews. */
+ public final boolean notEnoughReviews;
+
+ /** Object containing highlights for product. */
+ @Nullable public final Highlight highlights;
+
+ /** Time since the last analysis was performed. */
+ public final long lastAnalysisTime;
+
+ /** Boolean indicating if reported that this product has been deleted. */
+ public final boolean deletedProductReported;
+
+ /** Boolean indicating if this product is now deleted. */
+ public final boolean deletedProduct;
+
+ /* package */ ReviewAnalysis(final GeckoBundle message) {
+ analysisURL = message.getString("analysis_url");
+ productId = message.getString("product_id");
+ grade = message.getString("grade");
+ adjustedRating = message.getDoubleObject("adjusted_rating");
+ needsAnalysis = message.getBoolean("needs_analysis");
+ pageNotSupported = message.getBoolean("page_not_supported");
+ notEnoughReviews = message.getBoolean("not_enough_reviews");
+ if (message.getBundle("highlights") == null) {
+ highlights = null;
+ } else {
+ highlights = new Highlight(message.getBundle("highlights"));
+ }
+ lastAnalysisTime = message.getLong("last_analysis_time");
+ deletedProductReported = message.getBoolean("deleted_product_reported");
+ deletedProduct = message.getBoolean("deleted_product");
+ }
+
+ /**
+ * Initialize a ReviewAnalysis object with a builder object
+ *
+ * @param builder A ReviewAnalysis.Builder instance
+ */
+ protected ReviewAnalysis(final @NonNull Builder builder) {
+ analysisURL = builder.mAnalysisUrl;
+ productId = builder.mProductId;
+ grade = builder.mGrade;
+ adjustedRating = builder.mAdjustedRating;
+ needsAnalysis = builder.mNeedsAnalysis;
+ pageNotSupported = builder.mPageNotSupported;
+ notEnoughReviews = builder.mNotEnoughReviews;
+ highlights = builder.mHighlights;
+ lastAnalysisTime = builder.mLastAnalysisTime;
+ deletedProduct = builder.mDeletedProduct;
+ deletedProductReported = builder.mDeletedProductReported;
+ }
+
+ /** This is a Builder used by ReviewAnalysis class */
+ public static class Builder {
+ /* package */ String mAnalysisUrl = "";
+ /* package */ String mProductId = "";
+ /* package */ String mGrade = null;
+ /* package */ Double mAdjustedRating = 0.0;
+ /* package */ Boolean mNeedsAnalysis = false;
+ /* package */ Boolean mPageNotSupported = false;
+ /* package */ Boolean mNotEnoughReviews = false;
+ /* package */ Highlight mHighlights = new Highlight();
+ /* package */ long mLastAnalysisTime = 0;
+ /* package */ Boolean mDeletedProductReported = false;
+ /* package */ Boolean mDeletedProduct = false;
+
+ /**
+ * Construct a Builder instance with the specified product ID.
+ *
+ * @param productId A String with the product ID.
+ */
+ public Builder(final @Nullable String productId) {
+ productId(productId);
+ }
+
+ /**
+ * Set the analysis URL
+ *
+ * @param analysisUrl A URI String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder analysisUrl(final @Nullable String analysisUrl) {
+ mAnalysisUrl = analysisUrl;
+ return this;
+ }
+
+ /**
+ * Set the product identifier
+ *
+ * @param productId A product ID String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder productId(final @Nullable String productId) {
+ mProductId = productId;
+ return this;
+ }
+
+ /**
+ * Set the grade of the product
+ *
+ * @param grade A grade String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder grade(final @Nullable String grade) {
+ mGrade = grade;
+ return this;
+ }
+
+ /**
+ * Set the adjusted rating
+ *
+ * @param adjustedRating the adjusted rating of the product
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder adjustedRating(final @NonNull Double adjustedRating) {
+ mAdjustedRating = adjustedRating;
+ return this;
+ }
+
+ /**
+ * Set the flag that indicates whether this product needs analysis
+ *
+ * @param needsAnalysis indicates whether this product needs analysis
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder needsAnalysis(final @NonNull Boolean needsAnalysis) {
+ mNeedsAnalysis = needsAnalysis;
+ return this;
+ }
+
+ /**
+ * Set the flag that indicates whether this product page is supported
+ *
+ * @param pageNotSupported indicates whether this product page is supported
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder pageNotSupported(
+ final @NonNull Boolean pageNotSupported) {
+ mPageNotSupported = pageNotSupported;
+ return this;
+ }
+
+ /**
+ * Set the flag that indicates whether there are not enough reviews
+ *
+ * @param notEnoughReviews indicates whether there are not enough reviews
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder notEnoughReviews(
+ final @NonNull Boolean notEnoughReviews) {
+ mNotEnoughReviews = notEnoughReviews;
+ return this;
+ }
+
+ /**
+ * Set an empty highlights object for the product
+ *
+ * @param highlight A Highlight object (can be null) to overwrite the default empty Highlight
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder highlights(final @Nullable Highlight highlight) {
+ mHighlights = highlight;
+ return this;
+ }
+
+ /**
+ * Set the time of the analysis
+ *
+ * @param lastAnalysisTime The time of the analysis
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder lastAnalysisTime(final long lastAnalysisTime) {
+ mLastAnalysisTime = lastAnalysisTime;
+ return this;
+ }
+
+ /**
+ * Set the flag that indicates whether this deleted product was reported
+ *
+ * @param deletedProductReported Boolean to indicate whether this deleted product was reported
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder deletedProductReported(
+ final @NonNull Boolean deletedProductReported) {
+ mDeletedProductReported = deletedProductReported;
+ return this;
+ }
+
+ /**
+ * Set the flag that indicates whether the product is deleted
+ *
+ * @param deletedProduct Boolean to indicate whether the product is deleted
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis.Builder deletedProduct(final @NonNull Boolean deletedProduct) {
+ mDeletedProduct = deletedProduct;
+ return this;
+ }
+
+ /**
+ * @return A {@link ReviewAnalysis} constructed with the values from this Builder instance.
+ */
+ @AnyThread
+ public @NonNull ReviewAnalysis build() {
+ return new ReviewAnalysis(this);
+ }
+ }
+
+ /** Contains information about highlights of a product's reviews. */
+ public static class Highlight {
+ /** Highlights about the quality of a product. */
+ @Nullable public final String[] quality;
+
+ /** Highlights about the price of a product. */
+ @Nullable public final String[] price;
+
+ /** Highlights about the shipping of a product. */
+ @Nullable public final String[] shipping;
+
+ /** Highlights about the appearance of a product. */
+ @Nullable public final String[] appearance;
+
+ /** Highlights about the competitiveness of a product. */
+ @Nullable public final String[] competitiveness;
+
+ /* package */ Highlight(final GeckoBundle message) {
+ quality = message.getStringArray("quality");
+ price = message.getStringArray("price");
+ shipping = message.getStringArray("shipping");
+ appearance = message.getStringArray("packaging/appearance");
+ competitiveness = message.getStringArray("competitiveness");
+ }
+
+ /** Empty constructor for tests. */
+ protected Highlight() {
+ quality = null;
+ price = null;
+ shipping = null;
+ appearance = null;
+ competitiveness = null;
+ }
+ }
+ }
+
+ /** Contains information about a product recommendation. */
+ @AnyThread
+ public static class Recommendation {
+ /** Analysis URL. */
+ @NonNull public final String analysisUrl;
+
+ /** Adjusted rating. */
+ @NonNull public final Double adjustedRating;
+
+ /** Whether or not it is a sponsored recommendation. */
+ @NonNull public final Boolean sponsored;
+
+ /** Url of product recommendation image. */
+ @NonNull public final String imageUrl;
+
+ /** Unique identifier for the ad entity. */
+ @NonNull public final String aid;
+
+ /** Url of recommended product. */
+ @NonNull public final String url;
+
+ /** Name of recommended product. */
+ @NonNull public final String name;
+
+ /** Grade of recommended product. */
+ @NonNull public final String grade;
+
+ /** Price of recommended product. */
+ @NonNull public final String price;
+
+ /** Currency of recommended product. */
+ @NonNull public final String currency;
+
+ /* package */ Recommendation(@NonNull final GeckoBundle message) {
+ analysisUrl = message.getString("analysis_url");
+ adjustedRating = message.getDouble("adjusted_rating");
+ sponsored = message.getBoolean("sponsored");
+ imageUrl = message.getString("image_url");
+ aid = message.getString("aid");
+ url = message.getString("url");
+ name = message.getString("name");
+ grade = message.getString("grade");
+ price = message.getString("price");
+ currency = message.getString("currency");
+ }
+
+ /**
+ * Initialize Recommendation with a builder object
+ *
+ * @param builder A Recommendation.Builder instance
+ */
+ protected Recommendation(final @NonNull Builder builder) {
+ url = builder.mUrl;
+ analysisUrl = builder.mAnalysisUrl;
+ adjustedRating = builder.mAdjustedRating;
+ sponsored = builder.mSponsored;
+ imageUrl = builder.mImageUrl;
+ aid = builder.mAid;
+ name = builder.mName;
+ grade = builder.mGrade;
+ price = builder.mPrice;
+ currency = builder.mCurrency;
+ }
+
+ /** This is a Builder used by Recommendation class */
+ public static class Builder {
+ /* package */ String mAnalysisUrl = "";
+ /* package */ Double mAdjustedRating = 0.0;
+ /* package */ Boolean mSponsored = false;
+ /* package */ String mImageUrl = "";
+ /* package */ String mAid = "";
+ /* package */ String mUrl = "";
+ /* package */ String mName = "";
+ /* package */ String mGrade = "";
+ /* package */ String mPrice = "";
+ /* package */ String mCurrency = "";
+
+ /**
+ * Construct a Builder instance with the specified recommendation URL.
+ *
+ * @param recommendationUrl A URI String.
+ */
+ public Builder(final @NonNull String recommendationUrl) {
+ url(recommendationUrl);
+ }
+
+ /**
+ * Set the analysis URL
+ *
+ * @param analysisUrl A URI String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder analysisUrl(final @NonNull String analysisUrl) {
+ mAnalysisUrl = analysisUrl;
+ return this;
+ }
+
+ /**
+ * Set the adjusted rating
+ *
+ * @param adjustedRating the adjusted rating of the product
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder adjustedRating(final @NonNull Double adjustedRating) {
+ mAdjustedRating = adjustedRating;
+ return this;
+ }
+
+ /**
+ * Set the flag that indicates whether this recommendation is sponsored or not
+ *
+ * @param sponsored indicates whether this recommendation is sponsored
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder sponsored(final @NonNull Boolean sponsored) {
+ mSponsored = sponsored;
+ return this;
+ }
+
+ /**
+ * Set the image URL
+ *
+ * @param imageUrl An image URL String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder imageUrl(final @NonNull String imageUrl) {
+ mImageUrl = imageUrl;
+ return this;
+ }
+
+ /**
+ * Set the ad identifier
+ *
+ * @param aid The id String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder aid(final @NonNull String aid) {
+ mAid = aid;
+ return this;
+ }
+
+ /**
+ * Set the recommendation URL
+ *
+ * @param url A URI String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder url(final @NonNull String url) {
+ mUrl = url;
+ return this;
+ }
+
+ /**
+ * Set the name of the recommended product
+ *
+ * @param name A name String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder name(final @NonNull String name) {
+ mName = name;
+ return this;
+ }
+
+ /**
+ * Set the grade of the recommended product
+ *
+ * @param grade A grade String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder grade(final @NonNull String grade) {
+ mGrade = grade;
+ return this;
+ }
+
+ /**
+ * Set the price of the recommended product
+ *
+ * @param price A price String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder price(final @NonNull String price) {
+ mPrice = price;
+ return this;
+ }
+
+ /**
+ * Set the currency of the price of the recommended product
+ *
+ * @param currency A currency String
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation.Builder currency(final @NonNull String currency) {
+ mCurrency = currency;
+ return this;
+ }
+
+ /**
+ * @return A {@link Recommendation} constructed with the values from this Builder instance.
+ */
+ @AnyThread
+ public @NonNull Recommendation build() {
+ return new Recommendation(this);
+ }
+ }
+ }
+
+ /** Contains information about a product's analysis status response. */
+ @AnyThread
+ public static class AnalysisStatusResponse {
+ /** Status of the analysis. */
+ @NonNull public final String status;
+
+ /** Indicates the progress of the analysis. */
+ @NonNull public final Double progress;
+
+ /* package */ AnalysisStatusResponse(@NonNull final GeckoBundle message) {
+ status = message.getString("status");
+ progress = message.getDoubleObject("progress", 0.0);
+ }
+
+ /**
+ * Initialize AnalysisStatusResponse with a builder object
+ *
+ * @param builder A AnalysisStatusResponse.Builder instance
+ */
+ protected AnalysisStatusResponse(final @NonNull Builder builder) {
+ status = builder.mStatus;
+ progress = builder.mProgress;
+ }
+
+ /** This is a Builder used by AnalysisStatusResponse class */
+ public static class Builder {
+ /* package */ String mStatus = "";
+ /* package */ Double mProgress = 0.0;
+
+ /**
+ * Construct a Builder instance with the specified AnalysisStatusResponse status.
+ *
+ * @param status A status String.
+ */
+ public Builder(final @NonNull String status) {
+ status(status);
+ }
+
+ /**
+ * Set the status.
+ *
+ * @param status A status String.
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull AnalysisStatusResponse.Builder status(final @NonNull String status) {
+ mStatus = status;
+ return this;
+ }
+
+ /**
+ * Set the progress.
+ *
+ * @param progress Indicates the progress of the analysis.
+ * @return This Builder instance.
+ */
+ @AnyThread
+ public @NonNull AnalysisStatusResponse.Builder progress(final @NonNull Double progress) {
+ mProgress = progress;
+ return this;
+ }
+
+ /**
+ * @return A {@link AnalysisStatusResponse} constructed with the values from this Builder
+ * instance.
+ */
+ @AnyThread
+ public @NonNull AnalysisStatusResponse build() {
+ return new AnalysisStatusResponse(this);
+ }
+ }
+ }
+
+ public interface ContentDelegate {
+ /**
+ * A page title was discovered in the content or updated after the content loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param title The title sent from the content.
+ */
+ @UiThread
+ default void onTitleChange(@NonNull final GeckoSession session, @Nullable final String title) {}
+
+ /**
+ * A preview image was discovered in the content after the content loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param previewImageUrl The preview image URL sent from the content.
+ */
+ @UiThread
+ default void onPreviewImage(
+ @NonNull final GeckoSession session, @NonNull final String previewImageUrl) {}
+
+ /**
+ * A page has requested focus. Note that window.focus() in content will not result in this being
+ * called.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onFocusRequest(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has requested to close
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onCloseRequest(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has entered or exited full screen mode. Typically, the implementation would set the
+ * Activity containing the GeckoSession to full screen when the page is in full screen mode.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param fullScreen True if the page is in full screen mode.
+ */
+ @UiThread
+ default void onFullScreen(@NonNull final GeckoSession session, final boolean fullScreen) {}
+
+ /**
+ * A viewport-fit was discovered in the content or updated after the content.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param viewportFit The value of viewport-fit of meta element in content.
+ * @see <a href="https://drafts.csswg.org/css-round-display/#viewport-fit-descriptor">4.1. The
+ * viewport-fit descriptor</a>
+ */
+ @UiThread
+ default void onMetaViewportFitChange(
+ @NonNull final GeckoSession session, @NonNull final String viewportFit) {}
+
+ /**
+ * Session is on a product url.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onProductUrl(@NonNull final GeckoSession session) {}
+
+ /** Element details for onContextMenu callbacks. */
+ class ContextElement {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_NONE, TYPE_IMAGE, TYPE_VIDEO, TYPE_AUDIO})
+ public @interface Type {}
+
+ public static final int TYPE_NONE = 0;
+ public static final int TYPE_IMAGE = 1;
+ public static final int TYPE_VIDEO = 2;
+ public static final int TYPE_AUDIO = 3;
+
+ /** The base URI of the element's document. */
+ public final @Nullable String baseUri;
+
+ /** The absolute link URI (href) of the element. */
+ public final @Nullable String linkUri;
+
+ /** The title text of the element. */
+ public final @Nullable String title;
+
+ /** The alternative text (alt) for the element. */
+ public final @Nullable String altText;
+
+ /** The type of the element. One of the {@link ContextElement#TYPE_NONE} flags. */
+ public final @Type int type;
+
+ /** The source URI (src) of the element. Set for (nested) media elements. */
+ public final @Nullable String srcUri;
+
+ /** The text content of the element */
+ public final @Nullable String textContent;
+
+ // TODO: Bug 1595822 make public
+ final List<WebExtension.Menu> extensionMenus;
+
+ /**
+ * ContextElement constructor.
+ *
+ * @param baseUri The base URI.
+ * @param linkUri The absolute link URI (href).
+ * @param title The title text.
+ * @param altText The alternative text (alt).
+ * @param typeStr The type of the element.
+ * @param srcUri The source URI (src).
+ * @param textContent The text content.
+ */
+ protected ContextElement(
+ final @Nullable String baseUri,
+ final @Nullable String linkUri,
+ final @Nullable String title,
+ final @Nullable String altText,
+ final @NonNull String typeStr,
+ final @Nullable String srcUri,
+ final @Nullable String textContent) {
+ this.baseUri = baseUri;
+ this.linkUri = linkUri;
+ this.title = title;
+ this.altText = altText;
+ this.type = getType(typeStr);
+ this.srcUri = srcUri;
+ this.textContent = textContent;
+ this.extensionMenus = null;
+ }
+
+ protected ContextElement(
+ final @Nullable String baseUri,
+ final @Nullable String linkUri,
+ final @Nullable String title,
+ final @Nullable String altText,
+ final @NonNull String typeStr,
+ final @Nullable String srcUri) {
+ this(baseUri, linkUri, title, altText, typeStr, srcUri, null);
+ }
+
+ private static int getType(final String name) {
+ if ("HTMLImageElement".equals(name)) {
+ return TYPE_IMAGE;
+ } else if ("HTMLVideoElement".equals(name)) {
+ return TYPE_VIDEO;
+ } else if ("HTMLAudioElement".equals(name)) {
+ return TYPE_AUDIO;
+ }
+ return TYPE_NONE;
+ }
+ }
+
+ /**
+ * A user has initiated the context menu via long-press. This event is fired on links, (nested)
+ * images and (nested) media elements.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param screenX The screen coordinates of the press.
+ * @param screenY The screen coordinates of the press.
+ * @param element The details for the pressed element.
+ */
+ @UiThread
+ default void onContextMenu(
+ @NonNull final GeckoSession session,
+ final int screenX,
+ final int screenY,
+ @NonNull final ContextElement element) {}
+
+ /**
+ * This is fired when there is a response that cannot be handled by Gecko (e.g., a download).
+ *
+ * @param session the GeckoSession that received the external response.
+ * @param response the external WebResponse.
+ */
+ @UiThread
+ default void onExternalResponse(
+ @NonNull final GeckoSession session, @NonNull final WebResponse response) {}
+
+ /**
+ * The content process hosting this GeckoSession has crashed. The GeckoSession is now closed and
+ * unusable. You may call {@link #open(GeckoRuntime)} to recover the session, but no state is
+ * preserved. Most applications will want to call {@link #load} or {@link
+ * #restoreState(SessionState)} at this point.
+ *
+ * @param session The GeckoSession for which the content process has crashed.
+ */
+ @UiThread
+ default void onCrash(@NonNull final GeckoSession session) {}
+
+ /**
+ * The content process hosting this GeckoSession has been killed. The GeckoSession is now closed
+ * and unusable. You may call {@link #open(GeckoRuntime)} to recover the session, but no state
+ * is preserved. Most applications will want to call {@link #load} or {@link
+ * #restoreState(SessionState)} at this point.
+ *
+ * @param session The GeckoSession for which the content process has been killed.
+ */
+ @UiThread
+ default void onKill(@NonNull final GeckoSession session) {}
+
+ /**
+ * Notification that the first content composition has occurred. This callback is invoked for
+ * the first content composite after either a start or a restart of the compositor.
+ *
+ * @param session The GeckoSession that had a first paint event.
+ */
+ @UiThread
+ default void onFirstComposite(@NonNull final GeckoSession session) {}
+
+ /**
+ * Notification that the first content paint has occurred. This callback is invoked for the
+ * first content paint after a page has been loaded, or after a {@link
+ * #onPaintStatusReset(GeckoSession)} event. The function {@link
+ * #onFirstComposite(GeckoSession)} will be called once the compositor has started rendering.
+ * However, it is possible for the compositor to start rendering before there is any content to
+ * render. onFirstContentfulPaint() is called once some content has been rendered. It may be
+ * nothing more than the page background color. It is not an indication that the whole page has
+ * been rendered.
+ *
+ * @param session The GeckoSession that had a first paint event.
+ */
+ @UiThread
+ default void onFirstContentfulPaint(@NonNull final GeckoSession session) {}
+
+ /**
+ * Notification that the paint status has been reset.
+ *
+ * <p>This callback is invoked whenever the painted content is no longer being displayed. This
+ * can occur in response to the session being paused. After this has fired the compositor may
+ * continue rendering, but may not render the page content. This callback can therefore be used
+ * in conjunction with {@link #onFirstContentfulPaint(GeckoSession)} to determine when there is
+ * valid content being rendered.
+ *
+ * @param session The GeckoSession that had the paint status reset event.
+ */
+ @UiThread
+ default void onPaintStatusReset(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has requested to change pointer icon.
+ *
+ * <p>If the application wants to control pointer icon, it should override this, then handle it.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param icon The pointer icon sent from the content.
+ */
+ @TargetApi(Build.VERSION_CODES.N)
+ @UiThread
+ default void onPointerIconChange(
+ @NonNull final GeckoSession session, @NonNull final PointerIcon icon) {
+ final View view = session.getTextInput().getView();
+ if (view != null) {
+ view.setPointerIcon(icon);
+ }
+ }
+
+ /**
+ * This is fired when the loaded document has a valid Web App Manifest present.
+ *
+ * <p>The various colors (theme_color, background_color, etc.) present in the manifest have been
+ * transformed into #AARRGGBB format.
+ *
+ * @param session The GeckoSession that contains the Web App Manifest
+ * @param manifest A parsed and validated {@link JSONObject} containing the manifest contents.
+ * @see <a href="https://www.w3.org/TR/appmanifest/">Web App Manifest specification</a>
+ */
+ @UiThread
+ default void onWebAppManifest(
+ @NonNull final GeckoSession session, @NonNull final JSONObject manifest) {}
+
+ /**
+ * A script has exceeded its execution timeout value
+ *
+ * @param geckoSession GeckoSession that initiated the callback.
+ * @param scriptFileName Filename of the slow script
+ * @return A {@link GeckoResult} with a SlowScriptResponse value which indicates whether to
+ * allow the Slow Script to continue processing. Stop will halt the slow script. Continue
+ * will pause notifications for a period of time before resuming.
+ */
+ @UiThread
+ default @Nullable GeckoResult<SlowScriptResponse> onSlowScript(
+ @NonNull final GeckoSession geckoSession, @NonNull final String scriptFileName) {
+ return null;
+ }
+
+ /**
+ * The app should display its dynamic toolbar, fully expanded to the height that was previously
+ * specified via {@link GeckoView#setDynamicToolbarMaxHeight}.
+ *
+ * @param geckoSession GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onShowDynamicToolbar(@NonNull final GeckoSession geckoSession) {}
+
+ /**
+ * This method is called when a cookie banner was detected.
+ *
+ * <p>Note: this method is called only if the cookie banner setting is such that allows to
+ * handle the banner. For example, if cookiebanners.service.mode=1 (Reject only) but a cookie
+ * banner can only be accepted on the website - the detection in that case won't be reported.
+ * The exception is MODE_DETECT_ONLY mode, when only the detection event is emitted.
+ *
+ * @param session GeckoSession that initiated the callback.
+ */
+ @AnyThread
+ default void onCookieBannerDetected(@NonNull final GeckoSession session) {}
+
+ /**
+ * This method is called when a cookie banner was handled.
+ *
+ * @param session GeckoSession that initiated the callback.
+ */
+ @AnyThread
+ default void onCookieBannerHandled(@NonNull final GeckoSession session) {}
+ }
+
+ public interface SelectionActionDelegate {
+ /** The selection is collapsed at a single position. */
+ int FLAG_IS_COLLAPSED = 1 << 0;
+
+ /**
+ * The selection is inside editable content such as an input element or contentEditable node.
+ */
+ int FLAG_IS_EDITABLE = 1 << 1;
+
+ /** The selection is inside a password field. */
+ int FLAG_IS_PASSWORD = 1 << 2;
+
+ /** Hide selection actions and cause {@link #onHideAction} to be called. */
+ String ACTION_HIDE = "org.mozilla.geckoview.HIDE";
+
+ /** Copy onto the clipboard then delete the selected content. Selection must be editable. */
+ String ACTION_CUT = "org.mozilla.geckoview.CUT";
+
+ /** Copy the selected content onto the clipboard. */
+ String ACTION_COPY = "org.mozilla.geckoview.COPY";
+
+ /** Delete the selected content. Selection must be editable. */
+ String ACTION_DELETE = "org.mozilla.geckoview.DELETE";
+
+ /** Replace the selected content with the clipboard content. Selection must be editable. */
+ String ACTION_PASTE = "org.mozilla.geckoview.PASTE";
+
+ /**
+ * Replace the selected content with the clipboard content as plain text. Selection must be
+ * editable.
+ */
+ String ACTION_PASTE_AS_PLAIN_TEXT = "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT";
+
+ /** Select the entire content of the document or editor. */
+ String ACTION_SELECT_ALL = "org.mozilla.geckoview.SELECT_ALL";
+
+ /** Clear the current selection. Selection must not be editable. */
+ String ACTION_UNSELECT = "org.mozilla.geckoview.UNSELECT";
+
+ /** Collapse the current selection to its start position. Selection must be editable. */
+ String ACTION_COLLAPSE_TO_START = "org.mozilla.geckoview.COLLAPSE_TO_START";
+
+ /** Collapse the current selection to its end position. Selection must be editable. */
+ String ACTION_COLLAPSE_TO_END = "org.mozilla.geckoview.COLLAPSE_TO_END";
+
+ /** Represents attributes of a selection. */
+ class Selection {
+ /**
+ * Flags describing the current selection, as a bitwise combination of the {@link
+ * #FLAG_IS_COLLAPSED FLAG_*} constants.
+ */
+ public final @SelectionActionDelegateFlag int flags;
+
+ /**
+ * Text content of the current selection. An empty string indicates the selection is collapsed
+ * or the selection cannot be represented as plain text.
+ */
+ public final @NonNull String text;
+
+ /** The bounds of the current selection in screen coordinates. */
+ public final @Nullable RectF screenRect;
+
+ /** Set of valid actions available through {@link Selection#execute(String)} */
+ public final @NonNull @SelectionActionDelegateAction Collection<String> availableActions;
+
+ private final String mActionId;
+
+ private final WeakReference<EventDispatcher> mEventDispatcher;
+
+ /* package */ Selection(
+ final GeckoBundle bundle,
+ final @NonNull @SelectionActionDelegateAction Set<String> actions,
+ final EventDispatcher eventDispatcher) {
+ flags =
+ (bundle.getBoolean("collapsed") ? SelectionActionDelegate.FLAG_IS_COLLAPSED : 0)
+ | (bundle.getBoolean("editable") ? SelectionActionDelegate.FLAG_IS_EDITABLE : 0)
+ | (bundle.getBoolean("password") ? SelectionActionDelegate.FLAG_IS_PASSWORD : 0);
+ text = bundle.getString("selection");
+ screenRect = bundle.getRectF("screenRect");
+ availableActions = actions;
+ mActionId = bundle.getString("actionId");
+ mEventDispatcher = new WeakReference<>(eventDispatcher);
+ }
+
+ /** Empty constructor for tests. */
+ protected Selection() {
+ flags = 0;
+ text = "";
+ screenRect = null;
+ availableActions = new HashSet<>();
+ mActionId = null;
+ mEventDispatcher = null;
+ }
+
+ /**
+ * Checks if the passed action is available
+ *
+ * @param action An {@link SelectionActionDelegate} to perform
+ * @return True if the action is available.
+ */
+ @AnyThread
+ public boolean isActionAvailable(
+ @NonNull @SelectionActionDelegateAction final String action) {
+ return availableActions.contains(action);
+ }
+
+ /**
+ * Execute an {@link SelectionActionDelegate} action.
+ *
+ * @throws IllegalStateException If the action was not available.
+ * @param action A {@link SelectionActionDelegate} action.
+ */
+ @AnyThread
+ public void execute(@NonNull @SelectionActionDelegateAction final String action) {
+ if (!isActionAvailable(action)) {
+ throw new IllegalStateException("Action not available");
+ }
+ final EventDispatcher eventDispatcher = mEventDispatcher.get();
+ if (eventDispatcher == null) {
+ // The session is not available anymore, nothing really to do
+ Log.w(LOGTAG, "Calling execute on a stale Selection.");
+ return;
+ }
+ final GeckoBundle response = new GeckoBundle(2);
+ response.putString("id", action);
+ response.putString("actionId", mActionId);
+ eventDispatcher.dispatch("GeckoView:ExecuteSelectionAction", response);
+ }
+
+ /**
+ * Hide selection actions and cause {@link #onHideAction} to be called.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void hide() {
+ execute(ACTION_HIDE);
+ }
+
+ /**
+ * Copy onto the clipboard then delete the selected content.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void cut() {
+ execute(ACTION_CUT);
+ }
+
+ /**
+ * Copy the selected content onto the clipboard.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void copy() {
+ execute(ACTION_COPY);
+ }
+
+ /**
+ * Delete the selected content.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void delete() {
+ execute(ACTION_DELETE);
+ }
+
+ /**
+ * Replace the selected content with the clipboard content.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void paste() {
+ execute(ACTION_PASTE);
+ }
+
+ /**
+ * Replace the selected content with the clipboard content as plain text.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void pasteAsPlainText() {
+ execute(ACTION_PASTE_AS_PLAIN_TEXT);
+ }
+
+ /**
+ * Select the entire content of the document or editor.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void selectAll() {
+ execute(ACTION_SELECT_ALL);
+ }
+
+ /**
+ * Clear the current selection.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void unselect() {
+ execute(ACTION_UNSELECT);
+ }
+
+ /**
+ * Collapse the current selection to its start position.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void collapseToStart() {
+ execute(ACTION_COLLAPSE_TO_START);
+ }
+
+ /**
+ * Collapse the current selection to its end position.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void collapseToEnd() {
+ execute(ACTION_COLLAPSE_TO_END);
+ }
+ }
+
+ /**
+ * Selection actions are available. Selection actions become available when the user selects
+ * some content in the document or editor. Inside an editor, selection actions can also become
+ * available when the user explicitly requests editor action UI, for example by tapping on the
+ * caret handle.
+ *
+ * <p>In response to this callback, applications typically display a toolbar containing the
+ * selection actions. To perform a certain action, check if the action is available with {@link
+ * Selection#isActionAvailable} then either use the relevant helper method or {@link
+ * Selection#execute}
+ *
+ * <p>Once an {@link #onHideAction} call (with particular reasons) or another {@link
+ * #onShowActionRequest} call is received, the previous Selection object is no longer usable.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param selection Current selection attributes and Callback object for performing built-in
+ * actions. May be used multiple times to perform multiple actions at once.
+ */
+ @UiThread
+ default void onShowActionRequest(
+ @NonNull final GeckoSession session, @NonNull final Selection selection) {}
+
+ /** Actions are no longer available due to the user clearing the selection. */
+ int HIDE_REASON_NO_SELECTION = 0;
+
+ /**
+ * Actions are no longer available due to the user moving the selection out of view. Previous
+ * actions are still available after a callback with this reason.
+ */
+ int HIDE_REASON_INVISIBLE_SELECTION = 1;
+
+ /**
+ * Actions are no longer available due to the user actively changing the selection. {@link
+ * #onShowActionRequest} may be called again once the user has set a selection, if the new
+ * selection has available actions.
+ */
+ int HIDE_REASON_ACTIVE_SELECTION = 2;
+
+ /**
+ * Actions are no longer available due to the user actively scrolling the page. {@link
+ * #onShowActionRequest} may be called again once the user has stopped scrolling the page, if
+ * the selection is still visible. Until then, previous actions are still available after a
+ * callback with this reason.
+ */
+ int HIDE_REASON_ACTIVE_SCROLL = 3;
+
+ /**
+ * Previous actions are no longer available due to the user interacting with the page.
+ * Applications typically hide the action toolbar in response.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param reason The reason that actions are no longer available, as one of the {@link
+ * #HIDE_REASON_NO_SELECTION HIDE_REASON_*} constants.
+ */
+ @UiThread
+ default void onHideAction(
+ @NonNull final GeckoSession session, @SelectionActionDelegateHideReason final int reason) {}
+
+ /**
+ * Permission for reading clipboard data. See: <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText">Clipboard.readText()</a>
+ */
+ int PERMISSION_CLIPBOARD_READ = 1;
+
+ /** Represents attributes of a clipboard permission. */
+ class ClipboardPermission {
+ /** The URI associated with this content permission. */
+ public final @NonNull String uri;
+
+ /**
+ * The type of this permission; one of {@link #PERMISSION_CLIPBOARD_READ
+ * PERMISSION_CLIPBOARD_*}.
+ */
+ public final @ClipboardPermissionType int type;
+
+ /**
+ * The last mouse or touch location in screen coordinates when the permission is requested.
+ */
+ public final @Nullable Point screenPoint;
+
+ /** Empty constructor for tests */
+ protected ClipboardPermission() {
+ this.uri = "";
+ this.type = PERMISSION_CLIPBOARD_READ;
+ this.screenPoint = null;
+ }
+
+ private ClipboardPermission(final @NonNull GeckoBundle bundle) {
+ this.uri = bundle.getString("uri");
+ this.type = PERMISSION_CLIPBOARD_READ;
+ this.screenPoint = bundle.getPoint("screenPoint");
+ }
+ }
+
+ /**
+ * Request clipboard permission.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param permission An {@link ClipboardPermission} describing the permission being requested.
+ * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the
+ * permission request for this site.
+ */
+ @UiThread
+ default @Nullable GeckoResult<AllowOrDeny> onShowClipboardPermissionRequest(
+ @NonNull final GeckoSession session, @NonNull ClipboardPermission permission) {
+ return GeckoResult.deny();
+ }
+
+ /**
+ * Dismiss requesting clipboard permission popup or model.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onDismissClipboardPermissionRequest(@NonNull final GeckoSession session) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({
+ SelectionActionDelegate.ACTION_HIDE,
+ SelectionActionDelegate.ACTION_CUT,
+ SelectionActionDelegate.ACTION_COPY,
+ SelectionActionDelegate.ACTION_DELETE,
+ SelectionActionDelegate.ACTION_PASTE,
+ SelectionActionDelegate.ACTION_PASTE_AS_PLAIN_TEXT,
+ SelectionActionDelegate.ACTION_SELECT_ALL,
+ SelectionActionDelegate.ACTION_UNSELECT,
+ SelectionActionDelegate.ACTION_COLLAPSE_TO_START,
+ SelectionActionDelegate.ACTION_COLLAPSE_TO_END
+ })
+ public @interface SelectionActionDelegateAction {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SelectionActionDelegate.FLAG_IS_COLLAPSED,
+ SelectionActionDelegate.FLAG_IS_EDITABLE,
+ SelectionActionDelegate.FLAG_IS_PASSWORD
+ })
+ public @interface SelectionActionDelegateFlag {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SelectionActionDelegate.HIDE_REASON_NO_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL
+ })
+ public @interface SelectionActionDelegateHideReason {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SelectionActionDelegate.PERMISSION_CLIPBOARD_READ,
+ })
+ public @interface ClipboardPermissionType {}
+
+ public interface NavigationDelegate {
+ /**
+ * A view has started loading content from the network.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param url The resource being loaded.
+ * @param perms The permissions currently associated with this url.
+ */
+ @UiThread
+ default void onLocationChange(
+ @NonNull GeckoSession session,
+ @Nullable String url,
+ final @NonNull List<PermissionDelegate.ContentPermission> perms) {}
+
+ /**
+ * The view's ability to go back has changed.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param canGoBack The new value for the ability.
+ */
+ @UiThread
+ default void onCanGoBack(@NonNull final GeckoSession session, final boolean canGoBack) {}
+
+ /**
+ * The view's ability to go forward has changed.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param canGoForward The new value for the ability.
+ */
+ @UiThread
+ default void onCanGoForward(@NonNull final GeckoSession session, final boolean canGoForward) {}
+
+ int TARGET_WINDOW_NONE = 0;
+ int TARGET_WINDOW_CURRENT = 1;
+ int TARGET_WINDOW_NEW = 2;
+
+ // Match with nsIWebNavigation.idl.
+ /** The load request was triggered by an HTTP redirect. */
+ int LOAD_REQUEST_IS_REDIRECT = 0x800000;
+
+ /** Load request details. */
+ class LoadRequest {
+ /* package */ LoadRequest(
+ @NonNull final String uri,
+ @Nullable final String triggerUri,
+ final int geckoTarget,
+ final int flags,
+ final boolean hasUserGesture,
+ final boolean isDirectNavigation) {
+ this.uri = uri;
+ this.triggerUri = triggerUri;
+ this.target = convertGeckoTarget(geckoTarget);
+ this.isRedirect = (flags & LOAD_REQUEST_IS_REDIRECT) != 0;
+ this.hasUserGesture = hasUserGesture;
+ this.isDirectNavigation = isDirectNavigation;
+ }
+
+ /** Empty constructor for tests. */
+ protected LoadRequest() {
+ uri = "";
+ triggerUri = null;
+ target = TARGET_WINDOW_NONE;
+ isRedirect = false;
+ hasUserGesture = false;
+ isDirectNavigation = false;
+ }
+
+ // This needs to match nsIBrowserDOMWindow.idl
+ private @TargetWindow int convertGeckoTarget(final int geckoTarget) {
+ switch (geckoTarget) {
+ case 0: // OPEN_DEFAULTWINDOW
+ case 1: // OPEN_CURRENTWINDOW
+ return TARGET_WINDOW_CURRENT;
+ default: // OPEN_NEWWINDOW, OPEN_NEWTAB, OPEN_NEWTAB_BACKGROUND
+ return TARGET_WINDOW_NEW;
+ }
+ }
+
+ /** The URI to be loaded. */
+ public final @NonNull String uri;
+
+ /**
+ * The URI of the origin page that triggered the load request. null for initial loads and
+ * loads originating from data: URIs.
+ */
+ public final @Nullable String triggerUri;
+
+ /**
+ * The target where the window has requested to open. One of {@link #TARGET_WINDOW_NONE
+ * TARGET_WINDOW_*}.
+ */
+ public final @TargetWindow int target;
+
+ /**
+ * True if and only if the request was triggered by an HTTP redirect.
+ *
+ * <p>If the user loads URI "a", which redirects to URI "b", then <code>onLoadRequest</code>
+ * will be called twice, first with uri "a" and <code>isRedirect = false</code>, then with uri
+ * "b" and <code>isRedirect = true</code>.
+ */
+ public final boolean isRedirect;
+
+ /** True if there was an active user gesture when the load was requested. */
+ public final boolean hasUserGesture;
+
+ /**
+ * This load request was initiated by a direct navigation from the application. E.g. when
+ * calling {@link GeckoSession#load}.
+ */
+ public final boolean isDirectNavigation;
+
+ @Override
+ public String toString() {
+ final StringBuilder out = new StringBuilder("LoadRequest { ");
+ out.append("uri: " + uri)
+ .append(", triggerUri: " + triggerUri)
+ .append(", target: " + target)
+ .append(", isRedirect: " + isRedirect)
+ .append(", hasUserGesture: " + hasUserGesture)
+ .append(", fromLoadUri: " + hasUserGesture)
+ .append(" }");
+ return out.toString();
+ }
+ }
+
+ /**
+ * A request to open an URI. This is called before each top-level page load to allow custom
+ * behavior. For example, this can be used to override the behavior of TAGET_WINDOW_NEW
+ * requests, which defaults to requesting a new GeckoSession via onNewSession.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param request The {@link LoadRequest} containing the request details.
+ * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not
+ * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a
+ * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is
+ * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled).
+ */
+ @UiThread
+ default @Nullable GeckoResult<AllowOrDeny> onLoadRequest(
+ @NonNull final GeckoSession session, @NonNull final LoadRequest request) {
+ return null;
+ }
+
+ /**
+ * A request to load a URI in a non-top-level context.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param request The {@link LoadRequest} containing the request details.
+ * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not
+ * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a
+ * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is
+ * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled).
+ */
+ @UiThread
+ default @Nullable GeckoResult<AllowOrDeny> onSubframeLoadRequest(
+ @NonNull final GeckoSession session, @NonNull final LoadRequest request) {
+ return null;
+ }
+
+ /**
+ * A request has been made to open a new session. The URI is provided only for informational
+ * purposes. Do not call GeckoSession.load here. Additionally, the returned GeckoSession must be
+ * a newly-created one.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param uri The URI to be loaded.
+ * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which
+ * case the request for a new window by web content will fail. e.g., <code>window.open()
+ * </code> will return null. The implementation of onNewSession is responsible for
+ * maintaining a reference to the returned object, to prevent it from being garbage
+ * collected.
+ */
+ @UiThread
+ default @Nullable GeckoResult<GeckoSession> onNewSession(
+ @NonNull final GeckoSession session, @NonNull final String uri) {
+ return null;
+ }
+
+ /**
+ * @param session The GeckoSession that initiated the callback.
+ * @param uri The URI that failed to load.
+ * @param error A WebRequestError containing details about the error
+ * @return A URI to display as an error (cannot be http/https). Returning null or http/https URL
+ * will halt the load entirely. The following special methods are made available to the URI:
+ * - document.addCertException(isTemporary), returns Promise -
+ * document.getFailedCertSecurityInfo(), returns FailedCertSecurityInfo -
+ * document.getNetErrorInfo(), returns NetErrorInfo document.reloadWithHttpsOnlyException()
+ * @see <a
+ * href="https://searchfox.org/mozilla-central/source/dom/webidl/FailedCertSecurityInfo.webidl">FailedCertSecurityInfo
+ * IDL</a>
+ * @see <a
+ * href="https://searchfox.org/mozilla-central/source/dom/webidl/NetErrorInfo.webidl">NetErrorInfo
+ * IDL</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<String> onLoadError(
+ @NonNull final GeckoSession session,
+ @Nullable final String uri,
+ @NonNull final WebRequestError error) {
+ return null;
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ NavigationDelegate.TARGET_WINDOW_NONE,
+ NavigationDelegate.TARGET_WINDOW_CURRENT,
+ NavigationDelegate.TARGET_WINDOW_NEW
+ })
+ public @interface TargetWindow {}
+
+ /**
+ * GeckoSession applications implement this interface to handle prompts triggered by content in
+ * the GeckoSession, such as alerts, authentication dialogs, and select list pickers.
+ */
+ public interface PromptDelegate {
+ /** PromptResponse is an opaque class created upon confirming or dismissing a prompt. */
+ class PromptResponse {
+ private final BasePrompt mPrompt;
+
+ /* package */ PromptResponse(@NonNull final BasePrompt prompt) {
+ mPrompt = prompt;
+ }
+
+ /* package */ void dispatch(@NonNull final EventCallback callback) {
+ if (mPrompt == null) {
+ throw new RuntimeException("Trying to confirm/dismiss a null prompt.");
+ }
+ mPrompt.dispatch(callback);
+ }
+ }
+
+ interface PromptInstanceDelegate {
+ /**
+ * Called when this prompt has been dismissed by the system.
+ *
+ * <p>This can happen e.g. when the page navigates away and the content of the prompt is not
+ * relevant anymore.
+ *
+ * <p>When this method is called, you should hide the prompt UI elements.
+ *
+ * @param prompt the prompt that should be dismissed.
+ */
+ @UiThread
+ default void onPromptDismiss(final @NonNull BasePrompt prompt) {}
+
+ /**
+ * Called when this prompt has been updated.
+ *
+ * <p>This is called if inner &lt;option&gt; elements are updated when using &lt;select&gt;
+ * element.
+ *
+ * <p>When this method is called, you should update the prompt UI elements.
+ *
+ * @param prompt the new prompt that should be updated.
+ */
+ @UiThread
+ default void onPromptUpdate(final @NonNull BasePrompt prompt) {}
+ }
+
+ // Prompt classes.
+ class BasePrompt {
+ private boolean mIsCompleted;
+ private boolean mIsConfirmed;
+ private GeckoBundle mResult;
+ private final WeakReference<Observer> mObserver;
+ private PromptInstanceDelegate mDelegate;
+
+ protected interface Observer {
+ @AnyThread
+ default void onPromptCompleted(@NonNull BasePrompt prompt) {}
+ }
+
+ private void complete() {
+ mIsCompleted = true;
+ final Observer observer = mObserver.get();
+ if (observer != null) {
+ observer.onPromptCompleted(this);
+ }
+ }
+
+ /** The title of this prompt; may be null. */
+ public final @Nullable String title;
+
+ /* package */ String id;
+
+ private BasePrompt(
+ @NonNull final String id, @Nullable final String title, final Observer observer) {
+ this.title = title;
+ this.id = id;
+ mIsConfirmed = false;
+ mIsCompleted = false;
+ mObserver = new WeakReference<>(observer);
+ }
+
+ @UiThread
+ protected @NonNull PromptResponse confirm() {
+ if (mIsCompleted) {
+ throw new RuntimeException("Cannot confirm/dismiss a Prompt twice.");
+ }
+
+ mIsConfirmed = true;
+ complete();
+ return new PromptResponse(this);
+ }
+
+ /**
+ * This dismisses the prompt without sending any meaningful information back to content.
+ *
+ * @return A {@link PromptResponse} with which you can complete the {@link GeckoResult} that
+ * corresponds to this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ if (mIsCompleted) {
+ throw new RuntimeException("Cannot confirm/dismiss a Prompt twice.");
+ }
+
+ complete();
+ return new PromptResponse(this);
+ }
+
+ /**
+ * Set the delegate for this prompt.
+ *
+ * @param delegate the {@link PromptInstanceDelegate} instance.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable PromptInstanceDelegate delegate) {
+ mDelegate = delegate;
+ }
+
+ /**
+ * Get the delegate for this prompt.
+ *
+ * @return the {@link PromptInstanceDelegate} instance.
+ */
+ @UiThread
+ @Nullable
+ public PromptInstanceDelegate getDelegate() {
+ return mDelegate;
+ }
+
+ /* package */ GeckoBundle ensureResult() {
+ if (mResult == null) {
+ // Usually result object contains two items.
+ mResult = new GeckoBundle(2);
+ }
+ return mResult;
+ }
+
+ /**
+ * This returns true if the prompt has already been confirmed or dismissed.
+ *
+ * @return A boolean which is true if the prompt has been confirmed or dismissed, and false
+ * otherwise.
+ */
+ @UiThread
+ public boolean isComplete() {
+ return mIsCompleted;
+ }
+
+ /* package */ void dispatch(@NonNull final EventCallback callback) {
+ if (!mIsCompleted) {
+ throw new RuntimeException("Trying to dispatch an incomplete prompt.");
+ }
+
+ if (!mIsConfirmed) {
+ callback.sendSuccess(null);
+ } else {
+ callback.sendSuccess(mResult);
+ }
+ }
+ }
+
+ /**
+ * BeforeUnloadPrompt represents the onbeforeunload prompt. See
+ * https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
+ */
+ class BeforeUnloadPrompt extends BasePrompt {
+ protected BeforeUnloadPrompt(@NonNull final String id, @NonNull final Observer observer) {
+ super(id, null, observer);
+ }
+
+ /**
+ * Confirms the prompt.
+ *
+ * @param allowOrDeny whether the navigation should be allowed to continue or not.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) {
+ ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * RepostConfirmPrompt represents a prompt shown whenever the browser needs to resubmit POST
+ * data (e.g. due to page refresh).
+ */
+ class RepostConfirmPrompt extends BasePrompt {
+ protected RepostConfirmPrompt(@NonNull final String id, @NonNull final Observer observer) {
+ super(id, null, observer);
+ }
+
+ /**
+ * Confirms the prompt.
+ *
+ * @param allowOrDeny whether the browser should allow resubmitting data.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) {
+ ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * AlertPrompt contains the information necessary to represent a JavaScript alert() call from
+ * content; it can only be dismissed, not confirmed.
+ */
+ class AlertPrompt extends BasePrompt {
+ /** The message to be displayed with this alert; may be null. */
+ public final @Nullable String message;
+
+ protected AlertPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ }
+ }
+
+ /** Contains all the Identity credential prompts (FedCM) */
+ final class IdentityCredential {
+ /**
+ * ProviderSelectorPrompt contains the information necessary to represent a prompt that allows
+ * the user to select the identity credential provider they would like to use.
+ */
+ public static class ProviderSelectorPrompt extends BasePrompt {
+ /** The providers from which the user could select. */
+ public final @NonNull Provider[] providers;
+
+ /**
+ * Creates a new {@link ProviderSelectorPrompt} with the given parameters.
+ *
+ * @param id The identification for this prompt.
+ * @param providers The providers from which the user could select.
+ * @param observer A callback to notify when the prompt has been completed.
+ */
+ protected ProviderSelectorPrompt(
+ @NonNull final String id,
+ @NonNull final Provider[] providers,
+ @NonNull final Observer observer) {
+ super(id, null, observer);
+ this.providers = providers;
+ }
+
+ /**
+ * Confirms the prompt and passes the provider index back to content.
+ *
+ * @param providerIndex providerIndex An integer representing the index of the provider
+ * chosen by the user to be returned to content.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final int providerIndex) {
+ ensureResult().putInt("providerIndex", providerIndex);
+ return super.confirm();
+ }
+
+ /** A representation of an Identity Credential Provider. */
+ public static class Provider {
+ /** A base64 string for given icon for the provider; may be null. */
+ public final @Nullable String icon;
+
+ /** The name of the provider. */
+ public final @NonNull String name;
+
+ /** The id of the provider. */
+ public final int id;
+
+ /** The domain of the provider */
+ public final @NonNull String domain;
+
+ /**
+ * Creates a new {@link Provider} with the given parameters.
+ *
+ * @param id The identification for this prompt.
+ * @param icon A string base64 icon.
+ * @param name The name of the {@link Provider}.
+ * @param domain The domain of the {@link Provider}.
+ */
+ public Provider(
+ final int id,
+ final @NonNull String name,
+ final @Nullable String icon,
+ final @NonNull String domain) {
+ this.id = id;
+ this.icon = icon;
+ this.name = name;
+ this.domain = domain;
+ }
+
+ /* package */
+ static @NonNull Provider fromBundle(final @NonNull GeckoBundle bundle) {
+ final int id = bundle.getInt("providerIndex");
+ final String icon = bundle.getString("icon");
+ final String name = bundle.getString("name");
+ final String domain = bundle.getString("domain");
+ return new Provider(id, name, icon, domain);
+ }
+ }
+ }
+
+ /**
+ * AccountSelectorPrompt contains the information necessary to represent a prompt that allows
+ * the user to select the account they would like to use.
+ */
+ public static class AccountSelectorPrompt extends BasePrompt {
+ /** The accounts from which the user could select. */
+ public final @NonNull Account[] accounts;
+
+ /** The name of the provider the user is trying to login with */
+ public final @NonNull Provider provider;
+
+ /**
+ * Creates a new {@link AccountSelectorPrompt} with the given parameters.
+ *
+ * @param id The identification for this prompt.
+ * @param accounts The accounts from which the user could select.
+ * @param provider The provider on which the user is trying to log in.
+ * @param observer A callback to notify when the prompt has been completed.
+ */
+ public AccountSelectorPrompt(
+ @NonNull final String id,
+ @NonNull final Account[] accounts,
+ @NonNull final Provider provider,
+ final Observer observer) {
+ super(id, null, observer);
+ this.accounts = accounts;
+ this.provider = provider;
+ }
+
+ /**
+ * Confirms the prompt and passes the account index back to content.
+ *
+ * @param accountIndex An integer representing the index of the account chosen by the user
+ * to be returned to content.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final int accountIndex) {
+ ensureResult().putInt("accountIndex", accountIndex);
+ return super.confirm();
+ }
+
+ /** A representation of an Identity Credential Provider Accounts. */
+ public static class ProviderAccounts {
+ /** The name of the provider. */
+ public final @Nullable Provider provider;
+
+ /** The accounts available for this provider. */
+ public final @NonNull Account[] accounts;
+
+ /** The id of this prompt. */
+ public final int id;
+
+ /**
+ * Creates a new {@link ProviderAccounts} with the given parameters
+ *
+ * @param id The identification for this prompt.
+ * @param provider The name of the provider.
+ * @param accounts The list of {@link Account}s available for this provider.
+ */
+ public ProviderAccounts(
+ final int id, @Nullable final Provider provider, @NonNull final Account[] accounts) {
+ this.id = id;
+ this.provider = provider;
+ this.accounts = accounts;
+ }
+
+ /* package */
+ static @NonNull ProviderAccounts fromBundle(final @NonNull GeckoBundle bundle) {
+ final int id = bundle.getInt("accountIndex");
+ final Provider provider = Provider.fromBundle(bundle.getBundle("provider"));
+
+ final GeckoBundle[] accountsBundle = bundle.getBundleArray("accounts");
+ if (accountsBundle == null) {
+ return new ProviderAccounts(id, provider, new Account[0]);
+ }
+
+ final Account[] accounts = new Account[accountsBundle.length];
+ for (int i = 0; i < accountsBundle.length; i++) {
+ accounts[i] = Account.fromBundle(accountsBundle[i]);
+ }
+ return new ProviderAccounts(id, provider, accounts);
+ }
+ }
+
+ /** A representation of an Identity Credential Account. */
+ public static class Account {
+ /** The id of the account. */
+ public final int id;
+
+ /** The email associated to this account. */
+ public final @NonNull String email;
+
+ /** The name of this account. */
+ public final @NonNull String name;
+
+ /** A base64 string for given icon for the account; may be null. */
+ public final @Nullable String icon;
+
+ /**
+ * Creates a new {@link Account} with the given parameters.
+ *
+ * @param id The identification for this account.
+ * @param email The email of this account.
+ * @param name The name of this account.
+ * @param icon A string base64 icon.
+ */
+ public Account(
+ final int id,
+ @NonNull final String email,
+ @NonNull final String name,
+ @Nullable final String icon) {
+ this.email = email;
+ this.name = name;
+ this.icon = icon;
+ this.id = id;
+ }
+
+ /* package */
+ static @NonNull Account fromBundle(final @NonNull GeckoBundle bundle) {
+ final int id = bundle.getInt("id");
+ final String icon = bundle.getString("icon");
+ final String name = bundle.getString("name");
+ final String email = bundle.getString("email");
+ return new Account(id, email, name, icon);
+ }
+ }
+
+ /** A representation of an Identity Credential Provider for an Account Selector Prompt */
+ public static class Provider {
+ /** The name of the provider */
+ public final @NonNull String name;
+
+ /** The domain of the provider */
+ public final @NonNull String domain;
+
+ /** A base64 string for given icon for the provider; may be null. */
+ public final @Nullable String icon;
+
+ /**
+ * Creates a new {@link Provider} with the given parameters
+ *
+ * @param name the name of the Provider
+ * @param favicon A string base64 icon for the provider
+ * @param domain A string base64 icon for the provider
+ */
+ public Provider(
+ @NonNull final String name,
+ @NonNull final String domain,
+ @Nullable final String favicon) {
+ this.name = name;
+ this.domain = domain;
+ this.icon = favicon;
+ }
+
+ /* package */
+ static @NonNull Provider fromBundle(final @NonNull GeckoBundle bundle) {
+ final String name = bundle.getString("name");
+ final String domain = bundle.getString("domain");
+ final String icon = bundle.getString("icon");
+ return new Provider(name, domain, icon);
+ }
+ }
+ }
+
+ /**
+ * PrivacyPolicyPrompt contains the information necessary to represent a prompt that allows
+ * the user to indicate if agrees or not with the privacy policy of the identity credential
+ * provider.
+ */
+ public static class PrivacyPolicyPrompt extends BasePrompt {
+ /** The URL where the policy for using this provider is hosted. */
+ public final @NonNull String privacyPolicyUrl;
+
+ /** The URL where the terms of service for using this provider are hosted. */
+ public final @NonNull String termsOfServiceUrl;
+
+ /** The domain of the provider. */
+ public final @NonNull String providerDomain;
+
+ /** The host of the provider. */
+ public final @NonNull String host;
+
+ /** A base64 string for given icon for the provider; may be null. */
+ public final @Nullable String icon;
+
+ /**
+ * Creates a new {@link IdentityCredential.ProviderSelectorPrompt} with the given
+ * parameters.
+ *
+ * @param id The identification for this prompt.
+ * @param privacyPolicyUrl The URL where the policy for using this provider is hosted.
+ * @param termsOfServiceUrl The URL where the terms of service for using this provider are
+ * hosted.
+ * @param providerDomain The domain of the provider.
+ * @param host The host of the provider.
+ * @param icon A base64 string for given icon for the provider; may be null.
+ * @param observer A callback to notify when the prompt has been completed.
+ */
+ protected PrivacyPolicyPrompt(
+ @NonNull final String id,
+ @NonNull final String privacyPolicyUrl,
+ @NonNull final String termsOfServiceUrl,
+ @NonNull final String providerDomain,
+ @NonNull final String host,
+ @Nullable final String icon,
+ @NonNull final Observer observer) {
+ super(id, null, observer);
+ this.privacyPolicyUrl = privacyPolicyUrl;
+ this.termsOfServiceUrl = termsOfServiceUrl;
+ this.providerDomain = providerDomain;
+ this.host = host;
+ this.icon = icon;
+ }
+
+ /**
+ * Confirms the prompt and passes the provider accept value back to content.
+ *
+ * @param accept A boolean indicating if the user accepts or not the Privacy Policy of the
+ * provider.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final boolean accept) {
+ ensureResult().putBoolean("accept", accept);
+ return super.confirm();
+ }
+ }
+ }
+
+ /**
+ * ButtonPrompt contains the information necessary to represent a JavaScript confirm() call from
+ * content.
+ */
+ class ButtonPrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.POSITIVE, Type.NEGATIVE})
+ public @interface ButtonType {}
+
+ public static class Type {
+ /** Index of positive response button (eg, "Yes", "OK") */
+ public static final int POSITIVE = 0;
+
+ /** Index of negative response button (eg, "No", "Cancel") */
+ public static final int NEGATIVE = 2;
+
+ protected Type() {}
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ protected ButtonPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ }
+
+ /**
+ * Confirms this prompt, returning the selected button to content.
+ *
+ * @param selection An int representing the selected button, must be one of {@link Type}.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@ButtonType final int selection) {
+ ensureResult().putInt("button", selection);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * TextPrompt contains the information necessary to represent a Javascript prompt() call from
+ * content.
+ */
+ class TextPrompt extends BasePrompt {
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** The default value for the text field; may be null. */
+ public final @Nullable String defaultValue;
+
+ protected TextPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @Nullable final String defaultValue,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.defaultValue = defaultValue;
+ }
+
+ /**
+ * Confirms this prompt, returning the input text to content.
+ *
+ * @param text A String containing the text input given by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String text) {
+ ensureResult().putString("text", text);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * AuthPrompt contains the information necessary to represent an HTML authorization prompt
+ * generated by content.
+ */
+ class AuthPrompt extends BasePrompt {
+ public static class AuthOptions {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Flags.HOST,
+ Flags.PROXY,
+ Flags.ONLY_PASSWORD,
+ Flags.PREVIOUS_FAILED,
+ Flags.CROSS_ORIGIN_SUB_RESOURCE
+ })
+ public @interface AuthFlag {}
+
+ /** Auth prompt flags. */
+ public static class Flags {
+ /** The auth prompt is for a network host. */
+ public static final int HOST = 1 << 0;
+
+ /** The auth prompt is for a proxy. */
+ public static final int PROXY = 1 << 1;
+
+ /** The auth prompt should only request a password. */
+ public static final int ONLY_PASSWORD = 1 << 3;
+
+ /** The auth prompt is the result of a previous failed login. */
+ public static final int PREVIOUS_FAILED = 1 << 4;
+
+ /** The auth prompt is for a cross-origin sub-resource. */
+ public static final int CROSS_ORIGIN_SUB_RESOURCE = 1 << 5;
+
+ protected Flags() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Level.NONE, Level.PW_ENCRYPTED, Level.SECURE})
+ public @interface AuthLevel {}
+
+ /** Auth prompt levels. */
+ public static class Level {
+ /** The auth request is unencrypted or the encryption status is unknown. */
+ public static final int NONE = 0;
+
+ /** The auth request only encrypts password but not data. */
+ public static final int PW_ENCRYPTED = 1;
+
+ /** The auth request encrypts both password and data. */
+ public static final int SECURE = 2;
+
+ protected Level() {}
+ }
+
+ /** An int bit-field of {@link Flags}. */
+ public @AuthFlag final int flags;
+
+ /** A string containing the URI for the auth request or null if unknown. */
+ public @Nullable final String uri;
+
+ /** An int, one of {@link Level}, indicating level of encryption. */
+ public @AuthLevel final int level;
+
+ /** A string containing the initial username or null if password-only. */
+ public @Nullable final String username;
+
+ /** A string containing the initial password. */
+ public @Nullable final String password;
+
+ /* package */ AuthOptions(final GeckoBundle options) {
+ flags = options.getInt("flags");
+ uri = options.getString("uri");
+ level = options.getInt("level");
+ username = options.getString("username");
+ password = options.getString("password");
+ }
+
+ /** Empty constructor for tests */
+ protected AuthOptions() {
+ flags = 0;
+ uri = "";
+ level = Level.NONE;
+ username = "";
+ password = "";
+ }
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** The {@link AuthOptions} that describe the type of authorization prompt. */
+ public final @NonNull AuthOptions authOptions;
+
+ protected AuthPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final AuthOptions authOptions,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.authOptions = authOptions;
+ }
+
+ /**
+ * Confirms this prompt with just a password, returning the password to content.
+ *
+ * @param password A String containing the password input by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String password) {
+ ensureResult().putString("password", password);
+ return super.confirm();
+ }
+
+ /**
+ * Confirms this prompt with a username and password, returning both to content.
+ *
+ * @param username A String containing the username input by the user.
+ * @param password A String containing the password input by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final String username, @NonNull final String password) {
+ ensureResult().putString("username", username);
+ ensureResult().putString("password", password);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * ChoicePrompt contains the information necessary to display a menu or list prompt generated by
+ * content.
+ */
+ class ChoicePrompt extends BasePrompt {
+ public static class Choice {
+ /**
+ * A boolean indicating if the item is disabled. Item should not be selectable if this is
+ * true.
+ */
+ public final boolean disabled;
+
+ /**
+ * A String giving the URI of the item icon, or null if none exists (only valid for menus)
+ */
+ public final @Nullable String icon;
+
+ /** A String giving the ID of the item or group */
+ public final @NonNull String id;
+
+ /** A Choice array of sub-items in a group, or null if not a group */
+ public final @Nullable Choice[] items;
+
+ /** A string giving the label for displaying the item or group */
+ public final @NonNull String label;
+
+ /** A boolean indicating if the item should be pre-selected (pre-checked for menu items) */
+ public final boolean selected;
+
+ /** A boolean indicating if the item should be a menu separator (only valid for menus) */
+ public final boolean separator;
+
+ /* package */ Choice(final GeckoBundle choice) {
+ disabled = choice.getBoolean("disabled");
+ icon = choice.getString("icon");
+ id = choice.getString("id");
+ label = choice.getString("label");
+ selected = choice.getBoolean("selected");
+ separator = choice.getBoolean("separator");
+
+ final GeckoBundle[] choices = choice.getBundleArray("items");
+ if (choices == null) {
+ items = null;
+ } else {
+ items = new Choice[choices.length];
+ for (int i = 0; i < choices.length; i++) {
+ items[i] = new Choice(choices[i]);
+ }
+ }
+ }
+
+ /** Empty constructor for tests. */
+ protected Choice() {
+ disabled = false;
+ icon = "";
+ id = "";
+ label = "";
+ selected = false;
+ separator = false;
+ items = null;
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.MENU, Type.SINGLE, Type.MULTIPLE})
+ public @interface ChoiceType {}
+
+ public static class Type {
+ /** Display choices in a menu that dismisses as soon as an item is chosen. */
+ public static final int MENU = 1;
+
+ /** Display choices in a list that allows a single selection. */
+ public static final int SINGLE = 2;
+
+ /** Display choices in a list that allows multiple selections. */
+ public static final int MULTIPLE = 3;
+
+ protected Type() {}
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** One of {@link Type}. */
+ public final @ChoiceType int type;
+
+ /** An array of {@link Choice} representing possible choices. */
+ public final @NonNull Choice[] choices;
+
+ protected ChoicePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @ChoiceType final int type,
+ @NonNull final Choice[] choices,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.type = type;
+ this.choices = choices;
+ }
+
+ /**
+ * Confirms this prompt with the string id of a single choice.
+ *
+ * @param selectedId The string ID of the selected choice.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String selectedId) {
+ return confirm(new String[] {selectedId});
+ }
+
+ /**
+ * Confirms this prompt with the string ids of multiple choices
+ *
+ * @param selectedIds The string IDs of the selected choices.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String[] selectedIds) {
+ if ((Type.MENU == type || Type.SINGLE == type)
+ && (selectedIds == null || selectedIds.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+ ensureResult().putStringArray("choices", selectedIds);
+ return super.confirm();
+ }
+
+ /**
+ * Confirms this prompt with a single choice.
+ *
+ * @param selectedChoice The selected choice.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final Choice selectedChoice) {
+ return confirm(selectedChoice == null ? null : selectedChoice.id);
+ }
+
+ /**
+ * Confirms this prompt with multiple choices.
+ *
+ * @param selectedChoices The selected choices.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final Choice[] selectedChoices) {
+ if ((Type.MENU == type || Type.SINGLE == type)
+ && (selectedChoices == null || selectedChoices.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+
+ if (selectedChoices == null) {
+ return confirm((String[]) null);
+ }
+
+ final String[] ids = new String[selectedChoices.length];
+ for (int i = 0; i < ids.length; i++) {
+ ids[i] = (selectedChoices[i] == null) ? null : selectedChoices[i].id;
+ }
+
+ return confirm(ids);
+ }
+ }
+
+ /**
+ * ColorPrompt contains the information necessary to represent a prompt for color input
+ * generated by content.
+ */
+ class ColorPrompt extends BasePrompt {
+ /** The default value supplied by content. */
+ public final @Nullable String defaultValue;
+
+ /** The predefined values by &lt;datalist&gt; element */
+ public final @Nullable String[] predefinedValues;
+
+ protected ColorPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String defaultValue,
+ @Nullable final String[] predefinedValues,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.defaultValue = defaultValue;
+ this.predefinedValues = predefinedValues;
+ }
+
+ /**
+ * Confirms the prompt and passes the color value back to content.
+ *
+ * @param color A String representing the color to be returned to content.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String color) {
+ ensureResult().putString("color", color);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * DateTimePrompt contains the information necessary to represent a prompt for date and/or time
+ * input generated by content.
+ */
+ class DateTimePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.DATE, Type.MONTH, Type.WEEK, Type.TIME, Type.DATETIME_LOCAL})
+ public @interface DatetimeType {}
+
+ public static class Type {
+ /** Prompt for year, month, and day. */
+ public static final int DATE = 1;
+
+ /** Prompt for year and month. */
+ public static final int MONTH = 2;
+
+ /** Prompt for year and week. */
+ public static final int WEEK = 3;
+
+ /** Prompt for hour and minute. */
+ public static final int TIME = 4;
+
+ /** Prompt for year, month, day, hour, and minute, without timezone. */
+ public static final int DATETIME_LOCAL = 5;
+
+ protected Type() {}
+ }
+
+ /** One of {@link Type} indicating the type of prompt. */
+ public final @DatetimeType int type;
+
+ /** A String representing the default value supplied by content. */
+ public final @Nullable String defaultValue;
+
+ /** A String representing the minimum value allowed by content. */
+ public final @Nullable String minValue;
+
+ /** A String representing the maximum value allowed by content. */
+ public final @Nullable String maxValue;
+
+ /** A String representing the step value allowed by content. */
+ public final @Nullable String stepValue;
+
+ /** For testing. */
+ private DateTimePrompt() {
+ // Initialize final members
+ super("", null, null);
+ this.type = Type.DATE;
+ this.defaultValue = null;
+ this.minValue = null;
+ this.maxValue = null;
+ this.stepValue = null;
+ }
+
+ /* package */ DateTimePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @DatetimeType final int type,
+ @Nullable final String defaultValue,
+ @Nullable final String minValue,
+ @Nullable final String maxValue,
+ @Nullable final String stepValue,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.type = type;
+ this.defaultValue = defaultValue;
+ this.minValue = minValue;
+ this.maxValue = maxValue;
+ this.stepValue = stepValue;
+ }
+
+ /**
+ * Confirms the prompt and passes the date and/or time value back to content.
+ *
+ * @param datetime A String representing the date and time to be returned to content.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String datetime) {
+ ensureResult().putString("datetime", datetime);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * FilePrompt contains the information necessary to represent a prompt for a file or files
+ * generated by content.
+ */
+ class FilePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.SINGLE, Type.MULTIPLE})
+ public @interface FileType {}
+
+ /** Types of file prompts. */
+ public static class Type {
+ /** Prompt for a single file. */
+ public static final int SINGLE = 1;
+
+ /** Prompt for multiple files. */
+ public static final int MULTIPLE = 2;
+
+ protected Type() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Capture.NONE, Capture.ANY, Capture.USER, Capture.ENVIRONMENT})
+ public @interface CaptureType {}
+
+ /** Possible capture attribute values. */
+ public static class Capture {
+ // These values should match the corresponding values in nsIFilePicker.idl
+ /** No capture attribute has been supplied by content. */
+ public static final int NONE = 0;
+
+ /** The capture attribute was supplied with a missing or invalid value. */
+ public static final int ANY = 1;
+
+ /** The "user" capture attribute has been supplied by content. */
+ public static final int USER = 2;
+
+ /** The "environment" capture attribute has been supplied by content. */
+ public static final int ENVIRONMENT = 3;
+
+ protected Capture() {}
+ }
+
+ /** One of {@link Type} indicating the prompt type. */
+ public final @FileType int type;
+
+ /**
+ * An array of Strings giving the MIME types specified by the "accept" attribute, if any are
+ * specified.
+ */
+ public final @Nullable String[] mimeTypes;
+
+ /** One of {@link Capture} indicating the capture attribute supplied by content. */
+ public final @CaptureType int capture;
+
+ protected FilePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @FileType final int type,
+ @CaptureType final int capture,
+ @Nullable final String[] mimeTypes,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.type = type;
+ this.capture = capture;
+ this.mimeTypes = mimeTypes;
+ }
+
+ /**
+ * Confirms the prompt and passes the file URI back to content.
+ *
+ * @param context An Application context for parsing URIs.
+ * @param uri The URI of the file chosen by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final Context context, @NonNull final Uri uri) {
+ return confirm(context, new Uri[] {uri});
+ }
+
+ /**
+ * Confirms the prompt and passes the file URIs back to content.
+ *
+ * @param context An Application context for parsing URIs.
+ * @param uris The URIs of the files chosen by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final Context context, @NonNull final Uri[] uris) {
+ if (Type.SINGLE == type && (uris == null || uris.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+
+ final String[] paths = new String[uris != null ? uris.length : 0];
+ for (int i = 0; i < paths.length; i++) {
+ paths[i] = getFile(context, uris[i]);
+ if (paths[i] == null) {
+ Log.e(LOGTAG, "Only file URIs are supported: " + uris[i]);
+ }
+ }
+ ensureResult().putStringArray("files", paths);
+
+ return super.confirm();
+ }
+
+ private static String getFile(final @NonNull Context context, final @NonNull Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+ if ("file".equals(uri.getScheme())) {
+ return uri.getPath();
+ }
+ final ContentResolver cr = context.getContentResolver();
+ final Cursor cur =
+ cr.query(
+ uri,
+ new String[] {"_data"}, /* selection */
+ null,
+ /* args */ null, /* sort */
+ null);
+ if (cur == null) {
+ return null;
+ }
+ try {
+ final int idx = cur.getColumnIndex("_data");
+ if (idx < 0 || !cur.moveToFirst()) {
+ return null;
+ }
+ do {
+ try {
+ final String path = cur.getString(idx);
+ if (path != null && !path.isEmpty()) {
+ return path;
+ }
+ } catch (final Exception e) {
+ }
+ } while (cur.moveToNext());
+ } finally {
+ cur.close();
+ }
+ return null;
+ }
+ }
+
+ /** PopupPrompt contains the information necessary to represent a popup blocking request. */
+ class PopupPrompt extends BasePrompt {
+ /** The target URI for the popup; may be null. */
+ public final @Nullable String targetUri;
+
+ protected PopupPrompt(
+ @NonNull final String id,
+ @Nullable final String targetUri,
+ @NonNull final Observer observer) {
+ super(id, null, observer);
+ this.targetUri = targetUri;
+ }
+
+ /**
+ * Confirms the prompt and either allows or blocks the popup.
+ *
+ * @param response An {@link AllowOrDeny} specifying whether to allow or deny the popup.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final AllowOrDeny response) {
+ final boolean res = AllowOrDeny.ALLOW == response;
+ ensureResult().putBoolean("response", res);
+ return super.confirm();
+ }
+ }
+
+ /** SharePrompt contains the information necessary to represent a (v1) WebShare request. */
+ class SharePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Result.SUCCESS, Result.FAILURE, Result.ABORT})
+ public @interface ShareResult {}
+
+ /** Possible results to a {@link SharePrompt}. */
+ public static class Result {
+ /** The user shared with another app successfully. */
+ public static final int SUCCESS = 0;
+
+ /** The user attempted to share with another app, but it failed. */
+ public static final int FAILURE = 1;
+
+ /** The user aborted the share. */
+ public static final int ABORT = 2;
+
+ protected Result() {}
+ }
+
+ /** The text for the share request. */
+ public final @Nullable String text;
+
+ /** The uri for the share request. */
+ public final @Nullable String uri;
+
+ protected SharePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String text,
+ @Nullable final String uri,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.text = text;
+ this.uri = uri;
+ }
+
+ /**
+ * Confirms the prompt and either blocks or allows the share request.
+ *
+ * @param response One of {@link Result} specifying the outcome of the share attempt.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@ShareResult final int response) {
+ ensureResult().putInt("response", response);
+ return super.confirm();
+ }
+
+ /**
+ * Dismisses the prompt and returns {@link Result#ABORT} to web content.
+ *
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ ensureResult().putInt("response", Result.ABORT);
+ return super.dismiss();
+ }
+ }
+
+ /** Request containing information required to resolve Autocomplete prompt requests. */
+ class AutocompleteRequest<T extends Autocomplete.Option<?>> extends BasePrompt {
+ /**
+ * The Autocomplete options for this request. This can contain a single or multiple entries.
+ */
+ public final @NonNull T[] options;
+
+ protected AutocompleteRequest(
+ final @NonNull String id, final @NonNull T[] options, final Observer observer) {
+ super(id, null, observer);
+ this.options = options;
+ }
+
+ /**
+ * Confirm the request by responding with a selection. See the PromptDelegate callbacks for
+ * specifics.
+ *
+ * @param selection The {@link Autocomplete.Option} used to confirm the request.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @NonNull Autocomplete.Option<?> selection) {
+ ensureResult().putBundle("selection", selection.toBundle());
+ return super.confirm();
+ }
+
+ /**
+ * Dismiss the request. See the PromptDelegate callbacks for specifics.
+ *
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ return super.dismiss();
+ }
+ }
+
+ // Delegate functions.
+ /**
+ * Display an alert prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link AlertPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAlertPrompt(
+ @NonNull final GeckoSession session, @NonNull final AlertPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a onbeforeunload prompt.
+ *
+ * <p>See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
+ * See {@link BeforeUnloadPrompt}
+ *
+ * @param session GeckoSession that triggered the prompt
+ * @param prompt the {@link BeforeUnloadPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to {@link AllowOrDeny#ALLOW} if the page is allowed
+ * to continue with the navigation or {@link AllowOrDeny#DENY} otherwise.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onBeforeUnloadPrompt(
+ @NonNull final GeckoSession session, @NonNull final BeforeUnloadPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a POST resubmission confirmation prompt.
+ *
+ * <p>This prompt will trigger whenever refreshing or navigating to a page needs resubmitting
+ * POST data that has been submitted already.
+ *
+ * @param session GeckoSession that triggered the prompt
+ * @param prompt the {@link RepostConfirmPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to {@link AllowOrDeny#ALLOW} if the page is allowed
+ * to continue with the navigation and resubmit the POST data or {@link AllowOrDeny#DENY}
+ * otherwise.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onRepostConfirmPrompt(
+ @NonNull final GeckoSession session, @NonNull final RepostConfirmPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a button prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ButtonPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onButtonPrompt(
+ @NonNull final GeckoSession session, @NonNull final ButtonPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a text prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link TextPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onTextPrompt(
+ @NonNull final GeckoSession session, @NonNull final TextPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display an authorization prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link AuthPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAuthPrompt(
+ @NonNull final GeckoSession session, @NonNull final AuthPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a list/menu prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ChoicePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onChoicePrompt(
+ @NonNull final GeckoSession session, @NonNull final ChoicePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a color prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ColorPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onColorPrompt(
+ @NonNull final GeckoSession session, @NonNull final ColorPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a date/time prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link DateTimePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onDateTimePrompt(
+ @NonNull final GeckoSession session, @NonNull final DateTimePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a file prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link FilePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onFilePrompt(
+ @NonNull final GeckoSession session, @NonNull final FilePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a popup request prompt; this occurs when content attempts to open a new window in a
+ * way that doesn't appear to be the result of user input.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link PopupPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onPopupPrompt(
+ @NonNull final GeckoSession session, @NonNull final PopupPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a share request prompt; this occurs when content attempts to use the WebShare API.
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link SharePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onSharePrompt(
+ @NonNull final GeckoSession session, @NonNull final SharePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Handle a login save prompt request. This is triggered by the user entering new or modified
+ * login credentials into a login form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onLoginSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created login entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onLoginSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.LoginSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a address save prompt request. This is triggered by the user entering new or modified
+ * address credentials into a address form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onAddressSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created address entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAddressSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.AddressSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a credit card save prompt request. This is triggered by the user entering new or
+ * modified credit card credentials into a form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onCreditCardSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created credit card entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onCreditCardSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.CreditCardSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a login selection prompt request. This is triggered by the user focusing on a login
+ * username field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * login forms with the given selection details. The confirmed selection may be an entry out
+ * of the request's options, a modified option, or a freshly created login entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onLoginSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.LoginSelectOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle an Identity Credential Provider selection prompt request. This is triggered by the
+ * user focusing on selecting a provider for authenticating.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param prompt The {@link ProviderSelectorPrompt} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onSelectIdentityCredentialProvider(
+ @NonNull final GeckoSession session, @NonNull final ProviderSelectorPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Handle an Identity Credential Account selection prompt request. This is triggered by the user
+ * focusing on selecting a provider for authenticating.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param prompt The {@link ProviderSelectorPrompt} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onSelectIdentityCredentialAccount(
+ @NonNull final GeckoSession session, @NonNull final AccountSelectorPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Handle an Identity Credential privacy policy prompt request.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param prompt The {@link PrivacyPolicyPrompt} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onShowPrivacyPolicyIdentityCredential(
+ @NonNull final GeckoSession session, @NonNull final PrivacyPolicyPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Handle a credit card selection prompt request. This is triggered by the user focusing on a
+ * credit card input field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * credit card forms with the given selection details. The confirmed selection may be an
+ * entry out of the request's options, a modified option, or a freshly created credit card
+ * entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onCreditCardSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.CreditCardSelectOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a address selection prompt request. This is triggered by the user focusing on a
+ * address field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * address forms with the given selection details. The confirmed selection may be an entry
+ * out of the request's options, a modified option, or a freshly created address entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAddressSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.AddressSelectOption> request) {
+ return null;
+ }
+ }
+
+ /** GeckoSession applications implement this interface to handle content scroll events. */
+ public interface ScrollDelegate {
+ /**
+ * The scroll position of the content has changed.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param scrollX The new horizontal scroll position in pixels.
+ * @param scrollY The new vertical scroll position in pixels.
+ */
+ @UiThread
+ default void onScrollChanged(
+ @NonNull final GeckoSession session, final int scrollX, final int scrollY) {}
+ }
+
+ /**
+ * Get the PanZoomController instance for this session.
+ *
+ * @return PanZoomController instance.
+ */
+ @UiThread
+ public @NonNull PanZoomController getPanZoomController() {
+ ThreadUtils.assertOnUiThread();
+
+ return mPanZoomController;
+ }
+
+ /**
+ * Get the OverscrollEdgeEffect instance for this session.
+ *
+ * @return OverscrollEdgeEffect instance.
+ */
+ @UiThread
+ public @NonNull OverscrollEdgeEffect getOverscrollEdgeEffect() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mOverscroll == null) {
+ mOverscroll = new OverscrollEdgeEffect();
+ }
+ return mOverscroll;
+ }
+
+ /**
+ * Get the CompositorController instance for this session.
+ *
+ * @return CompositorController instance.
+ */
+ @UiThread
+ public @NonNull CompositorController getCompositorController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mController == null) {
+ mController = new CompositorController(this);
+ if (mCompositorReady) {
+ mController.onCompositorReady();
+ }
+ }
+ return mController;
+ }
+
+ /**
+ * Get a matrix for transforming from client coordinates to surface coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToScreenMatrix(Matrix)
+ * @see #getPageToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ public void getClientToSurfaceMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ matrix.setScale(mViewportZoom, mViewportZoom);
+ if (mClientTop != mTop) {
+ matrix.postTranslate(0, mClientTop - mTop);
+ }
+ }
+
+ /**
+ * Get a matrix for transforming from client coordinates to screen coordinates. The client
+ * coordinates are in CSS pixels and are relative to the viewport origin; their relation to screen
+ * coordinates does not depend on the current scroll position.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToSurfaceMatrix(Matrix)
+ * @see #getPageToScreenMatrix(Matrix)
+ */
+ @UiThread
+ public void getClientToScreenMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getClientToSurfaceMatrix(matrix);
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get a matrix for transforming from page coordinates to screen coordinates. The page coordinates
+ * are in CSS pixels and are relative to the page origin; their relation to screen coordinates
+ * depends on the current scroll position of the outermost frame.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getPageToSurfaceMatrix(Matrix)
+ * @see #getClientToScreenMatrix(Matrix)
+ */
+ @UiThread
+ public void getPageToScreenMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getPageToSurfaceMatrix(matrix);
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get a matrix for transforming from page coordinates to surface coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getPageToScreenMatrix(Matrix)
+ * @see #getClientToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ public void getPageToSurfaceMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getClientToSurfaceMatrix(matrix);
+ matrix.postTranslate(-mViewportLeft, -mViewportTop);
+ }
+
+ /**
+ * Get a matrix for transforming from layout device client coordinates to screen coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToScreenMatrix(Matrix)
+ * @see #getPageToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ /* package */ void getClientToScreenOffsetMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get a matrix for transforming from screen coordinates to Android's current window coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see <a
+ * href="https://developer.android.com/guide/topics/large-screens/multi-window-support#window_metrics">...</a>
+ */
+ @UiThread
+ /* package */ void getScreenToWindowManagerOffsetMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ final WindowManager wm =
+ (WindowManager)
+ GeckoAppShell.getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ final Rect currentWindowRect = wm.getCurrentWindowMetrics().getBounds();
+ matrix.postTranslate(-currentWindowRect.left, -currentWindowRect.top);
+ return;
+ }
+
+ // TODO(m_kato): Bug 1678531
+ // How to get window coordinate on Android 7-10 that supports split window?
+ }
+
+ /**
+ * Get the bounds of the client area in client coordinates. The returned top-left coordinates are
+ * always (0, 0). Use the matrix from {@link #getClientToSurfaceMatrix(Matrix)} or {@link
+ * #getClientToScreenMatrix(Matrix)} to map these bounds to surface or screen coordinates,
+ * respectively.
+ *
+ * @param rect RectF to be replaced by the client bounds in client coordinates.
+ * @see #getSurfaceBounds(Rect)
+ */
+ @UiThread
+ public void getClientBounds(@NonNull final RectF rect) {
+ ThreadUtils.assertOnUiThread();
+
+ rect.set(0.0f, 0.0f, (float) mWidth / mViewportZoom, (float) mClientHeight / mViewportZoom);
+ }
+
+ /**
+ * Get the bounds of the client area in surface coordinates. This is equivalent to mapping the
+ * bounds returned by #getClientBounds(RectF) with the matrix returned by
+ * #getClientToSurfaceMatrix(Matrix).
+ *
+ * @param rect Rect to be replaced by the client bounds in surface coordinates.
+ */
+ @UiThread
+ public void getSurfaceBounds(@NonNull final Rect rect) {
+ ThreadUtils.assertOnUiThread();
+
+ rect.set(0, mClientTop - mTop, mWidth, mHeight);
+ }
+
+ /**
+ * GeckoSession applications implement this interface to handle requests for permissions from
+ * content, such as geolocation and notifications. For each permission, usually two requests are
+ * generated: one request for the Android app permission through requestAppPermissions, which is
+ * typically handled by a system permission dialog; and another request for the content permission
+ * (e.g. through requestContentPermission), which is typically handled by an app-specific
+ * permission dialog.
+ *
+ * <p>When denying an Android app permission, the response is not stored by GeckoView. It is the
+ * responsibility of the consumer to store the response state and therefore prevent further
+ * requests from being presented to the user.
+ */
+ public interface PermissionDelegate {
+ /**
+ * Permission for using the geolocation API. See:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Geolocation
+ */
+ int PERMISSION_GEOLOCATION = 0;
+
+ /**
+ * Permission for using the notifications API. See:
+ * https://developer.mozilla.org/en-US/docs/Web/API/notification
+ */
+ int PERMISSION_DESKTOP_NOTIFICATION = 1;
+
+ /**
+ * Permission for using the storage API. See:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Storage_API
+ */
+ int PERMISSION_PERSISTENT_STORAGE = 2;
+
+ /** Permission for using the WebXR API. See: https://www.w3.org/TR/webxr */
+ int PERMISSION_XR = 3;
+
+ /** Permission for allowing autoplay of inaudible (silent) video. */
+ int PERMISSION_AUTOPLAY_INAUDIBLE = 4;
+
+ /** Permission for allowing autoplay of audible video. */
+ int PERMISSION_AUTOPLAY_AUDIBLE = 5;
+
+ /** Permission for accessing system media keys used to decode DRM media. */
+ int PERMISSION_MEDIA_KEY_SYSTEM_ACCESS = 6;
+
+ /**
+ * Permission for trackers to operate on the page -- disables all tracking protection features
+ * for a given site.
+ */
+ int PERMISSION_TRACKING = 7;
+
+ /**
+ * Permission for third party frames to access first party cookies. May be granted heuristically
+ * in some cases.
+ */
+ int PERMISSION_STORAGE_ACCESS = 8;
+
+ /**
+ * Represents a content permission -- including the type of permission, the present value of the
+ * permission, the URL the permission pertains to, and other information.
+ */
+ class ContentPermission {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({VALUE_PROMPT, VALUE_DENY, VALUE_ALLOW})
+ public @interface Value {}
+
+ /** The corresponding permission is currently set to default/prompt behavior. */
+ public static final int VALUE_PROMPT = 3;
+
+ /** The corresponding permission is currently set to deny. */
+ public static final int VALUE_DENY = 2;
+
+ /** The corresponding permission is currently set to allow. */
+ public static final int VALUE_ALLOW = 1;
+
+ /** The URI associated with this content permission. */
+ public final @NonNull String uri;
+
+ /**
+ * The third party origin associated with the request; currently only used for storage access
+ * permission.
+ */
+ public final @Nullable String thirdPartyOrigin;
+
+ /**
+ * A boolean indicating whether this content permission is associated with private browsing.
+ */
+ public final boolean privateMode;
+
+ /** The type of this permission; one of {@link #PERMISSION_GEOLOCATION PERMISSION_*}. */
+ public final int permission;
+
+ /** The value of the permission; one of {@link #VALUE_PROMPT VALUE_}. */
+ public final @Value int value;
+
+ /**
+ * The context ID associated with the permission if any.
+ *
+ * @see GeckoSessionSettings.Builder#contextId
+ */
+ public final @Nullable String contextId;
+
+ private final String mPrincipal;
+
+ protected ContentPermission() {
+ this.uri = "";
+ this.thirdPartyOrigin = null;
+ this.privateMode = false;
+ this.permission = PERMISSION_GEOLOCATION;
+ this.value = VALUE_ALLOW;
+ this.mPrincipal = "";
+ this.contextId = null;
+ }
+
+ private ContentPermission(final @NonNull GeckoBundle bundle) {
+ this.uri = bundle.getString("uri");
+ this.mPrincipal = bundle.getString("principal");
+ this.privateMode = bundle.getBoolean("privateMode");
+
+ final String permission = bundle.getString("perm");
+ this.permission = convertType(permission);
+ if (permission.startsWith("3rdPartyStorage^")) {
+ // Storage access permissions are stored with the key "3rdPartyStorage^https://foo.com"
+ // where the third party origin is "https://foo.com".
+ this.thirdPartyOrigin = permission.substring(16);
+ } else if (permission.startsWith("3rdPartyFrameStorage^")) {
+ // Storage access permissions may also be stored with the key
+ // "3rdPartyFrameStorage^https://foo.com" where the third party
+ // origin is "https://foo.com".
+ this.thirdPartyOrigin = permission.substring(21);
+ } else {
+ this.thirdPartyOrigin = bundle.getString("thirdPartyOrigin");
+ }
+
+ this.value = bundle.getInt("value");
+ this.contextId =
+ StorageController.retrieveUnsafeSessionContextId(bundle.getString("contextId"));
+ }
+
+ /**
+ * Converts a JSONObject to a ContentPermission -- should only be used on the output of {@link
+ * #toJson()}.
+ *
+ * @param perm A JSONObject representing a ContentPermission, output by {@link #toJson()}.
+ * @return The corresponding ContentPermission.
+ */
+ @AnyThread
+ public static @Nullable ContentPermission fromJson(final @NonNull JSONObject perm) {
+ ContentPermission res = null;
+ try {
+ res = new ContentPermission(GeckoBundle.fromJSONObject(perm));
+ } catch (final JSONException e) {
+ Log.w(LOGTAG, "Failed to create ContentPermission; invalid JSONObject.", e);
+ }
+ return res;
+ }
+
+ /**
+ * Converts a ContentPermission to a JSONObject that can be converted back to a
+ * ContentPermission by {@link #fromJson(JSONObject)}.
+ *
+ * @return A JSONObject representing this ContentPermission. Modifying any of the fields may
+ * result in undefined behavior when converted back to a ContentPermission and used.
+ * @throws JSONException if the conversion fails for any reason.
+ */
+ @AnyThread
+ public @NonNull JSONObject toJson() throws JSONException {
+ return toGeckoBundle().toJSONObject();
+ }
+
+ private static int convertType(final @NonNull String type) {
+ if ("geolocation".equals(type)) {
+ return PERMISSION_GEOLOCATION;
+ } else if ("desktop-notification".equals(type)) {
+ return PERMISSION_DESKTOP_NOTIFICATION;
+ } else if ("persistent-storage".equals(type)) {
+ return PERMISSION_PERSISTENT_STORAGE;
+ } else if ("xr".equals(type)) {
+ return PERMISSION_XR;
+ } else if ("autoplay-media-inaudible".equals(type)) {
+ return PERMISSION_AUTOPLAY_INAUDIBLE;
+ } else if ("autoplay-media-audible".equals(type)) {
+ return PERMISSION_AUTOPLAY_AUDIBLE;
+ } else if ("media-key-system-access".equals(type)) {
+ return PERMISSION_MEDIA_KEY_SYSTEM_ACCESS;
+ } else if ("trackingprotection".equals(type) || "trackingprotection-pb".equals(type)) {
+ return PERMISSION_TRACKING;
+ } else if ("storage-access".equals(type)
+ || type.startsWith("3rdPartyStorage^")
+ || type.startsWith("3rdPartyFrameStorage^")) {
+ return PERMISSION_STORAGE_ACCESS;
+ } else {
+ return -1;
+ }
+ }
+
+ // This also gets used in StorageController, so it's package rather than private.
+ /* package */ static String convertType(final int type, final boolean privateMode) {
+ switch (type) {
+ case PERMISSION_GEOLOCATION:
+ return "geolocation";
+ case PERMISSION_DESKTOP_NOTIFICATION:
+ return "desktop-notification";
+ case PERMISSION_PERSISTENT_STORAGE:
+ return "persistent-storage";
+ case PERMISSION_XR:
+ return "xr";
+ case PERMISSION_AUTOPLAY_INAUDIBLE:
+ return "autoplay-media-inaudible";
+ case PERMISSION_AUTOPLAY_AUDIBLE:
+ return "autoplay-media-audible";
+ case PERMISSION_MEDIA_KEY_SYSTEM_ACCESS:
+ return "media-key-system-access";
+ case PERMISSION_TRACKING:
+ return privateMode ? "trackingprotection-pb" : "trackingprotection";
+ case PERMISSION_STORAGE_ACCESS:
+ return "storage-access";
+ default:
+ return "";
+ }
+ }
+
+ /* package */ static @NonNull ArrayList<ContentPermission> fromBundleArray(
+ final @NonNull GeckoBundle[] bundleArray) {
+ final ArrayList<ContentPermission> res = new ArrayList<ContentPermission>();
+ if (bundleArray == null) {
+ return res;
+ }
+
+ for (final GeckoBundle bundle : bundleArray) {
+ final ContentPermission temp = new ContentPermission(bundle);
+ if (temp.permission == -1 || temp.value < 1 || temp.value > 3) {
+ continue;
+ }
+ res.add(temp);
+ }
+ return res;
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toGeckoBundle() {
+ final GeckoBundle res = new GeckoBundle(7);
+ res.putString("uri", uri);
+ res.putString("thirdPartyOrigin", thirdPartyOrigin);
+ res.putString("principal", mPrincipal);
+ res.putBoolean("privateMode", privateMode);
+ res.putString("perm", convertType(permission, privateMode));
+ res.putInt("value", value);
+ res.putString("contextId", contextId);
+ return res;
+ }
+ }
+
+ /** Callback interface for notifying the result of a permission request. */
+ interface Callback {
+ /**
+ * Called by the implementation after permissions are granted; the implementation must call
+ * either grant() or reject() for every request.
+ */
+ @UiThread
+ default void grant() {}
+
+ /**
+ * Called by the implementation when permissions are not granted; the implementation must call
+ * either grant() or reject() for every request.
+ */
+ @UiThread
+ default void reject() {}
+ }
+
+ /**
+ * Request Android app permissions.
+ *
+ * @param session GeckoSession instance requesting the permissions.
+ * @param permissions List of permissions to request; possible values are,
+ * android.Manifest.permission.ACCESS_COARSE_LOCATION
+ * android.Manifest.permission.ACCESS_FINE_LOCATION android.Manifest.permission.CAMERA
+ * android.Manifest.permission.RECORD_AUDIO
+ * @param callback Callback interface.
+ */
+ @UiThread
+ default void onAndroidPermissionsRequest(
+ @NonNull final GeckoSession session,
+ @Nullable final String[] permissions,
+ @NonNull final Callback callback) {
+ callback.reject();
+ }
+
+ /**
+ * Request content permission.
+ *
+ * <p>Note, that in the case of PERMISSION_PERSISTENT_STORAGE, once permission has been granted
+ * for a site, it cannot be revoked. If the permission has previously been granted, it is the
+ * responsibility of the consuming app to remember the permission and prevent the prompt from
+ * being redisplayed to the user.
+ *
+ * @param session GeckoSession instance requesting the permission.
+ * @param perm An {@link ContentPermission} describing the permission being requested and its
+ * current status.
+ * @return A {@link GeckoResult} resolving to one of {@link ContentPermission#VALUE_PROMPT
+ * VALUE_*}, determining the response to the permission request and updating the permissions
+ * for this site.
+ */
+ @UiThread
+ default @Nullable GeckoResult<Integer> onContentPermissionRequest(
+ @NonNull final GeckoSession session, @NonNull ContentPermission perm) {
+ return GeckoResult.fromValue(ContentPermission.VALUE_PROMPT);
+ }
+
+ class MediaSource {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SOURCE_CAMERA, SOURCE_SCREEN,
+ SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE,
+ SOURCE_OTHER
+ })
+ public @interface Source {}
+
+ /** Constant to indicate that camera will be recorded. */
+ public static final int SOURCE_CAMERA = 0;
+
+ /** Constant to indicate that screen will be recorded. */
+ public static final int SOURCE_SCREEN = 1;
+
+ /** Constant to indicate that microphone will be recorded. */
+ public static final int SOURCE_MICROPHONE = 2;
+
+ /** Constant to indicate that device audio playback will be recorded. */
+ public static final int SOURCE_AUDIOCAPTURE = 3;
+
+ /** Constant to indicate a media source that does not fall under the other categories. */
+ public static final int SOURCE_OTHER = 4;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_VIDEO, TYPE_AUDIO})
+ public @interface Type {}
+
+ /** The media type is video. */
+ public static final int TYPE_VIDEO = 0;
+
+ /** The media type is audio. */
+ public static final int TYPE_AUDIO = 1;
+
+ /** A string giving a unique source identifier. */
+ public final @NonNull String id;
+
+ /**
+ * A string giving the name of the video source from the system (for example, "Camera 0,
+ * Facing back, Orientation 90"). May be empty.
+ */
+ public final @Nullable String name;
+
+ /**
+ * An int indicating the media source type. Possible values for a video source are:
+ * SOURCE_CAMERA, SOURCE_SCREEN, and SOURCE_OTHER. Possible values for an audio source are:
+ * SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE, and SOURCE_OTHER.
+ */
+ public final @Source int source;
+
+ /** An int giving the type of media, must be either TYPE_VIDEO or TYPE_AUDIO. */
+ public final @Type int type;
+
+ private static @Source int getSourceFromString(final String src) {
+ // The strings here should match those in MediaSourceEnum in MediaStreamTrack.webidl
+ if ("camera".equals(src)) {
+ return SOURCE_CAMERA;
+ } else if ("screen".equals(src) || "window".equals(src) || "browser".equals(src)) {
+ return SOURCE_SCREEN;
+ } else if ("microphone".equals(src)) {
+ return SOURCE_MICROPHONE;
+ } else if ("audioCapture".equals(src)) {
+ return SOURCE_AUDIOCAPTURE;
+ } else if ("other".equals(src) || "application".equals(src)) {
+ return SOURCE_OTHER;
+ } else {
+ throw new IllegalArgumentException(
+ "String: " + src + " is not a valid media source string");
+ }
+ }
+
+ private static @Type int getTypeFromString(final String type) {
+ // The strings here should match the possible types in MediaDevice::MediaDevice in
+ // MediaManager.cpp
+ if ("videoinput".equals(type)) {
+ return TYPE_VIDEO;
+ } else if ("audioinput".equals(type)) {
+ return TYPE_AUDIO;
+ } else {
+ throw new IllegalArgumentException(
+ "String: " + type + " is not a valid media type string");
+ }
+ }
+
+ /* package */ MediaSource(final GeckoBundle media) {
+ id = media.getString("id");
+ name = media.getString("name");
+ source = getSourceFromString(media.getString("mediaSource"));
+ type = getTypeFromString(media.getString("type"));
+ }
+
+ /** Empty constructor for tests. */
+ protected MediaSource() {
+ id = null;
+ name = null;
+ source = SOURCE_CAMERA;
+ type = TYPE_VIDEO;
+ }
+ }
+
+ /**
+ * Callback interface for notifying the result of a media permission request, including which
+ * media source(s) to use.
+ */
+ interface MediaCallback {
+ /**
+ * Called by the implementation after permissions are granted; the implementation must call
+ * one of grant() or reject() for every request.
+ *
+ * @param video "id" value from the bundle for the video source to use, or null when video is
+ * not requested.
+ * @param audio "id" value from the bundle for the audio source to use, or null when audio is
+ * not requested.
+ */
+ @UiThread
+ default void grant(final @Nullable String video, final @Nullable String audio) {}
+
+ /**
+ * Called by the implementation after permissions are granted; the implementation must call
+ * one of grant() or reject() for every request.
+ *
+ * @param video MediaSource for the video source to use (must be an original MediaSource
+ * object that was passed to the implementation); or null when video is not requested.
+ * @param audio MediaSource for the audio source to use (must be an original MediaSource
+ * object that was passed to the implementation); or null when audio is not requested.
+ */
+ @UiThread
+ default void grant(final @Nullable MediaSource video, final @Nullable MediaSource audio) {}
+
+ /**
+ * Called by the implementation when permissions are not granted; the implementation must call
+ * one of grant() or reject() for every request.
+ */
+ @UiThread
+ default void reject() {}
+ }
+
+ /**
+ * Request content media permissions, including request for which video and/or audio source to
+ * use.
+ *
+ * <p>Media permissions will still be requested if the associated device permissions have been
+ * denied if there are video or audio sources in that category that can still be accessed. It is
+ * the responsibility of consumers to ensure that media permission requests are not displayed in
+ * this case.
+ *
+ * @param session GeckoSession instance requesting the permission.
+ * @param uri The URI of the content requesting the permission.
+ * @param video List of video sources, or null if not requesting video.
+ * @param audio List of audio sources, or null if not requesting audio.
+ * @param callback Callback interface.
+ */
+ @UiThread
+ default void onMediaPermissionRequest(
+ @NonNull final GeckoSession session,
+ @NonNull final String uri,
+ @Nullable final MediaSource[] video,
+ @Nullable final MediaSource[] audio,
+ @NonNull final MediaCallback callback) {
+ callback.reject();
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ PermissionDelegate.PERMISSION_GEOLOCATION,
+ PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION,
+ PermissionDelegate.PERMISSION_PERSISTENT_STORAGE,
+ PermissionDelegate.PERMISSION_XR,
+ PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE,
+ PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE,
+ PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS,
+ PermissionDelegate.PERMISSION_TRACKING,
+ PermissionDelegate.PERMISSION_STORAGE_ACCESS
+ })
+ public @interface Permission {}
+
+ /**
+ * Interface that SessionTextInput uses for performing operations such as opening and closing the
+ * software keyboard. If the delegate is not set, these operations are forwarded to the system
+ * {@link android.view.inputmethod.InputMethodManager} automatically.
+ */
+ public interface TextInputDelegate {
+ /** Restarting input due to an input field gaining focus. */
+ int RESTART_REASON_FOCUS = 0;
+
+ /** Restarting input due to an input field losing focus. */
+ int RESTART_REASON_BLUR = 1;
+
+ /**
+ * Restarting input due to the content of the input field changing. For example, the input field
+ * type may have changed, or the current composition may have been committed outside of the
+ * input method.
+ */
+ int RESTART_REASON_CONTENT_CHANGE = 2;
+
+ /**
+ * Reset the input method, and discard any existing states such as the current composition or
+ * current autocompletion. Because the current focused editor may have changed, as part of the
+ * reset, a custom input method would normally call {@link
+ * SessionTextInput#onCreateInputConnection} to update its knowledge of the focused editor. Note
+ * that {@code restartInput} should be used to detect changes in focus, rather than {@link
+ * #showSoftInput} or {@link #hideSoftInput}, because focus changes are not always accompanied
+ * by requests to show or hide the soft input. This method is always called, even in viewless
+ * mode.
+ *
+ * @param session Session instance.
+ * @param reason Reason for the reset.
+ */
+ @UiThread
+ default void restartInput(
+ @NonNull final GeckoSession session, @RestartReason final int reason) {}
+
+ /**
+ * Display the soft input. May be called consecutively, even if the soft input is already shown.
+ * This method is always called, even in viewless mode.
+ *
+ * @param session Session instance.
+ * @see #hideSoftInput
+ */
+ @UiThread
+ default void showSoftInput(@NonNull final GeckoSession session) {}
+
+ /**
+ * Hide the soft input. May be called consecutively, even if the soft input is already hidden.
+ * This method is always called, even in viewless mode.
+ *
+ * @param session Session instance.
+ * @see #showSoftInput
+ */
+ @UiThread
+ default void hideSoftInput(@NonNull final GeckoSession session) {}
+
+ /**
+ * Update the soft input on the current selection. This method is <i>not</i> called in viewless
+ * mode.
+ *
+ * @param session Session instance.
+ * @param selStart Start offset of the selection.
+ * @param selEnd End offset of the selection.
+ * @param compositionStart Composition start offset, or -1 if there is no composition.
+ * @param compositionEnd Composition end offset, or -1 if there is no composition.
+ */
+ @UiThread
+ default void updateSelection(
+ @NonNull final GeckoSession session,
+ final int selStart,
+ final int selEnd,
+ final int compositionStart,
+ final int compositionEnd) {}
+
+ /**
+ * Update the soft input on the current extracted text, as requested through {@link
+ * android.view.inputmethod.InputConnection#getExtractedText}. Consequently, this method is
+ * <i>not</i> called in viewless mode.
+ *
+ * @param session Session instance.
+ * @param request The extract text request.
+ * @param text The extracted text.
+ */
+ @UiThread
+ default void updateExtractedText(
+ @NonNull final GeckoSession session,
+ @NonNull final ExtractedTextRequest request,
+ @NonNull final ExtractedText text) {}
+
+ /**
+ * Update the cursor-anchor information as requested through {@link
+ * android.view.inputmethod.InputConnection#requestCursorUpdates}. Consequently, this method is
+ * <i>not</i> called in viewless mode.
+ *
+ * @param session Session instance.
+ * @param info Cursor-anchor information.
+ */
+ @UiThread
+ default void updateCursorAnchorInfo(
+ @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ TextInputDelegate.RESTART_REASON_FOCUS,
+ TextInputDelegate.RESTART_REASON_BLUR,
+ TextInputDelegate.RESTART_REASON_CONTENT_CHANGE
+ })
+ public @interface RestartReason {}
+
+ /* package */ void onSurfaceChanged(final @NonNull SurfaceInfo surfaceInfo) {
+ ThreadUtils.assertOnUiThread();
+
+ mWidth = surfaceInfo.mWidth;
+ mHeight = surfaceInfo.mHeight;
+ mNewSurfaceProvider = surfaceInfo.mNewSurfaceProvider;
+
+ if (mCompositorReady) {
+ mCompositor.syncResumeResizeCompositor(
+ surfaceInfo.mLeft,
+ surfaceInfo.mTop,
+ surfaceInfo.mWidth,
+ surfaceInfo.mHeight,
+ surfaceInfo.mSurface,
+ surfaceInfo.mSurfaceControl);
+ onWindowBoundsChanged();
+ return;
+ }
+
+ // We have a valid surface but we're not attached or the compositor
+ // is not ready; save the surface for later when we're ready.
+ mSurfaceInfo = surfaceInfo;
+
+ // Adjust bounds as the last step.
+ onWindowBoundsChanged();
+ }
+
+ /* package */ void onSurfaceDestroyed() {
+ ThreadUtils.assertOnUiThread();
+
+ mNewSurfaceProvider = null;
+
+ if (mCompositorReady) {
+ mCompositor.syncPauseCompositor();
+ return;
+ }
+
+ // While the surface was valid, we never became attached or the
+ // compositor never became ready; clear the saved surface.
+ mSurfaceInfo = null;
+ }
+
+ /* package */ void onScreenOriginChanged(final int left, final int top) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mLeft == left && mTop == top) {
+ return;
+ }
+
+ mLeft = left;
+ mTop = top;
+ onWindowBoundsChanged();
+ }
+
+ /* package */ void setDynamicToolbarMaxHeight(final int height) {
+ if (mDynamicToolbarMaxHeight == height) {
+ return;
+ }
+
+ if (mHeight != 0 && height != 0 && mHeight < height) {
+ Log.w(
+ LOGTAG,
+ new AssertionError(
+ "The maximum height of the dynamic toolbar ("
+ + height
+ + ") should be smaller than GeckoView height ("
+ + mHeight
+ + ")"));
+ }
+
+ mDynamicToolbarMaxHeight = height;
+
+ if (mAttachedCompositor) {
+ mCompositor.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight);
+ }
+ }
+
+ /* package */ void setFixedBottomOffset(final int offset) {
+ if (mFixedBottomOffset == offset) {
+ return;
+ }
+
+ mFixedBottomOffset = offset;
+
+ if (mCompositorReady) {
+ mCompositor.setFixedBottomOffset(mFixedBottomOffset);
+ }
+ }
+
+ /* package */ void onCompositorAttached() {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mAttachedCompositor = true;
+ mCompositor.attachNPZC(mPanZoomController.mNative);
+
+ if (mSurfaceInfo != null) {
+ // If we have a valid surface, create the compositor now that we're attached.
+ // Leave mSurface alone because we'll need it later for onCompositorReady.
+ onSurfaceChanged(mSurfaceInfo);
+ }
+
+ mCompositor.sendToolbarAnimatorMessage(IS_COMPOSITOR_CONTROLLER_OPEN);
+ mCompositor.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight);
+ }
+
+ /* package */ void onCompositorDetached() {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (mController != null) {
+ mController.onCompositorDetached();
+ }
+
+ mAttachedCompositor = false;
+ mCompositorReady = false;
+ }
+
+ /* package */ void handleCompositorMessage(final int message) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ switch (message) {
+ case COMPOSITOR_CONTROLLER_OPEN:
+ {
+ if (isCompositorReady()) {
+ return;
+ }
+
+ // Delay calling onCompositorReady to avoid deadlock due
+ // to synchronous call to the compositor.
+ ThreadUtils.postToUiThread(this::onCompositorReady);
+ break;
+ }
+
+ case FIRST_PAINT:
+ {
+ if (mController != null) {
+ mController.onFirstPaint();
+ }
+ final ContentDelegate delegate = mContentHandler.getDelegate();
+ if (delegate != null) {
+ delegate.onFirstComposite(this);
+ }
+ break;
+ }
+
+ case LAYERS_UPDATED:
+ {
+ if (mController != null) {
+ mController.notifyDrawCallbacks();
+ }
+ break;
+ }
+
+ default:
+ {
+ Log.w(LOGTAG, "Unexpected message: " + message);
+ break;
+ }
+ }
+ }
+
+ /* package */ boolean isCompositorReady() {
+ return mCompositorReady;
+ }
+
+ /* package */ void onCompositorReady() {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (!mAttachedCompositor) {
+ return;
+ }
+
+ mCompositorReady = true;
+
+ if (mController != null) {
+ mController.onCompositorReady();
+ }
+
+ if (mSurfaceInfo != null) {
+ // If we have a valid surface, resume the
+ // compositor now that the compositor is ready.
+ onSurfaceChanged(mSurfaceInfo);
+ mSurfaceInfo = null;
+ }
+
+ if (mFixedBottomOffset != 0) {
+ mCompositor.setFixedBottomOffset(mFixedBottomOffset);
+ }
+ }
+
+ /* package */ void updateOverscrollVelocity(final float x, final float y) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (mOverscroll == null) {
+ return;
+ }
+
+ // Multiply the velocity by 1000 to match what was done in JPZ.
+ mOverscroll.setVelocity(x * 1000.0f, OverscrollEdgeEffect.AXIS_X);
+ mOverscroll.setVelocity(y * 1000.0f, OverscrollEdgeEffect.AXIS_Y);
+ }
+
+ /* package */ void updateOverscrollOffset(final float x, final float y) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (mOverscroll == null) {
+ return;
+ }
+
+ mOverscroll.setDistance(x, OverscrollEdgeEffect.AXIS_X);
+ mOverscroll.setDistance(y, OverscrollEdgeEffect.AXIS_Y);
+ }
+
+ /* package */ void onMetricsChanged(final float scrollX, final float scrollY, final float zoom) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mViewportLeft = scrollX;
+ mViewportTop = scrollY;
+ mViewportZoom = zoom;
+ }
+
+ /* protected */ void onWindowBoundsChanged() {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (mHeight != 0 && mDynamicToolbarMaxHeight != 0 && mHeight < mDynamicToolbarMaxHeight) {
+ Log.w(
+ LOGTAG,
+ new AssertionError(
+ "The maximum height of the dynamic toolbar ("
+ + mDynamicToolbarMaxHeight
+ + ") should be smaller than GeckoView height ("
+ + mHeight
+ + ")"));
+ }
+
+ final int toolbarHeight = 0;
+
+ mClientTop = mTop + toolbarHeight;
+ // If the view is not tall enough to even fix the toolbar we just
+ // default the client height to 0
+ mClientHeight = Math.max(mHeight - toolbarHeight, 0);
+
+ if (mAttachedCompositor) {
+ mCompositor.onBoundsChanged(mLeft, mClientTop, mWidth, mClientHeight);
+ }
+
+ if (mOverscroll != null) {
+ mOverscroll.setSize(mWidth, mClientHeight);
+ }
+ }
+
+ /* pacakge */ void onSafeAreaInsetsChanged(
+ final int top, final int right, final int bottom, final int left) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAttachedCompositor) {
+ mCompositor.onSafeAreaInsetsChanged(top, right, bottom, left);
+ }
+ }
+
+ /* package */ void setPointerIcon(
+ final int defaultCursor, final @Nullable Bitmap customCursor, final float x, final float y) {
+ ThreadUtils.assertOnUiThread();
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ return;
+ }
+
+ final PointerIcon icon;
+ if (customCursor != null) {
+ try {
+ icon = PointerIcon.create(customCursor, x, y);
+ } catch (final IllegalArgumentException e) {
+ // x/y hotspot might be invalid
+ return;
+ }
+ } else {
+ final Context context = GeckoAppShell.getApplicationContext();
+ icon = PointerIcon.getSystemIcon(context, defaultCursor);
+ }
+
+ final ContentDelegate delegate = getContentDelegate();
+ if (delegate != null) {
+ delegate.onPointerIconChange(this, icon);
+ }
+ }
+
+ /* package */ void startDragAndDrop(final Bitmap bitmap) {
+ ThreadUtils.assertOnUiThread();
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ return;
+ }
+ final View view = getTextInput().getView();
+ if (view == null) {
+ return;
+ }
+
+ GeckoDragAndDrop.startDragAndDrop(view, bitmap);
+ }
+
+ /* package */ void updateDragImage(final Bitmap bitmap) {
+ ThreadUtils.assertOnUiThread();
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ return;
+ }
+ final View view = getTextInput().getView();
+ if (view == null) {
+ return;
+ }
+
+ GeckoDragAndDrop.updateDragImage(view, bitmap);
+ }
+
+ /** GeckoSession applications implement this interface to handle media events. */
+ public interface MediaDelegate {
+
+ class RecordingDevice {
+
+ /*
+ * Default status flags for this RecordingDevice.
+ */
+ public static class Status {
+ public static final long RECORDING = 0;
+ public static final long INACTIVE = 1 << 0;
+
+ // Do not instantiate this class.
+ protected Status() {}
+ }
+
+ /*
+ * Default device types for this RecordingDevice.
+ */
+ public static class Type {
+ public static final long CAMERA = 0;
+ public static final long MICROPHONE = 1 << 0;
+
+ // Do not instantiate this class.
+ protected Type() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {Status.RECORDING, Status.INACTIVE})
+ public @interface RecordingStatus {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {Type.CAMERA, Type.MICROPHONE})
+ public @interface DeviceType {}
+
+ /**
+ * A long giving the current recording status, must be either Status.RECORDING, Status.PAUSED
+ * or Status.INACTIVE.
+ */
+ public final @RecordingStatus long status;
+
+ /**
+ * A long giving the type of the recording device, must be either Type.CAMERA or
+ * Type.MICROPHONE.
+ */
+ public final @DeviceType long type;
+
+ private static @DeviceType long getTypeFromString(final String type) {
+ if ("microphone".equals(type)) {
+ return Type.MICROPHONE;
+ } else if ("camera".equals(type)) {
+ return Type.CAMERA;
+ } else {
+ throw new IllegalArgumentException(
+ "String: " + type + " is not a valid recording device string");
+ }
+ }
+
+ private static @RecordingStatus long getStatusFromString(final String type) {
+ if ("recording".equals(type)) {
+ return Status.RECORDING;
+ } else {
+ return Status.INACTIVE;
+ }
+ }
+
+ /* package */ RecordingDevice(final GeckoBundle media) {
+ status = getStatusFromString(media.getString("status"));
+ type = getTypeFromString(media.getString("type"));
+ }
+
+ /** Empty constructor for tests. */
+ protected RecordingDevice() {
+ status = Status.INACTIVE;
+ type = Type.CAMERA;
+ }
+ }
+
+ /**
+ * A recording device has changed state. Any change to the recording state of the devices
+ * microphone or camera will call this delegate method. The argument provides details of the
+ * active recording devices.
+ *
+ * @param session The session that the event has originated from.
+ * @param devices The list of active devices and their recording state.
+ */
+ @UiThread
+ default void onRecordingStatusChanged(
+ @NonNull final GeckoSession session, @NonNull final RecordingDevice[] devices) {}
+ }
+
+ /** An interface for recording new history visits and fetching the visited status for links. */
+ public interface HistoryDelegate {
+ /** A representation of an entry in browser history. */
+ interface HistoryItem {
+ /**
+ * Get the URI of this history element.
+ *
+ * @return A String representing the URI of this history element.
+ */
+ @AnyThread
+ default @NonNull String getUri() {
+ throw new UnsupportedOperationException("HistoryItem.getUri() called on invalid object.");
+ }
+
+ /**
+ * Get the title of this history element.
+ *
+ * @return A String representing the title of this history element.
+ */
+ @AnyThread
+ default @NonNull String getTitle() {
+ throw new UnsupportedOperationException(
+ "HistoryItem.getString() called on invalid object.");
+ }
+ }
+
+ /**
+ * A representation of browser history, accessible as a `List`. The list itself and its entries
+ * are immutable; any attempt to mutate will result in an `UnsupportedOperationException`.
+ */
+ interface HistoryList extends List<HistoryItem> {
+ /**
+ * Get the current index in browser history.
+ *
+ * @return An int representing the current index in browser history.
+ */
+ @AnyThread
+ default int getCurrentIndex() {
+ throw new UnsupportedOperationException(
+ "HistoryList.getCurrentIndex() called on invalid object.");
+ }
+ }
+
+ // These flags are similar to those in `IHistory::LoadFlags`, but we use
+ // different values to decouple GeckoView from Gecko changes. These
+ // should be kept in sync with `GeckoViewHistory::GeckoViewVisitFlags`.
+
+ /** The URL was visited a top-level window. */
+ int VISIT_TOP_LEVEL = 1 << 0;
+
+ /** The URL is the target of a temporary redirect. */
+ int VISIT_REDIRECT_TEMPORARY = 1 << 1;
+
+ /** The URL is the target of a permanent redirect. */
+ int VISIT_REDIRECT_PERMANENT = 1 << 2;
+
+ /** The URL is temporarily redirected to another URL. */
+ int VISIT_REDIRECT_SOURCE = 1 << 3;
+
+ /** The URL is permanently redirected to another URL. */
+ int VISIT_REDIRECT_SOURCE_PERMANENT = 1 << 4;
+
+ /** The URL failed to load due to a client or server error. */
+ int VISIT_UNRECOVERABLE_ERROR = 1 << 5;
+
+ /**
+ * Records a visit to a page.
+ *
+ * @param session The session where the URL was visited.
+ * @param url The visited URL.
+ * @param lastVisitedURL The last visited URL in this session, to detect redirects and reloads.
+ * @param flags Additional flags for this visit, including redirect and error statuses. This is
+ * a bitmask of one or more {@link #VISIT_TOP_LEVEL VISIT_*} flags, OR-ed together.
+ * @return A {@link GeckoResult} completed with a boolean indicating whether to highlight links
+ * for the new URL as visited ({@code true}) or unvisited ({@code false}).
+ */
+ @UiThread
+ default @Nullable GeckoResult<Boolean> onVisited(
+ @NonNull final GeckoSession session,
+ @NonNull final String url,
+ @Nullable final String lastVisitedURL,
+ @VisitFlags final int flags) {
+ return null;
+ }
+
+ /**
+ * Returns the visited statuses for links on a page. This is used to highlight links as visited
+ * or unvisited, for example.
+ *
+ * @param session The session requesting the visited statuses.
+ * @param urls A list of URLs to check.
+ * @return A {@link GeckoResult} completed with a list of booleans corresponding to the URLs in
+ * {@code urls}, and indicating whether to highlight links for each URL as visited ({@code
+ * true}) or unvisited ({@code false}).
+ */
+ @UiThread
+ default @Nullable GeckoResult<boolean[]> getVisited(
+ @NonNull final GeckoSession session, @NonNull final String[] urls) {
+ return null;
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ default void onHistoryStateChange(
+ @NonNull final GeckoSession session, @NonNull final HistoryList historyList) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ HistoryDelegate.VISIT_TOP_LEVEL,
+ HistoryDelegate.VISIT_REDIRECT_TEMPORARY,
+ HistoryDelegate.VISIT_REDIRECT_PERMANENT,
+ HistoryDelegate.VISIT_REDIRECT_SOURCE,
+ HistoryDelegate.VISIT_REDIRECT_SOURCE_PERMANENT,
+ HistoryDelegate.VISIT_UNRECOVERABLE_ERROR
+ })
+ public @interface VisitFlags {}
+
+ private Autofill.Support getAutofillSupport() {
+ return mAutofillSupport;
+ }
+
+ /**
+ * Sets the autofill delegate for this session.
+ *
+ * @param delegate An instance of {@link Autofill.Delegate}.
+ */
+ @UiThread
+ public void setAutofillDelegate(final @Nullable Autofill.Delegate delegate) {
+ getAutofillSupport().setDelegate(delegate);
+ }
+
+ /**
+ * @return The current {@link Autofill.Delegate} for this session, if any.
+ */
+ @UiThread
+ public @Nullable Autofill.Delegate getAutofillDelegate() {
+ return getAutofillSupport().getDelegate();
+ }
+
+ /**
+ * Provides an autofill structure similar to {@link
+ * View#onProvideAutofillVirtualStructure(ViewStructure, int)} , but does not rely on {@link
+ * ViewStructure} to build the tree. This is useful for apps that want to provide autofill
+ * functionality without using the Android autofill system or requiring API 26.
+ *
+ * @return The elements available for autofill.
+ */
+ @UiThread
+ public @NonNull Autofill.Session getAutofillSession() {
+ return getAutofillSupport().getAutofillSession();
+ }
+
+ /**
+ * Saves a PDF of the currently displayed page.
+ *
+ * @return A GeckoResult with an InputStream containing the PDF. The result could
+ * CompleteExceptionally with a {@link GeckoPrintException}s, if there are any issues while
+ * generating the PDF.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<InputStream> saveAsPdf() {
+ return saveAsPdfByBrowsingContext(null);
+ }
+
+ /**
+ * Saves a PDF of the specified browsing context. Use null if the browsing context is unknown or
+ * to print the main page.
+ *
+ * @param browsingContextId the browsing context id of the item to print
+ * @return A GeckoResult with an InputStream containing the PDF.
+ */
+ @AnyThread
+ private @NonNull GeckoResult<InputStream> saveAsPdfByBrowsingContext(
+ final @Nullable Long browsingContextId) {
+ final GeckoResult<InputStream> geckoResult = new GeckoResult<>();
+ if (browsingContextId == null) {
+ // Ensures the canonical browsing context is available
+ setFocused(true);
+ this.mWindow.printToPdf(geckoResult);
+ } else {
+ this.mWindow.printToPdf(geckoResult, browsingContextId);
+ }
+ return geckoResult;
+ }
+
+ /** Prints the currently displayed page. */
+ @AnyThread
+ public void printPageContent() {
+ final PrintDelegate delegate = getPrintDelegate();
+ if (delegate != null) {
+ delegate.onPrint(this);
+ } else {
+ Log.w(LOGTAG, "Print delegate required for printing.");
+ }
+ }
+
+ /**
+ * Prints the currently displayed page and provides dialog finished status or if an exception
+ * occured.
+ *
+ * @return if the printing dialog finished or an exception.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> didPrintPageContent() {
+ final PrintDelegate delegate = getPrintDelegate();
+ final GeckoResult<Boolean> result = new GeckoResult<>();
+ if (delegate == null) {
+ result.completeExceptionally(new GeckoPrintException(ERROR_NO_PRINT_DELEGATE));
+ return result;
+ }
+ return saveAsPdfByBrowsingContext(null)
+ .then(pdfStream -> delegate.onPrintWithStatus(pdfStream));
+ }
+
+ private static String rgbaToArgb(final String color) {
+ // We expect #rrggbbaa
+ if (color.length() != 9 || !color.startsWith("#")) {
+ throw new IllegalArgumentException("Invalid color format");
+ }
+
+ return "#" + color.substring(7) + color.substring(1, 7);
+ }
+
+ private static void fixupManifestColor(final JSONObject manifest, final String name)
+ throws JSONException {
+ if (manifest.isNull(name)) {
+ return;
+ }
+
+ manifest.put(name, rgbaToArgb(manifest.getString(name)));
+ }
+
+ private static JSONObject fixupWebAppManifest(final JSONObject manifest) {
+ // Colors are #rrggbbaa, but we want them to be #aarrggbb, since that's what
+ // android.graphics.Color expects.
+ try {
+ fixupManifestColor(manifest, "theme_color");
+ fixupManifestColor(manifest, "background_color");
+ } catch (final JSONException e) {
+ Log.w(LOGTAG, "Failed to fixup web app manifest", e);
+ }
+
+ return manifest;
+ }
+
+ private static boolean maybeCheckDataUriLength(final @NonNull Loader request) {
+ if (!request.mIsDataUri) {
+ return true;
+ }
+
+ return request.mUri.length() <= DATA_URI_MAX_LENGTH;
+ }
+
+ /**
+ * Used for printing page content.
+ *
+ * <p>The provided implementation is in {@link GeckoView}. It uses a PDF of the content and the
+ * Android print API to print the page.
+ */
+ @AnyThread
+ public interface PrintDelegate {
+ /**
+ * Print the current page content.
+ *
+ * @param session to print
+ */
+ default void onPrint(@NonNull final GeckoSession session) {}
+
+ /**
+ * Print any provided PDF InputStream.
+ *
+ * @param pdfInputStream an InputStream containing a PDF
+ */
+ default void onPrint(@NonNull final InputStream pdfInputStream) {}
+
+ /**
+ * Print any provided PDF InputStream.
+ *
+ * @param pdfInputStream an InputStream containing a PDF
+ * @return A GeckoResult if the print dialog has closed
+ */
+ default @Nullable GeckoResult<Boolean> onPrintWithStatus(
+ @NonNull final InputStream pdfInputStream) {
+ return null;
+ }
+ }
+
+ /**
+ * Gets the print delegate for this session.
+ *
+ * @return The current {@link PrintDelegate} for this session, if any.
+ */
+ @AnyThread
+ public @Nullable PrintDelegate getPrintDelegate() {
+ return mPrintHandler.getDelegate();
+ }
+
+ /**
+ * Sets the print delegate for this session.
+ *
+ * @param delegate An instance of {@link PrintDelegate}.
+ */
+ @AnyThread
+ public void setPrintDelegate(final @Nullable PrintDelegate delegate) {
+ mPrintHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Gets the experiment delegate for this session.
+ *
+ * @return The current {@link ExperimentDelegate} for this session, if any.
+ */
+ @AnyThread
+ public @Nullable ExperimentDelegate getExperimentDelegate() {
+ return mExperimentHandler.getDelegate();
+ }
+
+ /**
+ * Gets the experiment delegate from the runtime.
+ *
+ * @return The current {@link ExperimentDelegate} for the runtime or null.
+ */
+ @AnyThread
+ private @Nullable ExperimentDelegate getRuntimeExperimentDelegate() {
+ final GeckoRuntime runtime = this.getRuntime();
+ if (runtime != null) {
+ final GeckoRuntimeSettings runtimeSettings = runtime.getSettings();
+ if (runtimeSettings != null) {
+ return runtimeSettings.getExperimentDelegate();
+ }
+ }
+ Log.w(LOGTAG, "Could not retrieve experiment delegate from runtime.");
+ return null;
+ }
+
+ /**
+ * Sets the experiment delegate for this session. Default is set to the runtime experiment
+ * delegate.
+ *
+ * @param delegate An instance of {@link ExperimentDelegate}.
+ */
+ @AnyThread
+ public void setExperimentDelegate(final @Nullable ExperimentDelegate delegate) {
+ mExperimentHandler.setDelegate(delegate, this);
+ }
+
+ /** Thrown when failure occurs when printing from a website. */
+ @WrapForJNI
+ public static class GeckoPrintException extends Exception {
+ /** The print service was not available. */
+ public static final int ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE = -1;
+
+ /** The print service was not created due to an initialization error. */
+ public static final int ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS = -2;
+
+ /** An error happened while trying to find the canonical browing context */
+ public static final int ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT = -3;
+
+ /** An error happened while trying to find the activity context delegate */
+ public static final int ERROR_NO_ACTIVITY_CONTEXT_DELEGATE = -4;
+
+ /** An error happened while trying to find the activity context */
+ public static final int ERROR_NO_ACTIVITY_CONTEXT = -5;
+
+ /** An error happened while trying to find the print delegate */
+ public static final int ERROR_NO_PRINT_DELEGATE = -6;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE,
+ ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS,
+ ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT,
+ ERROR_NO_ACTIVITY_CONTEXT_DELEGATE,
+ ERROR_NO_ACTIVITY_CONTEXT,
+ ERROR_NO_PRINT_DELEGATE
+ })
+ public @interface Codes {}
+
+ /** One of {@link Codes} that provides more information about this exception. */
+ public final @Codes int code;
+
+ @Override
+ public String toString() {
+ return "GeckoPrintException: " + code;
+ }
+
+ /* package */ GeckoPrintException(final @Codes int code) {
+ this.code = code;
+ }
+
+ /** For testing. */
+ protected GeckoPrintException() {
+ code = ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java
new file mode 100644
index 0000000000..629211a4a6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java
@@ -0,0 +1,106 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/* package */ abstract class GeckoSessionHandler<Delegate> implements BundleEventListener {
+
+ private static final String LOGTAG = "GeckoSessionHandler";
+ private static final boolean DEBUG = false;
+
+ private final String mModuleName;
+ private final String[] mEvents;
+ private Delegate mDelegate;
+ private boolean mRegisteredListeners;
+
+ /* package */ GeckoSessionHandler(
+ final String module, final GeckoSession session, final String[] events) {
+ this(module, session, events, new String[] {});
+ }
+
+ /* package */ GeckoSessionHandler(
+ final String module,
+ final GeckoSession session,
+ final String[] events,
+ final String[] defaultEvents) {
+ session.handlersCount++;
+
+ mModuleName = module;
+ mEvents = events;
+
+ // Default events are always active
+ session.getEventDispatcher().registerUiThreadListener(this, defaultEvents);
+ }
+
+ public Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ public void setDelegate(final Delegate delegate, final GeckoSession session) {
+ if (mDelegate == delegate) {
+ return;
+ }
+
+ mDelegate = delegate;
+
+ if (!mRegisteredListeners && delegate != null) {
+ session.getEventDispatcher().registerUiThreadListener(this, mEvents);
+ mRegisteredListeners = true;
+ }
+
+ // If session is not open, we will update module state during session opening.
+ if (!session.isOpen()) {
+ return;
+ }
+
+ final GeckoBundle msg = new GeckoBundle(2);
+ msg.putString("module", mModuleName);
+ msg.putBoolean("enabled", isEnabled());
+ session.getEventDispatcher().dispatch("GeckoView:UpdateModuleState", msg);
+ }
+
+ public String getName() {
+ return mModuleName;
+ }
+
+ public boolean isEnabled() {
+ return mDelegate != null;
+ }
+
+ @Override
+ @UiThread
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, mModuleName + " handleMessage: event = " + event);
+ }
+
+ if (mDelegate != null) {
+ handleMessage(mDelegate, event, message, callback);
+ } else {
+ handleDefaultMessage(event, message, callback);
+ }
+ }
+
+ protected abstract void handleMessage(
+ final Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback);
+
+ protected void handleDefaultMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (callback != null) {
+ callback.sendError("No delegate registered");
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java
new file mode 100644
index 0000000000..046f7a3072
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java
@@ -0,0 +1,732 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.Collection;
+import org.mozilla.gecko.util.GeckoBundle;
+
+@AnyThread
+public final class GeckoSessionSettings implements Parcelable {
+
+ /** Settings builder used to construct the settings object. */
+ @AnyThread
+ public static final class Builder {
+ private final GeckoSessionSettings mSettings;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mSettings = new GeckoSessionSettings();
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder(final GeckoSessionSettings settings) {
+ mSettings = new GeckoSessionSettings(settings);
+ }
+
+ /**
+ * Finalize and return the settings.
+ *
+ * @return The constructed settings.
+ */
+ public @NonNull GeckoSessionSettings build() {
+ return new GeckoSessionSettings(mSettings);
+ }
+
+ /**
+ * Set the chrome URI.
+ *
+ * @param uri The URI to set the Chrome URI to.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder chromeUri(final @NonNull String uri) {
+ mSettings.setChromeUri(uri);
+ return this;
+ }
+
+ /**
+ * Set the screen id.
+ *
+ * @param id The screen id.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder screenId(final int id) {
+ mSettings.setScreenId(id);
+ return this;
+ }
+
+ /**
+ * Set the privacy mode for this instance.
+ *
+ * @param flag A flag determining whether Private Mode should be enabled. Default is false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder usePrivateMode(final boolean flag) {
+ mSettings.setUsePrivateMode(flag);
+ return this;
+ }
+
+ /**
+ * Set the session context ID for this instance. Setting a context ID partitions the cookie jars
+ * based on the provided IDs. This isolates the browser storage like cookies and localStorage
+ * between sessions, only sessions that share the same ID share storage data.
+ *
+ * <p>Warning: Storage data is collected persistently for each context, to delete context data,
+ * call {@link StorageController#clearDataForSessionContext} for the given context.
+ *
+ * @param value The custom context ID. The default ID is null, which removes isolation for this
+ * instance.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder contextId(final @Nullable String value) {
+ mSettings.setContextId(value);
+ return this;
+ }
+
+ /**
+ * Set whether tracking protection should be enabled.
+ *
+ * @param flag A flag determining whether tracking protection should be enabled. Default is
+ * false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder useTrackingProtection(final boolean flag) {
+ mSettings.setUseTrackingProtection(flag);
+ return this;
+ }
+
+ /**
+ * Set the user agent mode.
+ *
+ * @param mode The mode to set the user agent to. Use one or more of the {@link
+ * GeckoSessionSettings#USER_AGENT_MODE_MOBILE GeckoSessionSettings.USER_AGENT_MODE_*}
+ * flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder userAgentMode(@UserAgentMode final int mode) {
+ mSettings.setUserAgentMode(mode);
+ return this;
+ }
+
+ /**
+ * Override the user agent.
+ *
+ * @param agent The user agent to use.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder userAgentOverride(final @NonNull String agent) {
+ mSettings.setUserAgentOverride(agent);
+ return this;
+ }
+
+ /**
+ * Specify which display-mode to use.
+ *
+ * @param mode The mode to set the display to. Use one or more of the {@link
+ * GeckoSessionSettings#DISPLAY_MODE_BROWSER GeckoSessionSettings.DISPLAY_MODE_*} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder displayMode(@DisplayMode final int mode) {
+ mSettings.setDisplayMode(mode);
+ return this;
+ }
+
+ /**
+ * Set whether to suspend the playing of media when the session is inactive.
+ *
+ * @param flag A flag determining whether media should be suspended. Default is false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder suspendMediaWhenInactive(final boolean flag) {
+ mSettings.setSuspendMediaWhenInactive(flag);
+ return this;
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param flag A flag determining whether JavaScript should be enabled. Default is true.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder allowJavascript(final boolean flag) {
+ mSettings.setAllowJavascript(flag);
+ return this;
+ }
+
+ /**
+ * Set whether the entire accessible tree should be exposed with no caching.
+ *
+ * @param flag A flag determining if the entire accessible tree should be exposed. Default is
+ * false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder fullAccessibilityTree(final boolean flag) {
+ mSettings.setFullAccessibilityTree(flag);
+ return this;
+ }
+
+ /**
+ * Specify which viewport mode to use.
+ *
+ * @param mode The mode to set the viewport to. Use one or more of the {@link
+ * GeckoSessionSettings#VIEWPORT_MODE_MOBILE GeckoSessionSettings.VIEWPORT_MODE_*} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder viewportMode(@ViewportMode final int mode) {
+ mSettings.setViewportMode(mode);
+ return this;
+ }
+ }
+
+ private static final String LOGTAG = "GeckoSessionSettings";
+ private static final boolean DEBUG = false;
+
+ /** This value is for the display member of Web App Manifests */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ DISPLAY_MODE_BROWSER,
+ DISPLAY_MODE_MINIMAL_UI,
+ DISPLAY_MODE_STANDALONE,
+ DISPLAY_MODE_FULLSCREEN
+ })
+ public @interface DisplayMode {}
+
+ // This needs to match GeckoViewSettings.jsm
+ /** "browser" value of the display member in Web App Manifests */
+ public static final int DISPLAY_MODE_BROWSER = 0;
+
+ /** "minimal-ui" value of the display member in Web App Manifests */
+ public static final int DISPLAY_MODE_MINIMAL_UI = 1;
+
+ /** "standalone" value of the display member in Web App Manifests */
+ public static final int DISPLAY_MODE_STANDALONE = 2;
+
+ /** "fullscreen" value of the display member in Web App Manifests */
+ public static final int DISPLAY_MODE_FULLSCREEN = 3;
+
+ /** The user agent string mode */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ USER_AGENT_MODE_MOBILE,
+ USER_AGENT_MODE_DESKTOP,
+ USER_AGENT_MODE_VR,
+ })
+ public @interface UserAgentMode {}
+
+ // This needs to match GeckoViewSettingsChild.js and GeckoViewSettings.jsm
+ /** The user agent mode is mobile device */
+ public static final int USER_AGENT_MODE_MOBILE = 0;
+
+ /** The user agent mobe is desktop device */
+ public static final int USER_AGENT_MODE_DESKTOP = 1;
+
+ /** The user agent mode is VR device */
+ public static final int USER_AGENT_MODE_VR = 2;
+
+ /** The view port mode */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({VIEWPORT_MODE_MOBILE, VIEWPORT_MODE_DESKTOP})
+ public @interface ViewportMode {}
+
+ // This needs to match GeckoViewSettingsChild.js
+ /**
+ * Mobile-friendly pages will be rendered using a viewport based on their &lt;meta&gt; viewport
+ * tag. All other pages will be rendered using a special desktop mode viewport, which has a width
+ * of 980 CSS px.
+ */
+ public static final int VIEWPORT_MODE_MOBILE = 0;
+
+ /**
+ * All pages will be rendered using the special desktop mode viewport, which has a width of 980
+ * CSS px, regardless of whether the page has a &lt;meta&gt; viewport tag specified or not.
+ */
+ public static final int VIEWPORT_MODE_DESKTOP = 1;
+
+ public static class Key<T> {
+ /* package */ final String name;
+ /* package */ final boolean initOnly;
+ /* package */ final Collection<T> values;
+
+ /* package */ Key(final String name) {
+ this(name, /* initOnly */ false, /* values */ null);
+ }
+
+ /* package */ Key(final String name, final boolean initOnly, final Collection<T> values) {
+ this.name = name;
+ this.initOnly = initOnly;
+ this.values = values;
+ }
+ }
+
+ /**
+ * Key to set the chrome window URI, or null to use default URI. Read-only once session is open.
+ */
+ private static final Key<String> CHROME_URI =
+ new Key<String>("chromeUri", /* initOnly */ true, /* values */ null);
+
+ /** Key to set the window screen ID, or 0 to use default ID. Read-only once session is open. */
+ private static final Key<Integer> SCREEN_ID =
+ new Key<Integer>("screenId", /* initOnly */ true, /* values */ null);
+
+ /** Key to enable and disable tracking protection. */
+ private static final Key<Boolean> USE_TRACKING_PROTECTION =
+ new Key<Boolean>("useTrackingProtection");
+
+ /** Key to enable and disable private mode browsing. Read-only once session is open. */
+ private static final Key<Boolean> USE_PRIVATE_MODE =
+ new Key<Boolean>("usePrivateMode", /* initOnly */ true, /* values */ null);
+
+ /** Key to specify which user agent mode we should use. */
+ private static final Key<Integer> USER_AGENT_MODE =
+ new Key<Integer>(
+ "userAgentMode", /* initOnly */
+ false,
+ Arrays.asList(USER_AGENT_MODE_MOBILE, USER_AGENT_MODE_DESKTOP, USER_AGENT_MODE_VR));
+
+ /**
+ * Key to specify the user agent override string. Set value to null to use the user agent
+ * specified by USER_AGENT_MODE.
+ */
+ private static final Key<String> USER_AGENT_OVERRIDE =
+ new Key<String>("userAgentOverride", /* initOnly */ false, /* values */ null);
+
+ /** Key to specify which viewport mode we should use. */
+ private static final Key<Integer> VIEWPORT_MODE =
+ new Key<Integer>(
+ "viewportMode", /* initOnly */
+ false,
+ Arrays.asList(VIEWPORT_MODE_MOBILE, VIEWPORT_MODE_DESKTOP));
+
+ /** Key to specify which display-mode we should use. */
+ private static final Key<Integer> DISPLAY_MODE =
+ new Key<Integer>(
+ "displayMode", /* initOnly */
+ false,
+ Arrays.asList(
+ DISPLAY_MODE_BROWSER, DISPLAY_MODE_MINIMAL_UI,
+ DISPLAY_MODE_STANDALONE, DISPLAY_MODE_FULLSCREEN));
+
+ /** Key to specify if media should be suspended when the session is inactive. */
+ private static final Key<Boolean> SUSPEND_MEDIA_WHEN_INACTIVE =
+ new Key<Boolean>("suspendMediaWhenInactive", /* initOnly */ false, /* values */ null);
+
+ /** Key to specify if JavaScript should be allowed on this session. */
+ private static final Key<Boolean> ALLOW_JAVASCRIPT =
+ new Key<Boolean>("allowJavascript", /* initOnly */ false, /* values */ null);
+
+ /** Key to specify if entire accessible tree should be exposed with no caching. */
+ private static final Key<Boolean> FULL_ACCESSIBILITY_TREE =
+ new Key<Boolean>("fullAccessibilityTree", /* initOnly */ false, /* values */ null);
+
+ /**
+ * Key to specify if this GeckoSession is a Popup or not. Popup sessions can paint over other
+ * sessions and are not exposed to the tabs WebExtension API.
+ */
+ private static final Key<Boolean> IS_POPUP =
+ new Key<Boolean>("isPopup", /* initOnly */ false, /* values */ null);
+
+ /** Internal Gecko key to specify the session context ID. Derived from `UNSAFE_CONTEXT_ID`. */
+ private static final Key<String> CONTEXT_ID =
+ new Key<String>("sessionContextId", /* initOnly */ true, /* values */ null);
+
+ /** User-provided key to specify the session context ID. */
+ private static final Key<String> UNSAFE_CONTEXT_ID =
+ new Key<String>("unsafeSessionContextId", /* initOnly */ true, /* values */ null);
+
+ private final GeckoSession mSession;
+ private final GeckoBundle mBundle;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSessionSettings() {
+ this(null, null);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSessionSettings(final @NonNull GeckoSessionSettings settings) {
+ this(settings, null);
+ }
+
+ /* package */ GeckoSessionSettings(
+ final @Nullable GeckoSessionSettings settings, final @Nullable GeckoSession session) {
+ mSession = session;
+
+ if (settings != null) {
+ mBundle = new GeckoBundle(settings.mBundle);
+ return;
+ }
+
+ mBundle = new GeckoBundle();
+ mBundle.putString(CHROME_URI.name, null);
+ mBundle.putInt(SCREEN_ID.name, 0);
+ mBundle.putBoolean(USE_TRACKING_PROTECTION.name, false);
+ mBundle.putBoolean(USE_PRIVATE_MODE.name, false);
+ mBundle.putBoolean(SUSPEND_MEDIA_WHEN_INACTIVE.name, false);
+ mBundle.putBoolean(ALLOW_JAVASCRIPT.name, true);
+ mBundle.putBoolean(FULL_ACCESSIBILITY_TREE.name, false);
+ mBundle.putBoolean(IS_POPUP.name, false);
+ mBundle.putInt(USER_AGENT_MODE.name, USER_AGENT_MODE_MOBILE);
+ mBundle.putString(USER_AGENT_OVERRIDE.name, null);
+ mBundle.putInt(VIEWPORT_MODE.name, VIEWPORT_MODE_MOBILE);
+ mBundle.putInt(DISPLAY_MODE.name, DISPLAY_MODE_BROWSER);
+ mBundle.putString(CONTEXT_ID.name, null);
+ mBundle.putString(UNSAFE_CONTEXT_ID.name, null);
+ }
+
+ /**
+ * Set whether tracking protection should be enabled.
+ *
+ * @param value A flag determining whether tracking protection should be enabled. Default is
+ * false.
+ */
+ public void setUseTrackingProtection(final boolean value) {
+ setBoolean(USE_TRACKING_PROTECTION, value);
+ }
+
+ /**
+ * Set the privacy mode for this instance.
+ *
+ * @param value A flag determining whether Private Mode should be enabled. Default is false.
+ */
+ private void setUsePrivateMode(final boolean value) {
+ setBoolean(USE_PRIVATE_MODE, value);
+ }
+
+ /**
+ * Set whether to suspend the playing of media when the session is inactive.
+ *
+ * @param value A flag determining whether media should be suspended. Default is false.
+ */
+ public void setSuspendMediaWhenInactive(final boolean value) {
+ setBoolean(SUSPEND_MEDIA_WHEN_INACTIVE, value);
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param value A flag determining whether JavaScript should be enabled. Default is true.
+ */
+ public void setAllowJavascript(final boolean value) {
+ setBoolean(ALLOW_JAVASCRIPT, value);
+ }
+
+ /**
+ * Set whether the entire accessible tree should be exposed with no caching.
+ *
+ * @param value A flag determining full accessibility tree should be exposed. Default is false.
+ */
+ public void setFullAccessibilityTree(final boolean value) {
+ setBoolean(FULL_ACCESSIBILITY_TREE, value);
+ }
+
+ /* package */ void setIsPopup(final boolean value) {
+ setBoolean(IS_POPUP, value);
+ }
+
+ private void setBoolean(final Key<Boolean> key, final boolean value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putBoolean(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Whether tracking protection is enabled.
+ *
+ * @return true if tracking protection is enabled, false if not.
+ */
+ public boolean getUseTrackingProtection() {
+ return getBoolean(USE_TRACKING_PROTECTION);
+ }
+
+ /**
+ * Whether private mode is enabled.
+ *
+ * @return true if private mode is enabled, false if not.
+ */
+ public boolean getUsePrivateMode() {
+ return getBoolean(USE_PRIVATE_MODE);
+ }
+
+ /**
+ * The context ID for this session.
+ *
+ * @return The context ID for this session.
+ */
+ public @Nullable String getContextId() {
+ // Return the user-provided unsafe string.
+ return getString(UNSAFE_CONTEXT_ID);
+ }
+
+ /**
+ * Whether media will be suspended when the session is inactice.
+ *
+ * @return true if media will be suspended, false if not.
+ */
+ public boolean getSuspendMediaWhenInactive() {
+ return getBoolean(SUSPEND_MEDIA_WHEN_INACTIVE);
+ }
+
+ /**
+ * Whether javascript execution is allowed.
+ *
+ * @return true if javascript execution is allowed, false if not.
+ */
+ public boolean getAllowJavascript() {
+ return getBoolean(ALLOW_JAVASCRIPT);
+ }
+
+ /**
+ * Whether entire accessible tree is exposed with no caching.
+ *
+ * @return true if accessibility tree is exposed, false if not.
+ */
+ public boolean getFullAccessibilityTree() {
+ return getBoolean(FULL_ACCESSIBILITY_TREE);
+ }
+
+ /* package */ boolean getIsPopup() {
+ return getBoolean(IS_POPUP);
+ }
+
+ private boolean getBoolean(final Key<Boolean> key) {
+ synchronized (mBundle) {
+ return mBundle.getBoolean(key.name);
+ }
+ }
+
+ /**
+ * Set the screen id.
+ *
+ * @param value The screen id.
+ */
+ private void setScreenId(final int value) {
+ setInt(SCREEN_ID, value);
+ }
+
+ /**
+ * Specify which user agent mode we should use
+ *
+ * @param value One or more of the {@link GeckoSessionSettings#USER_AGENT_MODE_MOBILE
+ * GeckoSessionSettings.USER_AGENT_MODE_*} flags.
+ */
+ public void setUserAgentMode(@UserAgentMode final int value) {
+ setInt(USER_AGENT_MODE, value);
+ }
+
+ /**
+ * Set the display mode.
+ *
+ * @param value The mode to set the display to. Use one or more of the {@link
+ * GeckoSessionSettings#DISPLAY_MODE_BROWSER GeckoSessionSettings.DISPLAY_MODE_*} flags.
+ */
+ public void setDisplayMode(@DisplayMode final int value) {
+ setInt(DISPLAY_MODE, value);
+ }
+
+ /**
+ * Specify which viewport mode we should use
+ *
+ * @param value One or more of the {@link GeckoSessionSettings#VIEWPORT_MODE_MOBILE
+ * GeckoSessionSettings.VIEWPORT_MODE_*} flags.
+ */
+ public void setViewportMode(@ViewportMode final int value) {
+ setInt(VIEWPORT_MODE, value);
+ }
+
+ private void setInt(final Key<Integer> key, final int value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putInt(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Set the window screen ID. Read-only once session is open. Use the {@link Builder} to set on
+ * session open.
+ *
+ * @return Key to set the window screen ID. 0 is the default ID.
+ */
+ public int getScreenId() {
+ return getInt(SCREEN_ID);
+ }
+
+ /**
+ * The current user agent Mode
+ *
+ * @return One or more of the {@link GeckoSessionSettings#USER_AGENT_MODE_MOBILE
+ * GeckoSessionSettings.USER_AGENT_MODE_*} flags.
+ */
+ public @UserAgentMode int getUserAgentMode() {
+ return getInt(USER_AGENT_MODE);
+ }
+
+ /**
+ * The current display mode.
+ *
+ * @return One or more of the {@link GeckoSessionSettings#DISPLAY_MODE_BROWSER
+ * GeckoSessionSettings.DISPLAY_MODE_*} flags.
+ */
+ public @DisplayMode int getDisplayMode() {
+ return getInt(DISPLAY_MODE);
+ }
+
+ /**
+ * The current viewport Mode
+ *
+ * @return One or more of the {@link GeckoSessionSettings#VIEWPORT_MODE
+ * GeckoSessionSettings.VIEWPORT_MODE_*} flags.
+ */
+ public @ViewportMode int getViewportMode() {
+ return getInt(VIEWPORT_MODE);
+ }
+
+ private int getInt(final Key<Integer> key) {
+ synchronized (mBundle) {
+ return mBundle.getInt(key.name);
+ }
+ }
+
+ /**
+ * Set the chrome URI.
+ *
+ * @param value The URI to set the Chrome URI to.
+ */
+ private void setChromeUri(final @NonNull String value) {
+ setString(CHROME_URI, value);
+ }
+
+ /**
+ * Specify the user agent override string. Set value to null to use the user agent specified by
+ * USER_AGENT_MODE.
+ *
+ * @param value The string to override the user agent with.
+ */
+ public void setUserAgentOverride(final @Nullable String value) {
+ setString(USER_AGENT_OVERRIDE, value);
+ }
+
+ private void setContextId(final @Nullable String value) {
+ setString(UNSAFE_CONTEXT_ID, value);
+ setString(CONTEXT_ID, StorageController.createSafeSessionContextId(value));
+ }
+
+ private void setString(final Key<String> key, final String value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putString(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Set the chrome window URI. Read-only once session is open. Use the {@link Builder} to set on
+ * session open.
+ *
+ * @return Key to set the chrome window URI, or null to use default URI.
+ */
+ public @Nullable String getChromeUri() {
+ return getString(CHROME_URI);
+ }
+
+ /**
+ * The user agent override string.
+ *
+ * @return The current user agent string or null if the agent is specified by {@link
+ * GeckoSessionSettings#USER_AGENT_MODE}
+ */
+ public @Nullable String getUserAgentOverride() {
+ return getString(USER_AGENT_OVERRIDE);
+ }
+
+ private String getString(final Key<String> key) {
+ synchronized (mBundle) {
+ return mBundle.getString(key.name);
+ }
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ return new GeckoBundle(mBundle);
+ }
+
+ @Override
+ public String toString() {
+ return mBundle.toString();
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ return other instanceof GeckoSessionSettings
+ && mBundle.equals(((GeckoSessionSettings) other).mBundle);
+ }
+
+ @Override
+ public int hashCode() {
+ return mBundle.hashCode();
+ }
+
+ private <T> boolean valueChangedLocked(final Key<T> key, final T value) {
+ if (key.initOnly && mSession != null) {
+ throw new IllegalStateException("Read-only property");
+ } else if (key.values != null && !key.values.contains(value)) {
+ throw new IllegalArgumentException("Invalid value");
+ }
+
+ final Object old = mBundle.get(key.name);
+ return (old != value) && (old == null || !old.equals(value));
+ }
+
+ private void dispatchUpdate() {
+ if (mSession != null) {
+ mSession.getEventDispatcher().dispatch("GeckoView:UpdateSettings", toBundle());
+ }
+ }
+
+ @Override // Parcelable
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final @NonNull Parcel out, final int flags) {
+ mBundle.writeToParcel(out, flags);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ mBundle.readFromParcel(source);
+ }
+
+ public static final Parcelable.Creator<GeckoSessionSettings> CREATOR =
+ new Parcelable.Creator<GeckoSessionSettings>() {
+ @Override
+ public GeckoSessionSettings createFromParcel(final Parcel in) {
+ final GeckoSessionSettings settings = new GeckoSessionSettings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public GeckoSessionSettings[] newArray(final int size) {
+ return new GeckoSessionSettings[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java
new file mode 100644
index 0000000000..754414a0ea
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java
@@ -0,0 +1,42 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * Interface for registering the external VR context with WebVR. The context must be registered
+ * before Gecko is started. This API is not intended for external consumption. To see an example of
+ * how it is used please see the <a href="https://github.com/MozillaReality/FirefoxReality"
+ * target="_blank">Firefox Reality browser</a>.
+ *
+ * @see <a href="https://searchfox.org/mozilla-central/source/gfx/vr/external_api/moz_external_vr.h"
+ * target="_blank">External VR Context</a>
+ */
+public class GeckoVRManager {
+ private static long mExternalContext;
+
+ private GeckoVRManager() {}
+
+ @WrapForJNI
+ private static synchronized long getExternalContext() {
+ return mExternalContext;
+ }
+
+ /**
+ * Sets the external VR context. The external VR context is defined <a
+ * href="https://searchfox.org/mozilla-central/source/gfx/vr/external_api/moz_external_vr.h"
+ * target="_blank">here</a>.
+ *
+ * @param externalContext A pointer to the external VR context.
+ */
+ @AnyThread
+ public static synchronized void setExternalContext(final long externalContext) {
+ mExternalContext = externalContext;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
new file mode 100644
index 0000000000..74eccaeb15
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
@@ -0,0 +1,1246 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import static org.mozilla.geckoview.GeckoSession.GeckoPrintException.ERROR_NO_ACTIVITY_CONTEXT;
+import static org.mozilla.geckoview.GeckoSession.GeckoPrintException.ERROR_NO_ACTIVITY_CONTEXT_DELEGATE;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Region;
+import android.os.Build;
+import android.os.Handler;
+import android.print.PrintDocumentAdapter;
+import android.print.PrintManager;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.TypedValue;
+import android.view.DisplayCutout;
+import android.view.DragEvent;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import android.view.SurfaceView;
+import android.view.TextureView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStructure;
+import android.view.autofill.AutofillManager;
+import android.view.autofill.AutofillValue;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.FrameLayout;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.core.view.ViewCompat;
+import java.io.InputStream;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+import org.mozilla.gecko.AndroidGamepadManager;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.SurfaceViewWrapper;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public class GeckoView extends FrameLayout implements GeckoDisplay.NewSurfaceProvider {
+ private static final String LOGTAG = "GeckoView";
+ private static final boolean DEBUG = false;
+
+ protected final @NonNull Display mDisplay = new Display();
+
+ private Integer mLastCoverColor;
+ protected @Nullable GeckoSession mSession;
+ WeakReference<Autofill.Session> mAutofillSession = new WeakReference<>(null);
+
+ // Whether this GeckoView instance has a session that is no longer valid, e.g. because the session
+ // associated to this GeckoView was attached to a different GeckoView instance.
+ private boolean mIsSessionPoisoned = false;
+
+ private boolean mStateSaved;
+
+ private @Nullable SurfaceViewWrapper mSurfaceWrapper;
+
+ private boolean mIsResettingFocus;
+
+ private boolean mAutofillEnabled = true;
+
+ private GeckoSession.SelectionActionDelegate mSelectionActionDelegate;
+ private Autofill.Delegate mAutofillDelegate;
+ private @Nullable ActivityContextDelegate mActivityDelegate;
+ private GeckoSession.PrintDelegate mPrintDelegate;
+
+ private class Display implements SurfaceViewWrapper.Listener {
+ private final int[] mOrigin = new int[2];
+
+ private GeckoDisplay mDisplay;
+ private boolean mValid;
+
+ private int mClippingHeight;
+ private int mDynamicToolbarMaxHeight;
+
+ public void acquire(final GeckoDisplay display) {
+ mDisplay = display;
+
+ if (!mValid) {
+ return;
+ }
+
+ setVerticalClipping(mClippingHeight);
+
+ // Tell display there is already a surface.
+ onGlobalLayout();
+ if (GeckoView.this.mSurfaceWrapper != null) {
+ final SurfaceViewWrapper wrapper = GeckoView.this.mSurfaceWrapper;
+
+ mDisplay.surfaceChanged(
+ new GeckoDisplay.SurfaceInfo.Builder(wrapper.getSurface())
+ .surfaceControl(wrapper.getSurfaceControl())
+ .newSurfaceProvider(GeckoView.this)
+ .size(wrapper.getWidth(), wrapper.getHeight())
+ .build());
+ mDisplay.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight);
+ GeckoView.this.setActive(true);
+ }
+ }
+
+ public GeckoDisplay release() {
+ if (mValid) {
+ if (mDisplay != null) {
+ mDisplay.surfaceDestroyed();
+ }
+ GeckoView.this.setActive(false);
+ }
+
+ final GeckoDisplay display = mDisplay;
+ mDisplay = null;
+ return display;
+ }
+
+ @Override // SurfaceListener
+ public void onSurfaceChanged(
+ final Surface surface,
+ @Nullable final SurfaceControl surfaceControl,
+ final int width,
+ final int height) {
+ if (mDisplay != null) {
+ mDisplay.surfaceChanged(
+ new GeckoDisplay.SurfaceInfo.Builder(surface)
+ .surfaceControl(surfaceControl)
+ .newSurfaceProvider(GeckoView.this)
+ .size(width, height)
+ .build());
+ mDisplay.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight);
+ if (!mValid) {
+ GeckoView.this.setActive(true);
+ }
+ }
+ mValid = true;
+ }
+
+ @Override // SurfaceListener
+ public void onSurfaceDestroyed() {
+ if (mDisplay != null) {
+ mDisplay.surfaceDestroyed();
+ GeckoView.this.setActive(false);
+ }
+ mValid = false;
+ }
+
+ public void onGlobalLayout() {
+ if (mDisplay == null) {
+ return;
+ }
+ if (GeckoView.this.mSurfaceWrapper != null) {
+ GeckoView.this.mSurfaceWrapper.getView().getLocationOnScreen(mOrigin);
+ mDisplay.screenOriginChanged(mOrigin[0], mOrigin[1]);
+ // cutout support
+ if (Build.VERSION.SDK_INT >= 28) {
+ final DisplayCutout cutout =
+ GeckoView.this.mSurfaceWrapper.getView().getRootWindowInsets().getDisplayCutout();
+ if (cutout != null) {
+ mDisplay.safeAreaInsetsChanged(
+ cutout.getSafeInsetTop(),
+ cutout.getSafeInsetRight(),
+ cutout.getSafeInsetBottom(),
+ cutout.getSafeInsetLeft());
+ }
+ }
+ }
+ }
+
+ public boolean shouldPinOnScreen() {
+ return mDisplay != null && mDisplay.shouldPinOnScreen();
+ }
+
+ public void setVerticalClipping(final int clippingHeight) {
+ mClippingHeight = clippingHeight;
+
+ if (mDisplay != null) {
+ mDisplay.setVerticalClipping(clippingHeight);
+ }
+ }
+
+ public void setDynamicToolbarMaxHeight(final int height) {
+ mDynamicToolbarMaxHeight = height;
+
+ // Reset the vertical clipping value to zero whenever we change
+ // the dynamic toolbar __max__ height so that it can be properly
+ // propagated to both the main thread and the compositor thread,
+ // thus we will be able to reset the __current__ toolbar height
+ // on the both threads whatever the __current__ toolbar height is.
+ setVerticalClipping(0);
+
+ if (mDisplay != null) {
+ mDisplay.setDynamicToolbarMaxHeight(height);
+ }
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being rendered.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the currently visible rendered web page.
+ */
+ @UiThread
+ @NonNull
+ GeckoResult<Bitmap> capturePixels() {
+ if (mDisplay == null) {
+ return GeckoResult.fromException(
+ new IllegalStateException("Display must be created before pixels can be captured"));
+ }
+
+ return mDisplay.capturePixels();
+ }
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoView(final Context context) {
+ super(context);
+ init();
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private static Activity getActivityFromContext(final Context outerContext) {
+ Context context = outerContext;
+ while (context instanceof ContextWrapper) {
+ if (context instanceof Activity) {
+ return (Activity) context;
+ }
+ context = ((ContextWrapper) context).getBaseContext();
+ }
+ return null;
+ }
+
+ private void init() {
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+
+ // We are adding descendants to this LayerView, but we don't want the
+ // descendants to affect the way LayerView retains its focus.
+ setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS);
+
+ // This will stop PropertyAnimator from creating a drawing cache (i.e. a
+ // bitmap) from a SurfaceView, which is just not possible (the bitmap will be
+ // transparent).
+ setWillNotCacheDrawing(false);
+
+ mSurfaceWrapper = new SurfaceViewWrapper(getContext());
+ mSurfaceWrapper.setBackgroundColor(Color.WHITE);
+ addView(
+ mSurfaceWrapper.getView(),
+ new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ mSurfaceWrapper.setListener(mDisplay);
+
+ final Activity activity = getActivityFromContext(getContext());
+ if (activity != null) {
+ mSelectionActionDelegate = new BasicSelectionActionDelegate(activity);
+ }
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ mAutofillDelegate = new AndroidAutofillDelegate();
+ } else {
+ // We don't support Autofill on SDK < 26
+ mAutofillDelegate = new Autofill.Delegate() {};
+ }
+ mPrintDelegate = new GeckoViewPrintDelegate();
+ }
+
+ /**
+ * Set a color to cover the display surface while a document is being shown. The color is
+ * automatically cleared once the new document starts painting.
+ *
+ * @param color Cover color.
+ */
+ public void coverUntilFirstPaint(final int color) {
+ mLastCoverColor = color;
+ if (mSession != null) {
+ mSession.getCompositorController().setClearColor(color);
+ }
+ coverUntilFirstPaintInternal(color);
+ }
+
+ private void uncover() {
+ coverUntilFirstPaintInternal(Color.TRANSPARENT);
+ }
+
+ private void coverUntilFirstPaintInternal(final int color) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSurfaceWrapper != null) {
+ mSurfaceWrapper.setBackgroundColor(color);
+ }
+ }
+
+ /**
+ * This GeckoView instance will be backed by a {@link SurfaceView}.
+ *
+ * <p>This option offers the best performance at the price of not being able to animate GeckoView.
+ */
+ public static final int BACKEND_SURFACE_VIEW = 1;
+
+ /**
+ * This GeckoView instance will be backed by a {@link TextureView}.
+ *
+ * <p>This option offers worse performance compared to {@link #BACKEND_SURFACE_VIEW} but allows
+ * you to animate GeckoView or to paint a GeckoView on top of another GeckoView.
+ */
+ public static final int BACKEND_TEXTURE_VIEW = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({BACKEND_SURFACE_VIEW, BACKEND_TEXTURE_VIEW})
+ public @interface ViewBackend {}
+
+ /**
+ * Set which view should be used by this GeckoView instance to display content.
+ *
+ * <p>By default, GeckoView will use a {@link SurfaceView}.
+ *
+ * @param backend Any of {@link #BACKEND_SURFACE_VIEW BACKEND_*}.
+ */
+ public void setViewBackend(final @ViewBackend int backend) {
+ removeView(mSurfaceWrapper.getView());
+
+ if (backend == BACKEND_SURFACE_VIEW) {
+ mSurfaceWrapper.useSurfaceView(getContext());
+ } else if (backend == BACKEND_TEXTURE_VIEW) {
+ mSurfaceWrapper.useTextureView(getContext());
+ }
+
+ addView(mSurfaceWrapper.getView());
+
+ if (mSession != null) {
+ mSession.getMagnifier().setView(mSurfaceWrapper.getView());
+ }
+ }
+
+ /**
+ * Return whether the view should be pinned on the screen. When pinned, the view should not be
+ * moved on the screen due to animation, scrolling, etc. A common reason for the view being pinned
+ * is when the user is dragging a selection caret inside the view; normal user interaction would
+ * be disrupted in that case if the view was moved on screen.
+ *
+ * @return True if view should be pinned on the screen.
+ */
+ public boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+
+ return mDisplay.shouldPinOnScreen();
+ }
+
+ /**
+ * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion
+ * of the view. Tells gecko where to put bottom fixed elements so they are fully visible.
+ *
+ * <p>Optional call. The display's visible vertical space has changed. Must be called on the
+ * application main thread.
+ *
+ * @param clippingHeight The height of the bottom clipped space in screen pixels.
+ */
+ public void setVerticalClipping(final int clippingHeight) {
+ ThreadUtils.assertOnUiThread();
+
+ mDisplay.setVerticalClipping(clippingHeight);
+ }
+
+ /**
+ * Set the maximum height of the dynamic toolbar(s).
+ *
+ * <p>If there are two or more dynamic toolbars, the height value should be the total amount of
+ * the height of each dynamic toolbar.
+ *
+ * @param height The the maximum height of the dynamic toolbar(s).
+ */
+ public void setDynamicToolbarMaxHeight(final int height) {
+ mDisplay.setDynamicToolbarMaxHeight(height);
+ }
+
+ /* package */ void setActive(final boolean active) {
+ if (mSession != null) {
+ mSession.setActive(active);
+ }
+ }
+
+ // TODO: Bug 1670805 this should really be configurable
+ // Default dark color for about:blank, keep it in sync with PresShell.cpp
+ static final int DEFAULT_DARK_COLOR = 0xFF2A2A2E;
+
+ private int defaultColor() {
+ // If the app set a default color, just use that
+ if (mLastCoverColor != null) {
+ return mLastCoverColor;
+ }
+
+ if (mSession == null || !mSession.isOpen()) {
+ return Color.WHITE;
+ }
+
+ // ... otherwise use the prefers-color-scheme color
+ return mSession.getRuntime().usesDarkTheme() ? DEFAULT_DARK_COLOR : Color.WHITE;
+ }
+
+ /**
+ * Unsets the current session from this instance and returns it, if any. You must call this before
+ * {@link #setSession(GeckoSession)} if there is already an open session set for this instance.
+ *
+ * <p>Note: this method does not close the session and the session remains active. The caller is
+ * responsible for calling {@link GeckoSession#close()} when appropriate.
+ *
+ * @return The {@link GeckoSession} that was set for this instance. May be null.
+ */
+ @UiThread
+ public @Nullable GeckoSession releaseSession() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession == null) {
+ return null;
+ }
+
+ final GeckoSession session = mSession;
+ mSession.releaseDisplay(mDisplay.release());
+ mSession.getOverscrollEdgeEffect().setInvalidationCallback(null);
+ mSession.getOverscrollEdgeEffect().setSession(null);
+ mSession.getCompositorController().setFirstPaintCallback(null);
+
+ if (mSession.getAccessibility().getView() == this) {
+ mSession.getAccessibility().setView(null);
+ }
+
+ if (mSession.getTextInput().getView() == this) {
+ mSession.getTextInput().setView(null);
+ }
+
+ if (mSession.getSelectionActionDelegate() == mSelectionActionDelegate) {
+ mSession.setSelectionActionDelegate(null);
+ }
+
+ if (mSession.getAutofillDelegate() == mAutofillDelegate) {
+ mSession.setAutofillDelegate(null);
+ }
+
+ if (mSession.getPrintDelegate() == mPrintDelegate) {
+ session.setPrintDelegate(null);
+ }
+
+ if (mSession.getMagnifier().getView() == mSurfaceWrapper.getView()) {
+ session.getMagnifier().setView(null);
+ }
+
+ if (isFocused()) {
+ mSession.setFocused(false);
+ }
+ mSession = null;
+ mIsSessionPoisoned = false;
+ session.releaseOwner();
+ return session;
+ }
+
+ private final GeckoSession.Owner mSessionOwner =
+ new GeckoSession.Owner() {
+ @Override
+ public void onRelease() {
+ // The session that we own is being owned by some other object so we need to release it
+ // here.
+ releaseSession();
+ // The session associated to this GeckoView is now invalid, but the app is not aware of
+ // it. We cannot display this GeckoView until the app sets a session again (or releases
+ // the poisoned session).
+ mIsSessionPoisoned = true;
+ }
+ };
+
+ /**
+ * Attach a session to this view. If this instance already has an open session, you must use
+ * {@link #releaseSession()} first, otherwise {@link IllegalStateException} will be thrown. This
+ * is to avoid potentially leaking the currently opened session.
+ *
+ * @param session The session to be attached.
+ * @throws IllegalArgumentException if an existing open session is already set.
+ */
+ @UiThread
+ public void setSession(@NonNull final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+
+ if (session == mSession) {
+ // Nothing to do
+ return;
+ }
+
+ releaseSession();
+
+ session.setOwner(mSessionOwner);
+ mSession = session;
+ mIsSessionPoisoned = false;
+
+ // Make sure the clear color is set to the default
+ mSession.getCompositorController().setClearColor(defaultColor());
+
+ if (ViewCompat.isAttachedToWindow(this)) {
+ mDisplay.acquire(session.acquireDisplay());
+ }
+
+ final Context context = getContext();
+ session.getOverscrollEdgeEffect().setTheme(context);
+ session.getOverscrollEdgeEffect().setSession(session);
+ session
+ .getOverscrollEdgeEffect()
+ .setInvalidationCallback(
+ new Runnable() {
+ @Override
+ public void run() {
+ GeckoView.this.postInvalidateOnAnimation();
+ }
+ });
+
+ final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ final TypedValue outValue = new TypedValue();
+ if (context
+ .getTheme()
+ .resolveAttribute(android.R.attr.listPreferredItemHeight, outValue, true)) {
+ session.getPanZoomController().setScrollFactor(outValue.getDimension(metrics));
+ } else {
+ session.getPanZoomController().setScrollFactor(0.075f * metrics.densityDpi);
+ }
+
+ session.getCompositorController().setFirstPaintCallback(this::uncover);
+
+ if (session.getTextInput().getView() == null) {
+ session.getTextInput().setView(this);
+ }
+
+ if (session.getAccessibility().getView() == null) {
+ session.getAccessibility().setView(this);
+ }
+
+ if (session.getSelectionActionDelegate() == null && mSelectionActionDelegate != null) {
+ session.setSelectionActionDelegate(mSelectionActionDelegate);
+ }
+
+ if (mAutofillEnabled) {
+ session.setAutofillDelegate(mAutofillDelegate);
+ }
+
+ if (session.getMagnifier().getView() == null) {
+ session.getMagnifier().setView(mSurfaceWrapper.getView());
+ }
+
+ if (session.getPrintDelegate() == null && mPrintDelegate != null) {
+ session.setPrintDelegate(mPrintDelegate);
+ }
+
+ if (isFocused()) {
+ session.setFocused(true);
+ }
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable GeckoSession getSession() {
+ return mSession;
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ EventDispatcher getEventDispatcher() {
+ return mSession.getEventDispatcher();
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull PanZoomController getPanZoomController() {
+ ThreadUtils.assertOnUiThread();
+ return mSession.getPanZoomController();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ if (mIsSessionPoisoned) {
+ throw new IllegalStateException("Trying to display a view with invalid session.");
+ }
+ if (mSession != null) {
+ final GeckoRuntime runtime = mSession.getRuntime();
+ if (runtime != null) {
+ runtime.orientationChanged();
+ }
+ }
+
+ if (mSession != null) {
+ mDisplay.acquire(mSession.acquireDisplay());
+ }
+
+ super.onAttachedToWindow();
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (mSession == null) {
+ return;
+ }
+
+ // Release the display before we detach from the window.
+ mSession.releaseDisplay(mDisplay.release());
+ }
+
+ @Override
+ protected void onConfigurationChanged(final Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ if (mSession != null) {
+ final GeckoRuntime runtime = mSession.getRuntime();
+ if (runtime != null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // If API is 31+, DisplayManager API may report previous information.
+ // So we have to report it again. But since Configuration.orientation may still have
+ // previous information even if onConfigurationChanged is called, we have to calculate it
+ // from display data.
+ runtime.orientationChanged();
+ }
+
+ runtime.configurationChanged(newConfig);
+ }
+ }
+ }
+
+ @Override
+ public boolean gatherTransparentRegion(final Region region) {
+ // For detecting changes in SurfaceView layout, we take a shortcut here and
+ // override gatherTransparentRegion, instead of registering a layout listener,
+ // which is more expensive.
+ if (mSurfaceWrapper != null) {
+ mDisplay.onGlobalLayout();
+ }
+ return super.gatherTransparentRegion(region);
+ }
+
+ @Override
+ public void onWindowFocusChanged(final boolean hasWindowFocus) {
+ super.onWindowFocusChanged(hasWindowFocus);
+
+ // Only call setFocus(true) when the window gains focus. Any focus loss could be temporary
+ // (e.g. due to auto-fill popups) and we don't want to call setFocus(false) in those cases.
+ // Instead, we call setFocus(false) in onWindowVisibilityChanged.
+ if (mSession != null && hasWindowFocus && isFocused()) {
+ mSession.setFocused(true);
+ }
+ }
+
+ @Override
+ protected void onWindowVisibilityChanged(final int visibility) {
+ super.onWindowVisibilityChanged(visibility);
+
+ // We can be reasonably sure that the focus loss is not temporary, so call setFocus(false).
+ if (mSession != null && visibility != View.VISIBLE && !hasWindowFocus()) {
+ mSession.setFocused(false);
+ }
+ }
+
+ @Override
+ protected void onFocusChanged(
+ final boolean gainFocus, final int direction, final Rect previouslyFocusedRect) {
+ super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+
+ if (mIsResettingFocus) {
+ return;
+ }
+
+ if (mSession != null) {
+ mSession.setFocused(gainFocus);
+ }
+
+ if (!gainFocus) {
+ return;
+ }
+
+ post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (!isFocused()) {
+ return;
+ }
+
+ final InputMethodManager imm = InputMethods.getInputMethodManager(getContext());
+ // Bug 1404111: Through View#onFocusChanged, the InputMethodManager queues
+ // up a checkFocus call for the next spin of the message loop, so by
+ // posting this Runnable after super#onFocusChanged, the IMM should have
+ // completed its focus change handling at this point and we should be the
+ // active view for input handling.
+
+ // If however onViewDetachedFromWindow for the previously active view gets
+ // called *after* onFocusChanged, but *before* the focus change has been
+ // fully processed by the IMM with the help of checkFocus, the IMM will
+ // lose track of the currently active view, which means that we can't
+ // interact with the IME.
+ if (!imm.isActive(GeckoView.this)) {
+ // If that happens, we bring the IMM's internal state back into sync
+ // by clearing and resetting our focus.
+ mIsResettingFocus = true;
+ clearFocus();
+ // After calling clearFocus we might regain focus automatically, but
+ // we explicitly request it again in case this doesn't happen. If
+ // we've already got the focus back, this will then be a no-op anyway.
+ requestFocus();
+ mIsResettingFocus = false;
+ }
+ }
+ });
+ }
+
+ @Override
+ public Handler getHandler() {
+ if (Build.VERSION.SDK_INT >= 24 || mSession == null) {
+ return super.getHandler();
+ }
+ return mSession.getTextInput().getHandler(super.getHandler());
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(final EditorInfo outAttrs) {
+ if (mSession == null) {
+ return null;
+ }
+ return mSession.getTextInput().onCreateInputConnection(outAttrs);
+ }
+
+ @Override
+ public boolean onKeyPreIme(final int keyCode, final KeyEvent event) {
+ if (super.onKeyPreIme(keyCode, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyPreIme(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyUp(final int keyCode, final KeyEvent event) {
+ if (super.onKeyUp(keyCode, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyUp(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyDown(final int keyCode, final KeyEvent event) {
+ if (super.onKeyDown(keyCode, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyDown(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyLongPress(final int keyCode, final KeyEvent event) {
+ if (super.onKeyLongPress(keyCode, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyLongPress(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyMultiple(final int keyCode, final int repeatCount, final KeyEvent event) {
+ if (super.onKeyMultiple(keyCode, repeatCount, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyMultiple(keyCode, repeatCount, event);
+ }
+
+ @Override
+ public void dispatchDraw(final @Nullable Canvas canvas) {
+ super.dispatchDraw(canvas);
+
+ if (mSession != null) {
+ mSession.getOverscrollEdgeEffect().draw(canvas);
+ }
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public boolean onTouchEvent(final MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ requestFocus();
+ }
+
+ if (mSession == null) {
+ return false;
+ }
+
+ mSession.getPanZoomController().onTouchEvent(event);
+ return true;
+ }
+
+ /**
+ * Dispatches a {@link MotionEvent} to the {@link PanZoomController}. This is the same as {@link
+ * #onTouchEvent(MotionEvent)}, but instead returns a {@link PanZoomController.InputResult}
+ * indicating how the event was handled.
+ *
+ * <p>NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited
+ * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and
+ * unnecessary GC pressure.
+ *
+ * @param event A {@link MotionEvent}
+ * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}.
+ */
+ public @NonNull GeckoResult<PanZoomController.InputResultDetail> onTouchEventForDetailResult(
+ final @NonNull MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ requestFocus();
+ }
+
+ if (mSession == null) {
+ return GeckoResult.fromValue(
+ new PanZoomController.InputResultDetail(
+ PanZoomController.INPUT_RESULT_UNHANDLED,
+ PanZoomController.SCROLLABLE_FLAG_NONE,
+ PanZoomController.OVERSCROLL_FLAG_NONE));
+ }
+
+ // NOTE: Treat mouse events as "touch" rather than as "mouse", so mouse can be
+ // used to pan/zoom. Call onMouseEvent() instead for behavior similar to desktop.
+ return mSession.getPanZoomController().onTouchEventForDetailResult(event);
+ }
+
+ @Override
+ public boolean onGenericMotionEvent(final MotionEvent event) {
+ if (AndroidGamepadManager.handleMotionEvent(event)) {
+ return true;
+ }
+
+ if (mSession == null) {
+ return true;
+ }
+
+ if (mSession.getAccessibility().onMotionEvent(event)) {
+ return true;
+ }
+
+ mSession.getPanZoomController().onMotionEvent(event);
+ return true;
+ }
+
+ @Override
+ public void onProvideAutofillVirtualStructure(final ViewStructure structure, final int flags) {
+ if (mSession == null) {
+ return;
+ }
+
+ final Autofill.Session autofillSession = mSession.getAutofillSession();
+
+ // Let's store the session here in case we need to autofill it later
+ mAutofillSession = new WeakReference<>(autofillSession);
+ autofillSession.fillViewStructure(this, structure, flags);
+ }
+
+ @Override
+ @TargetApi(26)
+ public void autofill(@NonNull final SparseArray<AutofillValue> values) {
+ // Note: we can't use mSession.getAutofillSession() because the app might have swapped
+ // the session under us between the onProvideAutofillVirtualStructure and this call
+ // so mSession could refer to a different session or we might not have a session at all.
+ final Autofill.Session session = mAutofillSession.get();
+ if (session == null) {
+ return;
+ }
+ final SparseArray<CharSequence> strValues = new SparseArray<>(values.size());
+ for (int i = 0; i < values.size(); i++) {
+ final AutofillValue value = values.valueAt(i);
+ if (value.isText()) {
+ // Only text is currently supported.
+ strValues.put(values.keyAt(i), value.getTextValue());
+ }
+ }
+ session.autofill(strValues);
+ }
+
+ @Override
+ public boolean isVisibleToUserForAutofill(final int virtualId) {
+ // If autofill service works with compatibility mode,
+ // View.isVisibleToUserForAutofill walks through the accessibility nodes.
+ // This override avoids it.
+ return true;
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being rendered.
+ *
+ * <p>See {@link GeckoDisplay#capturePixels} for more details.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the currently visible rendered web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capturePixels() {
+ return mDisplay.capturePixels();
+ }
+
+ /**
+ * Sets whether or not this View participates in Android autofill.
+ *
+ * <p>When enabled, this will set an {@link Autofill.Delegate} on the {@link GeckoSession} for
+ * this instance.
+ *
+ * @param enabled Whether or not Android autofill is enabled for this view.
+ */
+ @TargetApi(26)
+ public void setAutofillEnabled(final boolean enabled) {
+ mAutofillEnabled = enabled;
+
+ if (mSession != null) {
+ if (!enabled && mSession.getAutofillDelegate() == mAutofillDelegate) {
+ mSession.setAutofillDelegate(null);
+ } else if (enabled) {
+ mSession.setAutofillDelegate(mAutofillDelegate);
+ }
+ }
+ }
+
+ /**
+ * @return Whether or not Android autofill is enabled for this view.
+ */
+ @TargetApi(26)
+ public boolean getAutofillEnabled() {
+ return mAutofillEnabled;
+ }
+
+ @TargetApi(26)
+ private class AndroidAutofillDelegate implements Autofill.Delegate {
+ AutofillManager mAutofillManager;
+ boolean mDisabled = false;
+
+ private void ensureAutofillManager() {
+ if (mDisabled || mAutofillManager != null) {
+ // Nothing to do
+ return;
+ }
+
+ mAutofillManager = GeckoView.this.getContext().getSystemService(AutofillManager.class);
+ if (mAutofillManager == null) {
+ // If we can't get a reference to the autofill manager, we cannot run the autofill service
+ mDisabled = true;
+ }
+ }
+
+ private Rect displayRectForId(
+ @NonNull final GeckoSession session, @Nullable final Autofill.Node node) {
+ if (node == null) {
+ return new Rect(0, 0, 0, 0);
+ }
+
+ if (!node.getScreenRect().isEmpty()) {
+ return node.getScreenRect();
+ }
+
+ final Matrix matrix = new Matrix();
+ final RectF rectF = new RectF(node.getDimensions());
+ session.getPageToScreenMatrix(matrix);
+ matrix.mapRect(rectF);
+
+ final Rect screenRect = new Rect();
+ rectF.roundOut(screenRect);
+ return screenRect;
+ }
+
+ @Override
+ public void onNodeBlur(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node prev,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewExited(GeckoView.this, data.getId());
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewExited: ", e);
+ }
+ }
+
+ @Override
+ public void onNodeAdd(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ if (!mSession.getAutofillSession().isVisible(node)) {
+ return;
+ }
+ final Autofill.Node focused = mSession.getAutofillSession().getFocused();
+ // We must have a focused node because |node| is visible
+ Objects.requireNonNull(focused);
+
+ final Autofill.NodeData focusedData = mSession.getAutofillSession().dataFor(focused);
+ Objects.requireNonNull(focusedData);
+
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewExited(GeckoView.this, focusedData.getId());
+ mAutofillManager.notifyViewEntered(
+ GeckoView.this, focusedData.getId(), displayRectForId(session, focused));
+ } catch (final SecurityException e) {
+ Log.e(
+ LOGTAG,
+ "Failed to call AutofillManager.notifyViewExited or AutofillManager.notifyViewEntered: ",
+ e);
+ }
+ }
+
+ @Override
+ public void onNodeFocus(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node focused,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewEntered(
+ GeckoView.this, data.getId(), displayRectForId(session, focused));
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewEntered: ", e);
+ }
+ }
+
+ @Override
+ public void onNodeRemove(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {}
+
+ @Override
+ public void onNodeUpdate(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyValueChanged(
+ GeckoView.this, data.getId(), AutofillValue.forText(data.getValue()));
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyValueChanged: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionCancel(final @NonNull GeckoSession session) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ // This line seems necessary for auto-fill to work on the initial page.
+ mAutofillManager.cancel();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionCommit(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.commit();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.commit: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionStart(final @NonNull GeckoSession session) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ // This line seems necessary for auto-fill to work on the initial page.
+ mAutofillManager.cancel();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e);
+ }
+ }
+ }
+
+ /**
+ * This delegate is used to provide the GeckoView an Activity context for certain operations such
+ * as retrieving a PrintManager, which requires an Activity context. Using getContext() directly
+ * might retrieve an Activity context or a Fragment context, this delegate ensures an Activity
+ * context.
+ *
+ * <p>Not to be confused with the GeckoRuntime delegate {@link GeckoRuntime.ActivityDelegate}
+ * which is tightly coupled with WebAuthn - see bug 1671988.
+ */
+ @AnyThread
+ public interface ActivityContextDelegate {
+ /**
+ * Method should return an Activity context. May return null if not available.
+ *
+ * @return Activity context
+ */
+ @Nullable
+ Context getActivityContext();
+ }
+
+ /**
+ * Sets the delegate for the GeckoView.
+ *
+ * @param delegate to provide activity context or null
+ */
+ public void setActivityContextDelegate(final @Nullable ActivityContextDelegate delegate) {
+ mActivityDelegate = delegate;
+ }
+
+ /**
+ * Gets the delegate from the GeckoView.
+ *
+ * @return delegate, if set
+ */
+ public @Nullable ActivityContextDelegate getActivityContextDelegate() {
+ return mActivityDelegate;
+ }
+
+ /**
+ * Retrieves the GeckoView's print delegate.
+ *
+ * @return The GeckoView's print delegate.
+ */
+ public @Nullable GeckoSession.PrintDelegate getPrintDelegate() {
+ return mPrintDelegate;
+ }
+
+ /**
+ * Sets the GeckoView's print delegate.
+ *
+ * @param delegate for printing
+ */
+ public void getPrintDelegate(@Nullable final GeckoSession.PrintDelegate delegate) {
+ mPrintDelegate = delegate;
+ }
+
+ private class GeckoViewPrintDelegate implements GeckoSession.PrintDelegate {
+ public void onPrint(@NonNull final GeckoSession session) {
+ final GeckoResult<InputStream> geckoResult = session.saveAsPdf();
+ geckoResult.accept(
+ pdfStream -> {
+ onPrint(pdfStream);
+ },
+ exception -> Log.e(LOGTAG, "Could not create a content PDF to print.", exception));
+ }
+
+ public void onPrint(@NonNull final InputStream pdfStream) {
+ onPrintWithStatus(pdfStream);
+ }
+
+ public GeckoResult<Boolean> onPrintWithStatus(@NonNull final InputStream pdfStream) {
+ final GeckoResult<Boolean> isDialogFinished = new GeckoResult<Boolean>();
+ if (mActivityDelegate == null) {
+ Log.w(LOGTAG, "Missing an activity context delegate, which is required for printing.");
+ isDialogFinished.completeExceptionally(
+ new GeckoSession.GeckoPrintException(ERROR_NO_ACTIVITY_CONTEXT_DELEGATE));
+ return isDialogFinished;
+ }
+ final Context printContext = mActivityDelegate.getActivityContext();
+ if (printContext == null) {
+ Log.w(LOGTAG, "An activity context is required for printing.");
+ isDialogFinished.completeExceptionally(
+ new GeckoSession.GeckoPrintException(ERROR_NO_ACTIVITY_CONTEXT));
+ return isDialogFinished;
+ }
+ final PrintManager printManager =
+ (PrintManager)
+ mActivityDelegate.getActivityContext().getSystemService(Context.PRINT_SERVICE);
+ final PrintDocumentAdapter pda =
+ new GeckoViewPrintDocumentAdapter(pdfStream, getContext(), isDialogFinished);
+ printManager.print("Firefox", pda, null);
+ return isDialogFinished;
+ }
+ }
+
+ // GeckoDisplay.NewSurfaceProvider
+
+ @Override
+ public void requestNewSurface() {
+ // Toggling the View's visibility is enough to provoke a surfaceChanged callback with a new
+ // Surface on all current versions of Android tested from 5 through to 13. On the more recent of
+ // those versions, however, this does not work when called from within a prior surfaceChanged
+ // callback, which we probably are here. We therefore post a Runnable to toggle the visibility
+ // from outside of the current callback.
+ post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mSurfaceWrapper.getView().setVisibility(View.INVISIBLE);
+ mSurfaceWrapper.getView().setVisibility(View.VISIBLE);
+ }
+ });
+ }
+
+ /** Handle drag and drop event */
+ @Override
+ public boolean onDragEvent(final DragEvent event) {
+ if (mSession == null) {
+ return false;
+ }
+ return mSession.getPanZoomController().onDragEvent(event);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewInputStream.java
new file mode 100644
index 0000000000..97b14f628d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewInputStream.java
@@ -0,0 +1,163 @@
+/* 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.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/** This class provides a Gecko nsIInputStream wrapper for a Java {@link InputStream}. */
+@WrapForJNI
+@AnyThread
+/* package */ class GeckoViewInputStream {
+ private static final String LOGTAG = "GeckoViewInputStream";
+ private static final int BUFFER_SIZE = 4096;
+
+ protected final ByteBuffer mBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
+ private ReadableByteChannel mChannel;
+ private InputStream mIs = null;
+ private boolean mMustGetData = true;
+ private int mPos = 0;
+ private int mSize;
+
+ /**
+ * Set an input stream.
+ *
+ * @param is the {@link InputStream} to set.
+ */
+ protected void setInputStream(final @NonNull InputStream is) {
+ mIs = is;
+ mChannel = Channels.newChannel(is);
+ }
+
+ /**
+ * Check if there is a stream.
+ *
+ * @return true if there is no stream.
+ */
+ public boolean isClosed() {
+ return mIs == null;
+ }
+
+ /**
+ * Called by native code to get the number of available bytes in the underlying stream.
+ *
+ * @return the number of available bytes.
+ */
+ public int available() {
+ if (mIs == null || mSize == -1) {
+ return 0;
+ }
+ try {
+ return Math.max(mIs.available(), mMustGetData ? 0 : mSize - mPos);
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Cannot get the number of available bytes", e);
+ return 0;
+ }
+ }
+
+ /** Close the underlying stream. */
+ public void close() {
+ if (mIs == null) {
+ return;
+ }
+ try {
+ mChannel.close();
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Cannot close the channel", e);
+ } finally {
+ mChannel = null;
+ }
+
+ try {
+ mIs.close();
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Cannot close the stream", e);
+ } finally {
+ mIs = null;
+ }
+ }
+
+ /**
+ * Called by native code to notify that the data have been consumed.
+ *
+ * @param length the number of consumed bytes.
+ * @return the position in the buffer.
+ */
+ public long consumedData(final int length) {
+ mPos += length;
+ if (mPos >= mSize) {
+ mPos = 0;
+ mMustGetData = true;
+ }
+ return mPos;
+ }
+
+ /**
+ * Check that the underlying stream starts with one of the given headers.
+ *
+ * @param headers the headers to check.
+ * @return true if one of the headers is found.
+ */
+ protected boolean checkHeaders(final @NonNull byte[][] headers) throws IOException {
+ read(0);
+ for (final byte[] header : headers) {
+ final int headerLength = header.length;
+ if (mSize < headerLength) {
+ continue;
+ }
+ int i = 0;
+ for (; i < headerLength; i++) {
+ if (mBuffer.get(i) != header[i]) {
+ break;
+ }
+ }
+ if (i == headerLength) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Called by native code to read some bytes in the underlying stream.
+ *
+ * @param aCount the number of bytes to read.
+ * @return the number of read bytes, -1 in case of EOF.
+ * @throws IOException if an error occurs.
+ */
+ @WrapForJNI(exceptionMode = "nsresult")
+ public int read(final long aCount) throws IOException {
+ if (mIs == null) {
+ Log.e(LOGTAG, "The stream is closed.");
+ throw new IllegalStateException("Stream is closed");
+ }
+
+ if (!mMustGetData) {
+ return (int) Math.min((long) (mSize - mPos), aCount);
+ }
+
+ mMustGetData = false;
+ mBuffer.clear();
+
+ try {
+ mSize = mChannel.read(mBuffer);
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Cannot read some bytes", e);
+ throw e;
+ }
+ if (mSize == -1) {
+ return -1;
+ }
+
+ return (int) Math.min((long) mSize, aCount);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java
new file mode 100644
index 0000000000..806343a637
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java
@@ -0,0 +1,233 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.geckoview;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.print.PageRange;
+import android.print.PrintAttributes;
+import android.print.PrintDocumentAdapter;
+import android.print.PrintDocumentInfo;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class GeckoViewPrintDocumentAdapter extends PrintDocumentAdapter {
+ private static final String LOGTAG = "GVPrintDocumentAdapter";
+ private static final String PRINT_NAME_DEFAULT = "Document";
+ private String mPrintName = PRINT_NAME_DEFAULT;
+ private File mPdfFile;
+ private GeckoResult<File> mGeneratedPdfFile;
+ private Boolean mDoDeleteTmpPdf;
+ private GeckoResult<Boolean> mPrintDialogFinish = null;
+
+ /**
+ * Default GeckoView PrintDocumentAdapter to be used with a PrintManager to print documents using
+ * the default Android print functionality. Will make a temporary PDF file from InputStream.
+ *
+ * @param pdfInputStream an input stream containing a PDF
+ * @param context context that should be used for making a temporary file
+ */
+ public GeckoViewPrintDocumentAdapter(
+ @NonNull final InputStream pdfInputStream, @NonNull final Context context) {
+ this.mDoDeleteTmpPdf = true;
+ this.mGeneratedPdfFile = pdfInputStreamToFile(pdfInputStream, context);
+ }
+
+ /**
+ * GeckoView PrintDocumentAdapter to be used with a PrintManager to print documents using the
+ * default Android print functionality. Will make a temporary PDF file from InputStream.
+ *
+ * @param pdfInputStream an input stream containing a PDF
+ * @param context context that should be used for making a temporary file
+ * @param printDialogFinish result to report that the print finished
+ */
+ public GeckoViewPrintDocumentAdapter(
+ @NonNull final InputStream pdfInputStream,
+ @NonNull final Context context,
+ @Nullable final GeckoResult<Boolean> printDialogFinish) {
+ this.mDoDeleteTmpPdf = true;
+ this.mGeneratedPdfFile = pdfInputStreamToFile(pdfInputStream, context);
+ this.mPrintDialogFinish = printDialogFinish;
+ }
+
+ /**
+ * Default GeckoView PrintDocumentAdapter to be used with a PrintManager to print documents using
+ * the default Android print functionality. Will use existing PDF file for rendering. The filename
+ * may be displayed to users.
+ *
+ * <p>Note: Recommend using other constructor if the PDF file still needs to be created so that
+ * the UI reflects progress.
+ *
+ * @param pdfFile PDF file
+ */
+ public GeckoViewPrintDocumentAdapter(@NonNull final File pdfFile) {
+ this.mPdfFile = pdfFile;
+ this.mDoDeleteTmpPdf = false;
+ this.mPrintName = mPdfFile.getName();
+ }
+
+ /**
+ * Writes the PDF InputStream to a file for the PrintDocumentAdapter to use.
+ *
+ * @param pdfInputStream - InputStream containing a PDF
+ * @param context context that should be used for making a temporary file
+ * @return temporary PDF file
+ */
+ @AnyThread
+ public static @Nullable File makeTempPdfFile(
+ @NonNull final InputStream pdfInputStream, @NonNull final Context context) {
+ File file = null;
+ try {
+ file = File.createTempFile("temp", ".pdf", context.getCacheDir());
+ } catch (final IOException ioe) {
+ Log.e(LOGTAG, "Could not make a file in the cache dir: ", ioe);
+ }
+ final int bufferSize = 8192;
+ final byte[] buffer = new byte[bufferSize];
+ try (final OutputStream out = new BufferedOutputStream(new FileOutputStream(file))) {
+ int len;
+ while ((len = pdfInputStream.read(buffer)) != -1) {
+ out.write(buffer, 0, len);
+ }
+ } catch (final IOException ioe) {
+ Log.e(LOGTAG, "Writing temporary PDF file failed: ", ioe);
+ }
+ return file;
+ }
+
+ /**
+ * Utility to make a PDF file from the input stream in the background.
+ *
+ * @param pdfInputStream - InputStream containing a PDF
+ * @param context context that should be used for making a temporary file
+ * @return gecko result with the file
+ */
+ private @NonNull GeckoResult<File> pdfInputStreamToFile(
+ final @NonNull InputStream pdfInputStream, final @NonNull Context context) {
+ final GeckoResult<File> result = new GeckoResult<>();
+ ThreadUtils.postToBackgroundThread(
+ () -> {
+ result.complete(makeTempPdfFile(pdfInputStream, context));
+ });
+ return result;
+ }
+
+ @Override
+ public void onLayout(
+ final PrintAttributes oldAttributes,
+ final PrintAttributes newAttributes,
+ final CancellationSignal cancellationSignal,
+ final LayoutResultCallback layoutResultCallback,
+ final Bundle bundle) {
+ if (cancellationSignal.isCanceled()) {
+ layoutResultCallback.onLayoutCancelled();
+ return;
+ }
+ final PrintDocumentInfo pdi =
+ new PrintDocumentInfo.Builder(mPrintName)
+ .setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
+ .build();
+ layoutResultCallback.onLayoutFinished(pdi, true);
+ }
+
+ /**
+ * Handles onWrite functionality. Recommend running on a background thread as onWrite is on the
+ * main thread.
+ *
+ * @param pdfFile - PDF file to generate print preview with.
+ * @param parcelFileDescriptor - onWrite parcelFileDescriptor
+ * @param writeResultCallback - onWrite writeResultCallback
+ */
+ private void onWritePdf(
+ final @Nullable File pdfFile,
+ final @NonNull ParcelFileDescriptor parcelFileDescriptor,
+ final @NonNull WriteResultCallback writeResultCallback) {
+ InputStream input = null;
+ OutputStream output = null;
+ try {
+ input = new FileInputStream(pdfFile);
+ output = new FileOutputStream(parcelFileDescriptor.getFileDescriptor());
+ final int bufferSize = 8192;
+ final byte[] buffer = new byte[bufferSize];
+ int bytesRead;
+ while ((bytesRead = input.read(buffer)) > 0) {
+ output.write(buffer, 0, bytesRead);
+ }
+ writeResultCallback.onWriteFinished(new PageRange[] {PageRange.ALL_PAGES});
+ } catch (final Exception ex) {
+ Log.e(LOGTAG, "Could not complete onWrite for printing: ", ex);
+ writeResultCallback.onWriteFailed(null);
+ } finally {
+ try {
+ input.close();
+ output.close();
+ } catch (final Exception ex) {
+ Log.e(LOGTAG, "Could not close i/o stream: ", ex);
+ }
+ }
+ }
+
+ @Override
+ public void onWrite(
+ final PageRange[] pageRanges,
+ final ParcelFileDescriptor parcelFileDescriptor,
+ final CancellationSignal cancellationSignal,
+ final WriteResultCallback writeResultCallback) {
+
+ ThreadUtils.postToBackgroundThread(
+ () -> {
+ if (mGeneratedPdfFile != null) {
+ mGeneratedPdfFile.then(
+ file -> {
+ if (mPrintName == PRINT_NAME_DEFAULT) {
+ mPrintName = file.getName();
+ }
+ onWritePdf(file, parcelFileDescriptor, writeResultCallback);
+ return null;
+ });
+ } else {
+ onWritePdf(mPdfFile, parcelFileDescriptor, writeResultCallback);
+ }
+ });
+ }
+
+ @Override
+ public void onFinish() {
+ // Remove the temporary file when the printing system is finished.
+ try {
+ if (mDoDeleteTmpPdf) {
+ if (mPdfFile != null) {
+ mPdfFile.delete();
+ }
+ if (mGeneratedPdfFile != null) {
+ mGeneratedPdfFile.then(
+ file -> {
+ file.delete();
+ return null;
+ });
+ }
+ }
+ } catch (final NullPointerException npe) {
+ // Silence the exception. We only want to delete a real file. We don't
+ // care if the file doesn't exist.
+ }
+ if (this.mPrintDialogFinish != null) {
+ mPrintDialogFinish.complete(true);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java
new file mode 100644
index 0000000000..1546451056
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java
@@ -0,0 +1,189 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.InetAddress;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Locale;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * GeckoWebExecutor is responsible for fetching a {@link WebRequest} and delivering a {@link
+ * WebResponse} to the caller via {@link #fetch(WebRequest)}. Example:
+ *
+ * <pre>
+ * final GeckoWebExecutor executor = new GeckoWebExecutor();
+ *
+ * final GeckoResult&lt;WebResponse&gt; result = executor.fetch(
+ * new WebRequest.Builder("https://example.org/json")
+ * .header("Accept", "application/json")
+ * .build());
+ *
+ * result.then(response -&gt; {
+ * // Do something with response
+ * });
+ * </pre>
+ */
+@AnyThread
+public class GeckoWebExecutor {
+ // We don't use this right now because we access GeckoThread directly, but
+ // it's future-proofing for a world where we allow multiple GeckoRuntimes.
+ private final GeckoRuntime mRuntime;
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "Fetch")
+ private static native void nativeFetch(
+ WebRequest request, int flags, GeckoResult<WebResponse> result);
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "Resolve")
+ private static native void nativeResolve(String host, GeckoResult<InetAddress[]> result);
+
+ @WrapForJNI(calledFrom = "gecko", exceptionMode = "nsresult")
+ private static ByteBuffer createByteBuffer(final int capacity) {
+ return ByteBuffer.allocateDirect(capacity);
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ FETCH_FLAGS_NONE,
+ FETCH_FLAGS_ANONYMOUS,
+ FETCH_FLAGS_NO_REDIRECTS,
+ FETCH_FLAGS_PRIVATE,
+ FETCH_FLAGS_STREAM_FAILURE_TEST,
+ })
+ public @interface FetchFlags {}
+
+ /** No special treatment. */
+ public static final int FETCH_FLAGS_NONE = 0;
+
+ /** Don't send cookies or other user data along with the request. */
+ @WrapForJNI public static final int FETCH_FLAGS_ANONYMOUS = 1;
+
+ /** Don't automatically follow redirects. */
+ @WrapForJNI public static final int FETCH_FLAGS_NO_REDIRECTS = 1 << 1;
+
+ // There was supposed to be another flag, which we then decided not to implement.
+ // That's the reason there's no value 1 << 2, and it can absolutely be used :)
+
+ /** Associates this download with the current private browsing session */
+ @WrapForJNI public static final int FETCH_FLAGS_PRIVATE = 1 << 3;
+
+ /** This flag causes a read error in the {@link WebResponse} body. Useful for testing. */
+ @WrapForJNI public static final int FETCH_FLAGS_STREAM_FAILURE_TEST = 1 << 10;
+
+ /**
+ * Create a new GeckoWebExecutor instance.
+ *
+ * @param runtime A GeckoRuntime instance
+ */
+ public GeckoWebExecutor(final @NonNull GeckoRuntime runtime) {
+ mRuntime = runtime;
+ }
+
+ /**
+ * Send the given {@link WebRequest}.
+ *
+ * @param request A {@link WebRequest} instance
+ * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the
+ * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a
+ * {@link WebRequestError}.
+ * @throws IllegalArgumentException if request is null or otherwise unusable.
+ */
+ public @NonNull GeckoResult<WebResponse> fetch(final @NonNull WebRequest request) {
+ return fetch(request, FETCH_FLAGS_NONE);
+ }
+
+ /**
+ * Send the given {@link WebRequest} with specified flags.
+ *
+ * @param request A {@link WebRequest} instance
+ * @param flags The specified flags. One or more of the {@link #FETCH_FLAGS_NONE FETCH_*} flags.
+ * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the
+ * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a
+ * {@link WebRequestError}.
+ * @throws IllegalArgumentException if request is null or otherwise unusable.
+ */
+ public @NonNull GeckoResult<WebResponse> fetch(
+ final @NonNull WebRequest request, final @FetchFlags int flags) {
+ if (request.body != null && !request.body.isDirect()) {
+ throw new IllegalArgumentException("Request body must be a direct ByteBuffer");
+ }
+
+ if (request.cacheMode < WebRequest.CACHE_MODE_FIRST
+ || request.cacheMode > WebRequest.CACHE_MODE_LAST) {
+ throw new IllegalArgumentException("Unknown cache mode");
+ }
+
+ final String uri = request.uri.toLowerCase(Locale.ROOT);
+ // We don't need to fully validate the URI here, just a sanity check
+ if (!uri.startsWith("http") && !uri.startsWith("blob")) {
+ throw new IllegalArgumentException(
+ "Unsupported URI scheme: " + (uri.length() > 10 ? uri.substring(0, 10) : uri));
+ }
+
+ final GeckoResult<WebResponse> result = new GeckoResult<>();
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeFetch(request, flags, result);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ this,
+ "nativeFetch",
+ WebRequest.class,
+ request,
+ flags,
+ GeckoResult.class,
+ result);
+ }
+
+ return result;
+ }
+
+ /**
+ * Resolves the specified host name.
+ *
+ * @param host An Internet host name, e.g. mozilla.org.
+ * @return A {@link GeckoResult} which will be fulfilled with a {@link List} of {@link
+ * InetAddress}. In case of failure, the {@link GeckoResult} will be completed exceptionally
+ * with a {@link java.net.UnknownHostException}.
+ */
+ public @NonNull GeckoResult<InetAddress[]> resolve(final @NonNull String host) {
+ final GeckoResult<InetAddress[]> result = new GeckoResult<>();
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeResolve(host, result);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ this,
+ "nativeResolve",
+ String.class,
+ host,
+ GeckoResult.class,
+ result);
+ }
+ return result;
+ }
+
+ /**
+ * This causes a speculative connection to be made to the host in the specified URI. This is
+ * useful if an app thinks it may be making a request to that host in the near future. If no
+ * request is made, the connection will be cleaned up after an unspecified amount of time.
+ *
+ * @param uri A URI String.
+ */
+ public void speculativeConnect(final @NonNull String uri) {
+ GeckoThread.speculativeConnect(uri);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java
new file mode 100644
index 0000000000..34bf6b0161
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java
@@ -0,0 +1,54 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.Bitmap;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ImageResource;
+
+/** Represents an Web API image resource as used in web app manifests and media session metadata. */
+@AnyThread
+public class Image {
+ private final ImageResource.Collection mCollection;
+
+ /* package */ Image(final ImageResource.Collection collection) {
+ mCollection = collection;
+ }
+
+ /* package */ static Image fromSizeSrcBundle(final GeckoBundle bundle) {
+ return new Image(ImageResource.Collection.fromSizeSrcBundle(bundle));
+ }
+
+ /**
+ * Get the best version of this image for size <code>size</code>. Embedders are encouraged to
+ * cache the result of this method keyed with this instance.
+ *
+ * @param size pixel size at which this image will be displayed at.
+ * @return A {@link GeckoResult} that resolves to the bitmap when ready. Will resolve
+ * exceptionally to {@link ImageProcessingException} if the image cannot be processed.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> getBitmap(final int size) {
+ return mCollection.getBitmap(size);
+ }
+
+ /** Thrown whenever an image cannot be processed by {@link #getBitmap} */
+ @WrapForJNI
+ public static class ImageProcessingException extends RuntimeException {
+ /**
+ * Build an instance of this class.
+ *
+ * @param message description of the error.
+ */
+ public ImageProcessingException(final String message) {
+ super(message);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
new file mode 100644
index 0000000000..a662b3a82d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
@@ -0,0 +1,645 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+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 org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ImageResource;
+
+/**
+ * The MediaSession API provides media controls and events for a GeckoSession. This includes support
+ * for the DOM Media Session API and regular HTML media content.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaSession">Media Session
+ * API</a>
+ */
+@UiThread
+public class MediaSession {
+ private static final String LOGTAG = "MediaSession";
+ private static final boolean DEBUG = false;
+
+ private final GeckoSession mSession;
+ private boolean mIsActive;
+
+ protected MediaSession(final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Get whether the media session is active. Only active media sessions can be controlled.
+ *
+ * <p>Changes in the active state are notified via {@link Delegate#onActivated} and {@link
+ * Delegate#onDeactivated} respectively.
+ *
+ * @see MediaSession.Delegate#onActivated
+ * @see MediaSession.Delegate#onDeactivated
+ * @return True if this media session is active, false otherwise.
+ */
+ public boolean isActive() {
+ return mIsActive;
+ }
+
+ /* package */ void setActive(final boolean active) {
+ mIsActive = active;
+ }
+
+ /** Pause playback for the media session. */
+ public void pause() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "pause");
+ }
+ mSession.getEventDispatcher().dispatch(PAUSE_EVENT, null);
+ }
+
+ /** Stop playback for the media session. */
+ public void stop() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "stop");
+ }
+ mSession.getEventDispatcher().dispatch(STOP_EVENT, null);
+ }
+
+ /** Start playback for the media session. */
+ public void play() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "play");
+ }
+ mSession.getEventDispatcher().dispatch(PLAY_EVENT, null);
+ }
+
+ /**
+ * Seek to a specific time. Prefer using fast seeking when calling this in a sequence. Don't use
+ * fast seeking for the last or only call in a sequence.
+ *
+ * @param time The time in seconds to move the playback time to.
+ * @param fast Whether fast seeking should be used.
+ */
+ public void seekTo(final double time, final boolean fast) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekTo: time=" + time + ", fast=" + fast);
+ }
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putDouble("time", time);
+ bundle.putBoolean("fast", fast);
+ mSession.getEventDispatcher().dispatch(SEEK_TO_EVENT, bundle);
+ }
+
+ /** Seek forward by a sensible number of seconds. */
+ public void seekForward() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekForward");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putDouble("offset", 0.0);
+ mSession.getEventDispatcher().dispatch(SEEK_FORWARD_EVENT, bundle);
+ }
+
+ /** Seek backward by a sensible number of seconds. */
+ public void seekBackward() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekBackward");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putDouble("offset", 0.0);
+ mSession.getEventDispatcher().dispatch(SEEK_BACKWARD_EVENT, bundle);
+ }
+
+ /**
+ * Select and play the next track. Move playback to the next item in the playlist when supported.
+ */
+ public void nextTrack() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "nextTrack");
+ }
+ mSession.getEventDispatcher().dispatch(NEXT_TRACK_EVENT, null);
+ }
+
+ /**
+ * Select and play the previous track. Move playback to the previous item in the playlist when
+ * supported.
+ */
+ public void previousTrack() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "previousTrack");
+ }
+ mSession.getEventDispatcher().dispatch(PREV_TRACK_EVENT, null);
+ }
+
+ /** Skip the advertisement that is currently playing. */
+ public void skipAd() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "skipAd");
+ }
+ mSession.getEventDispatcher().dispatch(SKIP_AD_EVENT, null);
+ }
+
+ /**
+ * Set whether audio should be muted. Muting audio is supported by default and does not require
+ * the media session to be active.
+ *
+ * @param mute True if audio for this media session should be muted.
+ */
+ public void muteAudio(final boolean mute) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "muteAudio=" + mute);
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putBoolean("mute", mute);
+ mSession.getEventDispatcher().dispatch(MUTE_AUDIO_EVENT, bundle);
+ }
+
+ /** Implement this delegate to receive media session events. */
+ @UiThread
+ public interface Delegate {
+ /**
+ * Notify that the given media session has become active. It is always the first event
+ * dispatched for a new or previously deactivated media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onActivated(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that the given media session has become inactive. Inactive media sessions can not be
+ * controlled.
+ *
+ * <p>TODO: Add settings links to control behavior.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onDeactivated(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify on updated metadata. Metadata may be provided by content via the DOM API or by
+ * GeckoView when not availble.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param meta The updated metadata.
+ */
+ default void onMetadata(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @NonNull final Metadata meta) {}
+
+ /**
+ * Notify on updated supported features. Unsupported actions will have no effect.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param features A combination of {@link Feature}.
+ */
+ default void onFeatures(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @MSFeature final long features) {}
+
+ /**
+ * Notify that playback has started for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onPlay(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that playback has paused for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onPause(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that playback has stopped for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onStop(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify on updated position state.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param state An instance of {@link PositionState}.
+ */
+ default void onPositionState(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @NonNull final PositionState state) {}
+
+ /**
+ * Notify on changed fullscreen state.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param enabled True when this media session in in fullscreen mode.
+ * @param meta An instance of {@link ElementMetadata}, if enabled.
+ */
+ default void onFullscreen(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ final boolean enabled,
+ @Nullable final ElementMetadata meta) {}
+ }
+
+ /** The representation of a media element's metadata. */
+ public static class ElementMetadata {
+ /** The media source URI. */
+ public final @Nullable String source;
+
+ /** The duration of the media in seconds. 0.0 if unknown. */
+ public final double duration;
+
+ /** The width of the video in device pixels. 0 if unknown. */
+ public final long width;
+
+ /** The height of the video in device pixels. 0 if unknown. */
+ public final long height;
+
+ /** The number of audio tracks contained in this element. */
+ public final int audioTrackCount;
+
+ /** The number of video tracks contained in this element. */
+ public final int videoTrackCount;
+
+ /**
+ * ElementMetadata constructor.
+ *
+ * @param source The media URI.
+ * @param duration The media duration in seconds.
+ * @param width The video width in device pixels.
+ * @param height The video height in device pixels.
+ * @param audioTrackCount The audio track count.
+ * @param videoTrackCount The video track count.
+ */
+ public ElementMetadata(
+ @Nullable final String source,
+ final double duration,
+ final long width,
+ final long height,
+ final int audioTrackCount,
+ final int videoTrackCount) {
+ this.source = source;
+ this.duration = duration;
+ this.width = width;
+ this.height = height;
+ this.audioTrackCount = audioTrackCount;
+ this.videoTrackCount = videoTrackCount;
+ }
+
+ /* package */ static @NonNull ElementMetadata fromBundle(final GeckoBundle bundle) {
+ // Sync with MediaUtils.sys.mjs.
+ return new ElementMetadata(
+ bundle.getString("src"),
+ bundle.getDouble("duration", 0.0),
+ bundle.getLong("width", 0),
+ bundle.getLong("height", 0),
+ bundle.getInt("audioTrackCount", 0),
+ bundle.getInt("videoTrackCount", 0));
+ }
+ }
+
+ /** The representation of a media session's metadata. */
+ public static class Metadata {
+ /** The media title. May be backfilled based on the document's title. May be null or empty. */
+ public final @Nullable String title;
+
+ /** The media artist name. May be null or empty. */
+ public final @Nullable String artist;
+
+ /** The media album title. May be null or empty. */
+ public final @Nullable String album;
+
+ /** The media artwork image. May be null. */
+ public final @Nullable Image artwork;
+
+ /**
+ * Metadata constructor.
+ *
+ * @param title The media title string.
+ * @param artist The media artist string.
+ * @param album The media album string.
+ * @param artwork The media artwork {@link Image}.
+ */
+ protected Metadata(
+ final @Nullable String title,
+ final @Nullable String artist,
+ final @Nullable String album,
+ final @Nullable Image artwork) {
+ this.title = title;
+ this.artist = artist;
+ this.album = album;
+ this.artwork = artwork;
+ }
+
+ @AnyThread
+ /* package */ static final class Builder {
+ private final GeckoBundle mBundle;
+
+ public Builder(final GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ public Builder(final Metadata meta) {
+ mBundle = meta.toBundle();
+ }
+
+ @NonNull
+ Builder title(final @Nullable String title) {
+ mBundle.putString("title", title);
+ return this;
+ }
+
+ @NonNull
+ Builder artist(final @Nullable String artist) {
+ mBundle.putString("artist", artist);
+ return this;
+ }
+
+ @NonNull
+ Builder album(final @Nullable String album) {
+ mBundle.putString("album", album);
+ return this;
+ }
+ }
+
+ /* package */ static @NonNull Metadata fromBundle(final GeckoBundle bundle) {
+ final GeckoBundle[] artworkBundles = bundle.getBundleArray("artwork");
+
+ final ImageResource.Collection.Builder artworkBuilder =
+ new ImageResource.Collection.Builder();
+
+ for (final GeckoBundle artworkBundle : artworkBundles) {
+ artworkBuilder.add(ImageResource.fromBundle(artworkBundle));
+ }
+
+ return new Metadata(
+ bundle.getString("title"),
+ bundle.getString("artist"),
+ bundle.getString("album"),
+ new Image(artworkBuilder.build()));
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(3);
+ bundle.putString("title", title);
+ bundle.putString("artist", artist);
+ bundle.putString("album", album);
+ return bundle;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Metadata {");
+ builder
+ .append(", title=")
+ .append(title)
+ .append(", artist=")
+ .append(artist)
+ .append(", album=")
+ .append(album)
+ .append(", artwork=")
+ .append(artwork)
+ .append("}");
+ return builder.toString();
+ }
+ }
+
+ /** Holds the details of the media session's playback state. */
+ public static class PositionState {
+ /** The duration of the media in seconds. */
+ public final double duration;
+
+ /** The last reported media playback position in seconds. */
+ public final double position;
+
+ /**
+ * The media playback rate coefficient. The rate is positive for forward and negative for
+ * backward playback.
+ */
+ public final double playbackRate;
+
+ /**
+ * PositionState constructor.
+ *
+ * @param duration The media duration in seconds.
+ * @param position The current media playback position in seconds.
+ * @param playbackRate The playback rate coefficient.
+ */
+ protected PositionState(
+ final double duration, final double position, final double playbackRate) {
+ this.duration = duration;
+ this.position = position;
+ this.playbackRate = playbackRate;
+ }
+
+ /* package */ static @NonNull PositionState fromBundle(final GeckoBundle bundle) {
+ return new PositionState(
+ bundle.getDouble("duration"),
+ bundle.getDouble("position"),
+ bundle.getDouble("playbackRate"));
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("PositionState {");
+ builder
+ .append("duration=")
+ .append(duration)
+ .append(", position=")
+ .append(position)
+ .append(", playbackRate=")
+ .append(playbackRate)
+ .append("}");
+ return builder.toString();
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {
+ Feature.NONE,
+ Feature.PLAY,
+ Feature.PAUSE,
+ Feature.STOP,
+ Feature.SEEK_TO,
+ Feature.SEEK_FORWARD,
+ Feature.SEEK_BACKWARD,
+ Feature.SKIP_AD,
+ Feature.NEXT_TRACK,
+ Feature.PREVIOUS_TRACK,
+ // Feature.SET_VIDEO_SURFACE
+ })
+ public @interface MSFeature {}
+
+ /** Flags for supported media session features. */
+ public static class Feature {
+ public static final long NONE = 0;
+
+ /** Playback supported. */
+ public static final long PLAY = 1 << 0;
+
+ /** Pausing supported. */
+ public static final long PAUSE = 1 << 1;
+
+ /** Stopping supported. */
+ public static final long STOP = 1 << 2;
+
+ /** Absolute seeking supported. */
+ public static final long SEEK_TO = 1 << 3;
+
+ /** Relative seeking supported (forward). */
+ public static final long SEEK_FORWARD = 1 << 4;
+
+ /** Relative seeking supported (backward). */
+ public static final long SEEK_BACKWARD = 1 << 5;
+
+ /** Skipping advertisements supported. */
+ public static final long SKIP_AD = 1 << 6;
+
+ /** Next track selection supported. */
+ public static final long NEXT_TRACK = 1 << 7;
+
+ /** Previous track selection supported. */
+ public static final long PREVIOUS_TRACK = 1 << 8;
+
+ /** Focusing supported. */
+ public static final long FOCUS = 1 << 9;
+
+ // /**
+ // * Custom video surface supported.
+ // */
+ // public static final long SET_VIDEO_SURFACE = 1 << 10;
+
+ /* package */ static long fromBundle(final GeckoBundle bundle) {
+ // Sync with MediaController.webidl.
+ return NONE
+ | (bundle.getBoolean("play") ? PLAY : NONE)
+ | (bundle.getBoolean("pause") ? PAUSE : NONE)
+ | (bundle.getBoolean("stop") ? STOP : NONE)
+ | (bundle.getBoolean("seekto") ? SEEK_TO : NONE)
+ | (bundle.getBoolean("seekforward") ? SEEK_FORWARD : NONE)
+ | (bundle.getBoolean("seekbackward") ? SEEK_BACKWARD : NONE)
+ | (bundle.getBoolean("nexttrack") ? NEXT_TRACK : NONE)
+ | (bundle.getBoolean("previoustrack") ? PREVIOUS_TRACK : NONE)
+ | (bundle.getBoolean("skipad") ? SKIP_AD : NONE)
+ | (bundle.getBoolean("focus") ? FOCUS : NONE);
+ }
+ }
+
+ private static final String ACTIVATED_EVENT = "GeckoView:MediaSession:Activated";
+ private static final String DEACTIVATED_EVENT = "GeckoView:MediaSession:Deactivated";
+ private static final String METADATA_EVENT = "GeckoView:MediaSession:Metadata";
+ private static final String POSITION_STATE_EVENT = "GeckoView:MediaSession:PositionState";
+ private static final String FEATURES_EVENT = "GeckoView:MediaSession:Features";
+ private static final String FULLSCREEN_EVENT = "GeckoView:MediaSession:Fullscreen";
+ private static final String PLAYBACK_NONE_EVENT = "GeckoView:MediaSession:Playback:None";
+ private static final String PLAYBACK_PAUSED_EVENT = "GeckoView:MediaSession:Playback:Paused";
+ private static final String PLAYBACK_PLAYING_EVENT = "GeckoView:MediaSession:Playback:Playing";
+
+ private static final String PLAY_EVENT = "GeckoView:MediaSession:Play";
+ private static final String PAUSE_EVENT = "GeckoView:MediaSession:Pause";
+ private static final String STOP_EVENT = "GeckoView:MediaSession:Stop";
+ private static final String NEXT_TRACK_EVENT = "GeckoView:MediaSession:NextTrack";
+ private static final String PREV_TRACK_EVENT = "GeckoView:MediaSession:PrevTrack";
+ private static final String SEEK_FORWARD_EVENT = "GeckoView:MediaSession:SeekForward";
+ private static final String SEEK_BACKWARD_EVENT = "GeckoView:MediaSession:SeekBackward";
+ private static final String SKIP_AD_EVENT = "GeckoView:MediaSession:SkipAd";
+ private static final String SEEK_TO_EVENT = "GeckoView:MediaSession:SeekTo";
+ private static final String MUTE_AUDIO_EVENT = "GeckoView:MediaSession:MuteAudio";
+
+ /* package */ static class Handler extends GeckoSessionHandler<MediaSession.Delegate> {
+
+ private final GeckoSession mSession;
+ private final MediaSession mMediaSession;
+
+ public Handler(final GeckoSession session) {
+ super(
+ "GeckoViewMediaControl",
+ session,
+ new String[] {
+ ACTIVATED_EVENT,
+ DEACTIVATED_EVENT,
+ METADATA_EVENT,
+ FULLSCREEN_EVENT,
+ POSITION_STATE_EVENT,
+ PLAYBACK_NONE_EVENT,
+ PLAYBACK_PAUSED_EVENT,
+ PLAYBACK_PLAYING_EVENT,
+ FEATURES_EVENT,
+ });
+ mSession = session;
+ mMediaSession = new MediaSession(session);
+ }
+
+ @Override
+ public void handleMessage(
+ final Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ }
+
+ if (ACTIVATED_EVENT.equals(event)) {
+ mMediaSession.setActive(true);
+ delegate.onActivated(mSession, mMediaSession);
+ } else if (DEACTIVATED_EVENT.equals(event)) {
+ mMediaSession.setActive(false);
+ delegate.onDeactivated(mSession, mMediaSession);
+ } else if (METADATA_EVENT.equals(event)) {
+ final Metadata meta = Metadata.fromBundle(message.getBundle("metadata"));
+ delegate.onMetadata(mSession, mMediaSession, meta);
+ } else if (POSITION_STATE_EVENT.equals(event)) {
+ final PositionState state = PositionState.fromBundle(message.getBundle("state"));
+ delegate.onPositionState(mSession, mMediaSession, state);
+ } else if (PLAYBACK_NONE_EVENT.equals(event)) {
+ delegate.onStop(mSession, mMediaSession);
+ } else if (PLAYBACK_PAUSED_EVENT.equals(event)) {
+ delegate.onPause(mSession, mMediaSession);
+ } else if (PLAYBACK_PLAYING_EVENT.equals(event)) {
+ delegate.onPlay(mSession, mMediaSession);
+ } else if (FEATURES_EVENT.equals(event)) {
+ final long features = Feature.fromBundle(message.getBundle("features"));
+ delegate.onFeatures(mSession, mMediaSession, features);
+ } else if (FULLSCREEN_EVENT.equals(event)) {
+ final boolean enabled = message.getBoolean("enabled");
+ final ElementMetadata meta = ElementMetadata.fromBundle(message.getBundle("metadata"));
+ if (!mMediaSession.isActive()) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Media session is not active yet");
+ }
+ callback.sendSuccess(false);
+ return;
+ }
+ delegate.onFullscreen(mSession, mMediaSession, enabled, meta);
+ callback.sendSuccess(true);
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java
new file mode 100644
index 0000000000..e2a4c236b5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java
@@ -0,0 +1,60 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class OrientationController {
+ private OrientationDelegate mDelegate;
+
+ OrientationController() {}
+
+ /**
+ * Sets the {@link OrientationDelegate} for this instance.
+ *
+ * @param delegate The {@link OrientationDelegate} instance.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable OrientationDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Gets the {@link OrientationDelegate} for this instance.
+ *
+ * @return delegate The {@link OrientationDelegate} instance.
+ */
+ @UiThread
+ @Nullable
+ public OrientationDelegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mDelegate;
+ }
+
+ /** This delegate will be called whenever an orientation lock is called. */
+ @UiThread
+ public interface OrientationDelegate {
+ /**
+ * Called whenever the orientation should be locked.
+ *
+ * @param aOrientation The desired orientation such as ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ * @return A {@link GeckoResult} which resolves to a {@link AllowOrDeny}
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onOrientationLock(@NonNull final int aOrientation) {
+ return null;
+ }
+
+ /** Called whenever the orientation should be unlocked. */
+ @Nullable
+ default void onOrientationUnlock() {}
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java
new file mode 100644
index 0000000000..efd8061c98
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java
@@ -0,0 +1,246 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.BlendMode;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.os.Build;
+import android.widget.EdgeEffect;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.reflect.Field;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public final class OverscrollEdgeEffect {
+ // Used to index particular edges in the edges array
+ private static final int TOP = 0;
+ private static final int BOTTOM = 1;
+ private static final int LEFT = 2;
+ private static final int RIGHT = 3;
+
+ /* package */ static final int AXIS_X = 0;
+ /* package */ static final int AXIS_Y = 1;
+
+ // All four edges of the screen
+ private final EdgeEffect[] mEdges = new EdgeEffect[4];
+
+ private GeckoSession mSession;
+ private Runnable mInvalidationCallback;
+ private int mWidth;
+ private int mHeight;
+
+ /* package */ OverscrollEdgeEffect() {}
+
+ private static Field sPaintField;
+
+ @SuppressLint("DiscouragedPrivateApi")
+ private void setBlendMode(final EdgeEffect edgeEffect) {
+ if (Build.VERSION.SDK_INT < 29) {
+ // setBlendMode is only supported on SDK_INT >= 29 and above.
+
+ if (sPaintField == null) {
+ try {
+ sPaintField = EdgeEffect.class.getDeclaredField("mPaint");
+ sPaintField.setAccessible(true);
+ } catch (final NoSuchFieldException e) {
+ // Cannot get the field, nothing we can do here
+ return;
+ }
+ }
+
+ try {
+ final Paint paint = (Paint) sPaintField.get(edgeEffect);
+ final PorterDuffXfermode mode = new PorterDuffXfermode(PorterDuff.Mode.SRC);
+ paint.setXfermode(mode);
+ } catch (final IllegalAccessException ex) {
+ // Nothing we can do
+ }
+
+ return;
+ }
+
+ edgeEffect.setBlendMode(BlendMode.SRC);
+ }
+
+ /**
+ * Set the theme to use for overscroll from a given Context.
+ *
+ * @param context Context to use for the overscroll theme.
+ */
+ public void setTheme(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+
+ for (int i = 0; i < mEdges.length; i++) {
+ final EdgeEffect edgeEffect = new EdgeEffect(context);
+ if (mWidth != 0 || mHeight != 0) {
+ edgeEffect.setSize(mWidth, mHeight);
+ }
+ setBlendMode(edgeEffect);
+ mEdges[i] = edgeEffect;
+ }
+ }
+
+ /* package */ void setSession(final @Nullable GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Set a Runnable that acts as a callback to invalidate the overscroll effect (for example, as a
+ * response to user fling for example). The Runnbale should schedule a future call to {@link
+ * #draw(Canvas)} as a result of the invalidation.
+ *
+ * @param runnable Invalidation Runnable.
+ * @see #getInvalidationCallback()
+ */
+ public void setInvalidationCallback(final @Nullable Runnable runnable) {
+ ThreadUtils.assertOnUiThread();
+ mInvalidationCallback = runnable;
+ }
+
+ /**
+ * Get the current invalidatation Runnable.
+ *
+ * @return Invalidation Runnable.
+ * @see #setInvalidationCallback(Runnable)
+ */
+ public @Nullable Runnable getInvalidationCallback() {
+ ThreadUtils.assertOnUiThread();
+ return mInvalidationCallback;
+ }
+
+ /* package */ void setSize(final int width, final int height) {
+ mEdges[LEFT].setSize(height, width);
+ mEdges[RIGHT].setSize(height, width);
+ mEdges[TOP].setSize(width, height);
+ mEdges[BOTTOM].setSize(width, height);
+
+ mWidth = width;
+ mHeight = height;
+ }
+
+ private EdgeEffect getEdgeForAxisAndSide(final int axis, final float side) {
+ if (axis == AXIS_Y) {
+ if (side < 0) {
+ return mEdges[TOP];
+ } else {
+ return mEdges[BOTTOM];
+ }
+ } else {
+ if (side < 0) {
+ return mEdges[LEFT];
+ } else {
+ return mEdges[RIGHT];
+ }
+ }
+ }
+
+ /* package */ void setVelocity(final float velocity, final int axis) {
+ if (velocity == 0.0f) {
+ if (axis == AXIS_Y) {
+ mEdges[TOP].onRelease();
+ mEdges[BOTTOM].onRelease();
+ } else {
+ mEdges[LEFT].onRelease();
+ mEdges[RIGHT].onRelease();
+ }
+
+ if (mInvalidationCallback != null) {
+ mInvalidationCallback.run();
+ }
+ return;
+ }
+
+ final EdgeEffect edge = getEdgeForAxisAndSide(axis, velocity);
+
+ // If we're showing overscroll already, start fading it out.
+ if (!edge.isFinished()) {
+ edge.onRelease();
+ } else {
+ // Otherwise, show an absorb effect
+ edge.onAbsorb((int) velocity);
+ }
+
+ if (mInvalidationCallback != null) {
+ mInvalidationCallback.run();
+ }
+ }
+
+ /* package */ void setDistance(final float distance, final int axis) {
+ // The first overscroll event often has zero distance. Throw it out
+ if (distance == 0.0f) {
+ return;
+ }
+
+ final EdgeEffect edge = getEdgeForAxisAndSide(axis, (int) distance);
+ edge.onPull(distance / (axis == AXIS_X ? mWidth : mHeight));
+
+ if (mInvalidationCallback != null) {
+ mInvalidationCallback.run();
+ }
+ }
+
+ /**
+ * Draw the overscroll effect on a Canvas.
+ *
+ * @param canvas Canvas to draw on.
+ */
+ public void draw(final @NonNull Canvas canvas) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession == null) {
+ return;
+ }
+
+ final Rect pageRect = new Rect();
+ mSession.getSurfaceBounds(pageRect);
+
+ // If we're pulling an edge, or fading it out, draw!
+ boolean invalidate = false;
+ if (!mEdges[TOP].isFinished()) {
+ invalidate |= draw(mEdges[TOP], canvas, pageRect.left, pageRect.top, 0);
+ }
+
+ if (!mEdges[BOTTOM].isFinished()) {
+ invalidate |= draw(mEdges[BOTTOM], canvas, pageRect.right, pageRect.bottom, 180);
+ }
+
+ if (!mEdges[LEFT].isFinished()) {
+ invalidate |= draw(mEdges[LEFT], canvas, pageRect.left, pageRect.bottom, 270);
+ }
+
+ if (!mEdges[RIGHT].isFinished()) {
+ invalidate |= draw(mEdges[RIGHT], canvas, pageRect.right, pageRect.top, 90);
+ }
+
+ // If the edge effect is animating off screen, invalidate.
+ if (invalidate && mInvalidationCallback != null) {
+ mInvalidationCallback.run();
+ }
+ }
+
+ private static boolean draw(
+ final EdgeEffect edge,
+ final Canvas canvas,
+ final float translateX,
+ final float translateY,
+ final float rotation) {
+ final int state = canvas.save();
+ canvas.translate(translateX, translateY);
+ canvas.rotate(rotation);
+ final boolean invalidate = edge.draw(canvas);
+ canvas.restoreToCount(state);
+
+ return invalidate;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java
new file mode 100644
index 0000000000..877e0e34a6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java
@@ -0,0 +1,982 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.app.UiModeManager;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.SystemClock;
+import android.util.Log;
+import android.util.Pair;
+import android.view.DragEvent;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoDragAndDrop;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public class PanZoomController {
+ private static final String LOGTAG = "GeckoNPZC";
+ private static final int EVENT_SOURCE_SCROLL = 0;
+ private static final int EVENT_SOURCE_MOTION = 1;
+ private static final int EVENT_SOURCE_MOUSE = 2;
+ private static Boolean sTreatMouseAsTouch = null;
+
+ private final GeckoSession mSession;
+ private final Rect mTempRect = new Rect();
+ private boolean mAttached;
+ private float mPointerScrollFactor = 64.0f;
+ private long mLastDownTime;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({SCROLL_BEHAVIOR_SMOOTH, SCROLL_BEHAVIOR_AUTO})
+ public @interface ScrollBehaviorType {}
+
+ /** Specifies smooth scrolling which animates content to the desired scroll position. */
+ public static final int SCROLL_BEHAVIOR_SMOOTH = 0;
+
+ /** Specifies auto scrolling which jumps content to the desired scroll position. */
+ public static final int SCROLL_BEHAVIOR_AUTO = 1;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ INPUT_RESULT_UNHANDLED,
+ INPUT_RESULT_HANDLED,
+ INPUT_RESULT_HANDLED_CONTENT,
+ INPUT_RESULT_IGNORED
+ })
+ public @interface InputResult {}
+
+ /**
+ * Specifies that an input event was not handled by the PanZoomController for a panning or zooming
+ * operation. The event may have been handled by Web content or internally (e.g. text selection).
+ */
+ @WrapForJNI public static final int INPUT_RESULT_UNHANDLED = 0;
+
+ /**
+ * Specifies that an input event was handled by the PanZoomController for a panning or zooming
+ * operation, but likely not by any touch event listeners in Web content.
+ */
+ @WrapForJNI public static final int INPUT_RESULT_HANDLED = 1;
+
+ /**
+ * Specifies that an input event was handled by the PanZoomController and passed on to touch event
+ * listeners in Web content.
+ */
+ @WrapForJNI public static final int INPUT_RESULT_HANDLED_CONTENT = 2;
+
+ /**
+ * Specifies that an input event was consumed by a PanZoomController internally and browsers
+ * should do nothing in response to the event.
+ */
+ @WrapForJNI public static final int INPUT_RESULT_IGNORED = 3;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SCROLLABLE_FLAG_NONE,
+ SCROLLABLE_FLAG_TOP,
+ SCROLLABLE_FLAG_RIGHT,
+ SCROLLABLE_FLAG_BOTTOM,
+ SCROLLABLE_FLAG_LEFT
+ })
+ public @interface ScrollableDirections {}
+
+ /**
+ * Represents which directions can be scrolled in the scroll container where an input event was
+ * handled. This value is only useful in the case of {@link
+ * PanZoomController#INPUT_RESULT_HANDLED}.
+ */
+ /* The container cannot be scrolled. */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_NONE = 0;
+
+ /* The container cannot be scrolled to top */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_TOP = 1 << 0;
+ /* The container cannot be scrolled to right */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_RIGHT = 1 << 1;
+ /* The container cannot be scrolled to bottom */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_BOTTOM = 1 << 2;
+ /* The container cannot be scrolled to left */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_LEFT = 1 << 3;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {OVERSCROLL_FLAG_NONE, OVERSCROLL_FLAG_HORIZONTAL, OVERSCROLL_FLAG_VERTICAL})
+ public @interface OverscrollDirections {}
+
+ /**
+ * Represents which directions can be over-scrolled in the scroll container where an input event
+ * was handled. This value is only useful in the case of {@link
+ * PanZoomController#INPUT_RESULT_HANDLED}.
+ */
+ /* the container cannot be over-scrolled. */
+ @WrapForJNI public static final int OVERSCROLL_FLAG_NONE = 0;
+
+ /* the container can be over-scrolled horizontally. */
+ @WrapForJNI public static final int OVERSCROLL_FLAG_HORIZONTAL = 1 << 0;
+ /* the container can be over-scrolled vertically. */
+ @WrapForJNI public static final int OVERSCROLL_FLAG_VERTICAL = 1 << 1;
+
+ /**
+ * Represents how a {@link MotionEvent} was handled in Gecko. This value can be used by browser
+ * apps to implement features like pull-to-refresh. Failing to account this value might break some
+ * websites expectations about touch events.
+ *
+ * <p>For example, a {@link PanZoomController.InputResultDetail#handledResult} value of {@link
+ * PanZoomController#INPUT_RESULT_HANDLED} and {@link
+ * PanZoomController.InputResultDetail#overscrollDirections} of {@link
+ * PanZoomController#OVERSCROLL_FLAG_NONE} indicates that the event was consumed for a panning or
+ * zooming operation and that the website does not expect the browser to react to the touch event
+ * (say, by triggering the pull-to-refresh feature) even though the scroll container reached to
+ * the edge.
+ */
+ @WrapForJNI
+ public static class InputResultDetail {
+ protected InputResultDetail(
+ final @InputResult int handledResult,
+ final @ScrollableDirections int scrollableDirections,
+ final @OverscrollDirections int overscrollDirections) {
+ mHandledResult = handledResult;
+ mScrollableDirections = scrollableDirections;
+ mOverscrollDirections = overscrollDirections;
+ }
+
+ /**
+ * @return One of the {@link #INPUT_RESULT_UNHANDLED INPUT_RESULT_*} indicating how the event
+ * was handled.
+ */
+ @AnyThread
+ public @InputResult int handledResult() {
+ return mHandledResult;
+ }
+
+ /**
+ * @return an OR-ed value of {@link #SCROLLABLE_FLAG_NONE SCROLLABLE_FLAG_*} indicating which
+ * directions can be scrollable.
+ */
+ @AnyThread
+ public @ScrollableDirections int scrollableDirections() {
+ return mScrollableDirections;
+ }
+
+ /**
+ * @return an OR-ed value of {@link #OVERSCROLL_FLAG_NONE OVERSCROLL_FLAG_*} indicating which
+ * directions can be over-scrollable.
+ */
+ @AnyThread
+ public @OverscrollDirections int overscrollDirections() {
+ return mOverscrollDirections;
+ }
+
+ private final @InputResult int mHandledResult;
+ private final @ScrollableDirections int mScrollableDirections;
+ private final @OverscrollDirections int mOverscrollDirections;
+ }
+
+ private SynthesizedEventState mPointerState;
+
+ private ArrayList<Pair<Integer, MotionEvent>> mQueuedEvents;
+
+ private boolean mSynthesizedEvent = false;
+
+ @WrapForJNI
+ private static class MotionEventData {
+ public final int action;
+ public final int actionIndex;
+ public final long time;
+ public final int metaState;
+ public final int pointerId[];
+ public final int historySize;
+ public final long historicalTime[];
+ public final float historicalX[];
+ public final float historicalY[];
+ public final float historicalOrientation[];
+ public final float historicalPressure[];
+ public final float historicalToolMajor[];
+ public final float historicalToolMinor[];
+ public final float x[];
+ public final float y[];
+ public final float orientation[];
+ public final float pressure[];
+ public final float toolMajor[];
+ public final float toolMinor[];
+
+ public MotionEventData(final MotionEvent event) {
+ final int count = event.getPointerCount();
+ action = event.getActionMasked();
+ actionIndex = event.getActionIndex();
+ time = event.getEventTime();
+ metaState = event.getMetaState();
+ historySize = event.getHistorySize();
+ historicalTime = new long[historySize];
+ historicalX = new float[historySize * count];
+ historicalY = new float[historySize * count];
+ historicalOrientation = new float[historySize * count];
+ historicalPressure = new float[historySize * count];
+ historicalToolMajor = new float[historySize * count];
+ historicalToolMinor = new float[historySize * count];
+ pointerId = new int[count];
+ x = new float[count];
+ y = new float[count];
+ orientation = new float[count];
+ pressure = new float[count];
+ toolMajor = new float[count];
+ toolMinor = new float[count];
+
+ for (int historyIndex = 0; historyIndex < historySize; historyIndex++) {
+ historicalTime[historyIndex] = event.getHistoricalEventTime(historyIndex);
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ for (int i = 0; i < count; i++) {
+ pointerId[i] = event.getPointerId(i);
+
+ for (int historyIndex = 0; historyIndex < historySize; historyIndex++) {
+ event.getHistoricalPointerCoords(i, historyIndex, coords);
+
+ final int historicalI = historyIndex * count + i;
+ historicalX[historicalI] = coords.x;
+ historicalY[historicalI] = coords.y;
+
+ historicalOrientation[historicalI] = coords.orientation;
+ historicalPressure[historicalI] = coords.pressure;
+
+ // If we are converting to CSS pixels, we should adjust the radii as well.
+ historicalToolMajor[historicalI] = coords.toolMajor;
+ historicalToolMinor[historicalI] = coords.toolMinor;
+ }
+
+ event.getPointerCoords(i, coords);
+
+ x[i] = coords.x;
+ y[i] = coords.y;
+
+ orientation[i] = coords.orientation;
+ pressure[i] = coords.pressure;
+
+ // If we are converting to CSS pixels, we should adjust the radii as well.
+ toolMajor[i] = coords.toolMajor;
+ toolMinor[i] = coords.toolMinor;
+ }
+ }
+ }
+
+ /* package */ final class NativeProvider extends JNIObject {
+ @Override // JNIObject
+ protected void disposeNative() {
+ // Disposal happens in native code.
+ throw new UnsupportedOperationException();
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private native void handleMotionEvent(
+ MotionEventData eventData,
+ float screenX,
+ float screenY,
+ GeckoResult<InputResultDetail> result);
+
+ @WrapForJNI(calledFrom = "ui")
+ private native @InputResult int handleScrollEvent(
+ long time, int metaState, float x, float y, float hScroll, float vScroll);
+
+ @WrapForJNI(calledFrom = "ui")
+ private native @InputResult int handleMouseEvent(
+ int action, long time, int metaState, float x, float y, int buttons);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ private native void handleDragEvent(
+ int action, long time, float x, float y, GeckoDragAndDrop.DropData data);
+
+ @WrapForJNI(stubName = "SetIsLongpressEnabled") // Called from test thread.
+ private native void nativeSetIsLongpressEnabled(boolean isLongpressEnabled);
+
+ @WrapForJNI(calledFrom = "ui")
+ private void synthesizeNativeTouchPoint(
+ final int pointerId,
+ final int eventType,
+ final int clientX,
+ final int clientY,
+ final double pressure,
+ final int orientation) {
+ if (pointerId == PointerInfo.RESERVED_MOUSE_POINTER_ID) {
+ throw new IllegalArgumentException("Pointer ID reserved for mouse");
+ }
+ synthesizeNativePointer(
+ InputDevice.SOURCE_TOUCHSCREEN,
+ pointerId,
+ eventType,
+ clientX,
+ clientY,
+ pressure,
+ orientation,
+ 0);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void synthesizeNativeMouseEvent(
+ final int eventType, final int clientX, final int clientY, final int button) {
+ synthesizeNativePointer(
+ InputDevice.SOURCE_MOUSE,
+ PointerInfo.RESERVED_MOUSE_POINTER_ID,
+ eventType,
+ clientX,
+ clientY,
+ 0,
+ 0,
+ button);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void setAttached(final boolean attached) {
+ if (attached) {
+ mAttached = true;
+ flushEventQueue();
+ } else if (mAttached) {
+ mAttached = false;
+ enableEventQueue();
+ }
+ }
+ }
+
+ /* package */ final NativeProvider mNative = new NativeProvider();
+
+ private void handleMotionEvent(final MotionEvent event) {
+ handleMotionEvent(event, null);
+ }
+
+ private void handleMotionEvent(
+ final MotionEvent event, final GeckoResult<InputResultDetail> result) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOTION, event));
+ if (result != null) {
+ result.complete(
+ new InputResultDetail(
+ INPUT_RESULT_HANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+ return;
+ }
+
+ final int action = event.getActionMasked();
+
+ if (action == MotionEvent.ACTION_DOWN) {
+ mLastDownTime = event.getDownTime();
+ } else if (mLastDownTime != event.getDownTime()) {
+ if (result != null) {
+ result.complete(
+ new InputResultDetail(
+ INPUT_RESULT_UNHANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+ return;
+ }
+
+ final float screenX = event.getRawX() - event.getX();
+ final float screenY = event.getRawY() - event.getY();
+
+ // Take this opportunity to update screen origin of session. This gets
+ // dispatched to the gecko thread, so we also pass the new screen x/y directly to apz.
+ // If this is a synthesized touch, the screen offset is bogus so ignore it.
+ if (!mSynthesizedEvent) {
+ mSession.onScreenOriginChanged((int) screenX, (int) screenY);
+ }
+
+ final MotionEventData data = new MotionEventData(event);
+ mNative.handleMotionEvent(data, screenX, screenY, result);
+ }
+
+ private @InputResult int handleScrollEvent(final MotionEvent event) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_SCROLL, event));
+ return INPUT_RESULT_HANDLED;
+ }
+
+ final int count = event.getPointerCount();
+
+ if (count <= 0) {
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ event.getPointerCoords(0, coords);
+
+ // Translate surface origin to client origin for scroll events.
+ mSession.getSurfaceBounds(mTempRect);
+ final float x = coords.x - mTempRect.left;
+ final float y = coords.y - mTempRect.top;
+
+ final float hScroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL) * mPointerScrollFactor;
+ final float vScroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL) * mPointerScrollFactor;
+
+ return mNative.handleScrollEvent(
+ event.getEventTime(), event.getMetaState(), x, y, hScroll, vScroll);
+ }
+
+ private @InputResult int handleMouseEvent(final MotionEvent event) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOUSE, event));
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final int count = event.getPointerCount();
+
+ if (count <= 0) {
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ event.getPointerCoords(0, coords);
+
+ // Translate surface origin to client origin for mouse events.
+ mSession.getSurfaceBounds(mTempRect);
+ final float x = coords.x - mTempRect.left;
+ final float y = coords.y - mTempRect.top;
+
+ return mNative.handleMouseEvent(
+ event.getActionMasked(),
+ event.getEventTime(),
+ event.getMetaState(),
+ x,
+ y,
+ event.getButtonState());
+ }
+
+ protected PanZoomController(final GeckoSession session) {
+ mSession = session;
+ enableEventQueue();
+ }
+
+ private boolean treatMouseAsTouch() {
+ if (sTreatMouseAsTouch == null) {
+ final Context c = GeckoAppShell.getApplicationContext();
+ if (c == null) {
+ // This might happen if the GeckoRuntime has not been initialized yet.
+ return false;
+ }
+ final UiModeManager m = (UiModeManager) c.getSystemService(Context.UI_MODE_SERVICE);
+ // on TV devices, treat mouse as touch. everywhere else, don't
+ sTreatMouseAsTouch = (m.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION);
+ }
+
+ return sTreatMouseAsTouch;
+ }
+
+ /**
+ * Set the current scroll factor. The scroll factor is the maximum scroll amount that one scroll
+ * event may generate, in device pixels.
+ *
+ * @param factor Scroll factor.
+ */
+ public void setScrollFactor(final float factor) {
+ ThreadUtils.assertOnUiThread();
+ mPointerScrollFactor = factor;
+ }
+
+ /**
+ * Get the current scroll factor.
+ *
+ * @return Scroll factor.
+ */
+ public float getScrollFactor() {
+ ThreadUtils.assertOnUiThread();
+ return mPointerScrollFactor;
+ }
+
+ /**
+ * This is a workaround for touch pad on Android app by Chrome OS. Android app on Chrome OS fires
+ * weird motion event by two finger scroll. See https://crbug.com/704051
+ */
+ private boolean mayTouchpadScroll(final @NonNull MotionEvent event) {
+ final int action = event.getActionMasked();
+ return event.getButtonState() == 0
+ && (action == MotionEvent.ACTION_DOWN
+ || (mLastDownTime == event.getDownTime()
+ && (action == MotionEvent.ACTION_MOVE
+ || action == MotionEvent.ACTION_UP
+ || action == MotionEvent.ACTION_CANCEL)));
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather
+ * than as "mouse". Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onTouchEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!treatMouseAsTouch()
+ && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
+ && !mayTouchpadScroll(event)) {
+ handleMouseEvent(event);
+ return;
+ }
+ handleMotionEvent(event);
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather
+ * than as "mouse". Pointer coordinates should be relative to the display surface.
+ *
+ * <p>NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited
+ * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and
+ * unnecessary GC pressure. Instead, prefer to call {@link #onTouchEvent(MotionEvent)}.
+ *
+ * @param event MotionEvent to process.
+ * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}).
+ */
+ public @NonNull GeckoResult<InputResultDetail> onTouchEventForDetailResult(
+ final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!treatMouseAsTouch()
+ && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
+ && !mayTouchpadScroll(event)) {
+ return GeckoResult.fromValue(
+ new InputResultDetail(
+ handleMouseEvent(event), SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+
+ final GeckoResult<InputResultDetail> result = new GeckoResult<>();
+ handleMotionEvent(event, result);
+ return result;
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "mouse" rather
+ * than as "touch". Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onMouseEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) {
+ return;
+ }
+ handleMotionEvent(event);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ mNative.setAttached(false);
+ }
+
+ /**
+ * Process a non-touch motion event through the pan-zoom controller. Currently, hover and scroll
+ * events are supported. Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onMotionEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ final int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_SCROLL) {
+ if (event.getDownTime() >= mLastDownTime) {
+ mLastDownTime = event.getDownTime();
+ } else if ((InputDevice.getDevice(event.getDeviceId()) != null)
+ && (InputDevice.getDevice(event.getDeviceId()).getSources() & InputDevice.SOURCE_TOUCHPAD)
+ == InputDevice.SOURCE_TOUCHPAD) {
+ return;
+ }
+ handleScrollEvent(event);
+ } else if ((action == MotionEvent.ACTION_HOVER_MOVE)
+ || (action == MotionEvent.ACTION_HOVER_ENTER)
+ || (action == MotionEvent.ACTION_HOVER_EXIT)) {
+ handleMouseEvent(event);
+ }
+ }
+
+ /**
+ * Process a drag event.
+ *
+ * @param event DragEvent to process.
+ * @return true if this event is accepted.
+ */
+ public boolean onDragEvent(@NonNull final DragEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ return false;
+ }
+
+ if (!GeckoDragAndDrop.onDragEvent(event)) {
+ return false;
+ }
+
+ mNative.handleDragEvent(
+ event.getAction(),
+ SystemClock.uptimeMillis(),
+ GeckoDragAndDrop.getLocationX(),
+ GeckoDragAndDrop.getLocationY(),
+ GeckoDragAndDrop.createDropData(event));
+ return true;
+ }
+
+ private void enableEventQueue() {
+ if (mQueuedEvents != null) {
+ throw new IllegalStateException("Already have an event queue");
+ }
+ mQueuedEvents = new ArrayList<>();
+ }
+
+ private void flushEventQueue() {
+ if (mQueuedEvents == null) {
+ return;
+ }
+
+ final ArrayList<Pair<Integer, MotionEvent>> events = mQueuedEvents;
+ mQueuedEvents = null;
+ for (final Pair<Integer, MotionEvent> pair : events) {
+ switch (pair.first) {
+ case EVENT_SOURCE_MOTION:
+ handleMotionEvent(pair.second);
+ break;
+ case EVENT_SOURCE_SCROLL:
+ handleScrollEvent(pair.second);
+ break;
+ case EVENT_SOURCE_MOUSE:
+ handleMouseEvent(pair.second);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Set whether Gecko should generate long-press events.
+ *
+ * @param isLongpressEnabled True if Gecko should generate long-press events.
+ */
+ public void setIsLongpressEnabled(final boolean isLongpressEnabled) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAttached) {
+ mNative.nativeSetIsLongpressEnabled(isLongpressEnabled);
+ }
+ }
+
+ private static class PointerInfo {
+ // We reserve one pointer ID for the mouse, so that tests don't have
+ // to worry about tracking pointer IDs if they just want to test mouse
+ // event synthesization. If somebody tries to use this ID for a
+ // synthesized touch event we'll throw an exception.
+ public static final int RESERVED_MOUSE_POINTER_ID = 100000;
+
+ public int pointerId;
+ public int source;
+ public int surfaceX;
+ public int surfaceY;
+ public double pressure;
+ public int orientation;
+ public int buttonState;
+
+ public MotionEvent.PointerCoords getCoords() {
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ coords.orientation = orientation;
+ coords.pressure = (float) pressure;
+ coords.x = surfaceX;
+ coords.y = surfaceY;
+ return coords;
+ }
+ }
+
+ private static class SynthesizedEventState {
+ public final ArrayList<PointerInfo> pointers;
+ public long downTime;
+
+ SynthesizedEventState() {
+ pointers = new ArrayList<PointerInfo>();
+ }
+
+ int getPointerIndex(final int pointerId) {
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).pointerId == pointerId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ int addPointer(final int pointerId, final int source) {
+ final PointerInfo info = new PointerInfo();
+ info.pointerId = pointerId;
+ info.source = source;
+ pointers.add(info);
+ return pointers.size() - 1;
+ }
+
+ int getPointerCount(final int source) {
+ int count = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ int getPointerButtonState(final int source) {
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ return pointers.get(i).buttonState;
+ }
+ }
+ return 0;
+ }
+
+ MotionEvent.PointerProperties[] getPointerProperties(final int source) {
+ final MotionEvent.PointerProperties[] props =
+ new MotionEvent.PointerProperties[getPointerCount(source)];
+ int index = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ final MotionEvent.PointerProperties p = new MotionEvent.PointerProperties();
+ p.id = pointers.get(i).pointerId;
+ switch (source) {
+ case InputDevice.SOURCE_TOUCHSCREEN:
+ p.toolType = MotionEvent.TOOL_TYPE_FINGER;
+ break;
+ case InputDevice.SOURCE_MOUSE:
+ p.toolType = MotionEvent.TOOL_TYPE_MOUSE;
+ break;
+ }
+ props[index++] = p;
+ }
+ }
+ return props;
+ }
+
+ MotionEvent.PointerCoords[] getPointerCoords(final int source) {
+ final MotionEvent.PointerCoords[] coords =
+ new MotionEvent.PointerCoords[getPointerCount(source)];
+ int index = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ coords[index++] = pointers.get(i).getCoords();
+ }
+ }
+ return coords;
+ }
+ }
+
+ private void synthesizeNativePointer(
+ final int source,
+ final int pointerId,
+ final int originalEventType,
+ final int clientX,
+ final int clientY,
+ final double pressure,
+ final int orientation,
+ final int button) {
+ if (mPointerState == null) {
+ mPointerState = new SynthesizedEventState();
+ }
+
+ // Find the pointer if it already exists
+ int pointerIndex = mPointerState.getPointerIndex(pointerId);
+
+ // Event-specific handling
+ int eventType = originalEventType;
+ switch (originalEventType) {
+ case MotionEvent.ACTION_POINTER_UP:
+ if (pointerIndex < 0) {
+ Log.w(LOGTAG, "Pointer-up for invalid pointer");
+ return;
+ }
+ if (mPointerState.pointers.size() == 1) {
+ // Last pointer is going up
+ eventType = MotionEvent.ACTION_UP;
+ }
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ if (pointerIndex < 0) {
+ Log.w(LOGTAG, "Pointer-cancel for invalid pointer");
+ return;
+ }
+ break;
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (pointerIndex < 0) {
+ // Adding a new pointer
+ pointerIndex = mPointerState.addPointer(pointerId, source);
+ if (pointerIndex == 0) {
+ // first pointer
+ eventType = MotionEvent.ACTION_DOWN;
+ mPointerState.downTime = SystemClock.uptimeMillis();
+ }
+ } else {
+ // We're moving an existing pointer
+ eventType = MotionEvent.ACTION_MOVE;
+ }
+ break;
+ case MotionEvent.ACTION_HOVER_MOVE:
+ if (pointerIndex < 0) {
+ // Mouse-move a pointer without it going "down". However
+ // in order to send the right MotionEvent without a lot of
+ // duplicated code, we add the pointer to mPointerState,
+ // and then remove it at the bottom of this function.
+ pointerIndex = mPointerState.addPointer(pointerId, source);
+ } else {
+ // We're moving an existing mouse pointer that went down.
+ eventType = MotionEvent.ACTION_MOVE;
+ }
+ break;
+ }
+
+ // Translate client origin to surface origin.
+ mSession.getSurfaceBounds(mTempRect);
+ final int surfaceX = clientX + mTempRect.left;
+ final int surfaceY = clientY + mTempRect.top;
+
+ // Update the pointer with the new info
+ final PointerInfo info = mPointerState.pointers.get(pointerIndex);
+ info.surfaceX = surfaceX;
+ info.surfaceY = surfaceY;
+ info.pressure = pressure;
+ info.orientation = orientation;
+ if (source == InputDevice.SOURCE_MOUSE) {
+ if (eventType == MotionEvent.ACTION_DOWN || eventType == MotionEvent.ACTION_MOVE) {
+ info.buttonState |= button;
+ } else if (eventType == MotionEvent.ACTION_UP) {
+ info.buttonState &= button;
+ }
+ }
+
+ // Dispatch the event
+ int action = 0;
+ if (eventType == MotionEvent.ACTION_POINTER_DOWN
+ || eventType == MotionEvent.ACTION_POINTER_UP) {
+ // for pointer-down and pointer-up events we need to add the
+ // index of the relevant pointer.
+ action = (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
+ action &= MotionEvent.ACTION_POINTER_INDEX_MASK;
+ }
+ action |= (eventType & MotionEvent.ACTION_MASK);
+ final MotionEvent event =
+ MotionEvent.obtain(
+ /*downTime*/ mPointerState.downTime,
+ /*eventTime*/ SystemClock.uptimeMillis(),
+ /*action*/ action,
+ /*pointerCount*/ mPointerState.getPointerCount(source),
+ /*pointerProperties*/ mPointerState.getPointerProperties(source),
+ /*pointerCoords*/ mPointerState.getPointerCoords(source),
+ /*metaState*/ 0,
+ /*buttonState*/ mPointerState.getPointerButtonState(source),
+ /*xPrecision*/ 0,
+ /*yPrecision*/ 0,
+ /*deviceId*/ 0,
+ /*edgeFlags*/ 0,
+ /*source*/ source,
+ /*flags*/ 0);
+
+ mSynthesizedEvent = true;
+ onTouchEvent(event);
+ mSynthesizedEvent = false;
+
+ // Forget about removed pointers
+ if (eventType == MotionEvent.ACTION_POINTER_UP
+ || eventType == MotionEvent.ACTION_UP
+ || eventType == MotionEvent.ACTION_CANCEL
+ || eventType == MotionEvent.ACTION_HOVER_MOVE) {
+ mPointerState.pointers.remove(pointerIndex);
+ }
+ }
+
+ /**
+ * Scroll the document body by an offset from the current scroll position. Uses {@link
+ * #SCROLL_BEHAVIOR_SMOOTH}.
+ *
+ * @param width {@link ScreenLength} offset to scroll along X axis.
+ * @param height {@link ScreenLength} offset to scroll along Y axis.
+ */
+ @UiThread
+ public void scrollBy(final @NonNull ScreenLength width, final @NonNull ScreenLength height) {
+ scrollBy(width, height, SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /**
+ * Scroll the document body by an offset from the current scroll position.
+ *
+ * @param width {@link ScreenLength} offset to scroll along X axis.
+ * @param height {@link ScreenLength} offset to scroll along Y axis.
+ * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link
+ * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content.
+ */
+ @UiThread
+ public void scrollBy(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = buildScrollMessage(width, height, behavior);
+ mSession.getEventDispatcher().dispatch("GeckoView:ScrollBy", msg);
+ }
+
+ /**
+ * Scroll the document body to an absolute position. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}.
+ *
+ * @param width {@link ScreenLength} position to scroll along X axis.
+ * @param height {@link ScreenLength} position to scroll along Y axis.
+ */
+ @UiThread
+ public void scrollTo(final @NonNull ScreenLength width, final @NonNull ScreenLength height) {
+ scrollTo(width, height, SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /**
+ * Scroll the document body to an absolute position.
+ *
+ * @param width {@link ScreenLength} position to scroll along X axis.
+ * @param height {@link ScreenLength} position to scroll along Y axis.
+ * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link
+ * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content.
+ */
+ @UiThread
+ public void scrollTo(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = buildScrollMessage(width, height, behavior);
+ mSession.getEventDispatcher().dispatch("GeckoView:ScrollTo", msg);
+ }
+
+ /** Scroll to the top left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */
+ @UiThread
+ public void scrollToTop() {
+ scrollTo(ScreenLength.zero(), ScreenLength.top(), SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /** Scroll to the bottom left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */
+ @UiThread
+ public void scrollToBottom() {
+ scrollTo(ScreenLength.zero(), ScreenLength.bottom(), SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ private GeckoBundle buildScrollMessage(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = new GeckoBundle();
+ msg.putDouble("widthValue", width.getValue());
+ msg.putInt("widthType", width.getType());
+ msg.putDouble("heightValue", height.getValue());
+ msg.putInt("heightType", height.getType());
+ msg.putInt("behavior", behavior);
+ return msg;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java
new file mode 100644
index 0000000000..7feb7d88ae
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java
@@ -0,0 +1,19 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+
+class ParcelableUtils {
+ public static void writeBoolean(final Parcel out, final boolean val) {
+ out.writeByte((byte) (val ? 1 : 0));
+ }
+
+ public static boolean readBoolean(final Parcel source) {
+ return source.readByte() == 1;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java
new file mode 100644
index 0000000000..9e655c5eb7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java
@@ -0,0 +1,182 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.GeckoJavaSampler;
+
+/**
+ * ProfilerController is used to manage GeckoProfiler related features.
+ *
+ * <p>If you want to add a profiler marker to mark a point in time (without a duration) you can
+ * directly use <code>profilerController.addMarker("marker name")</code>. Or if you want to provide
+ * more information, you can use <code>
+ * profilerController.addMarker("marker name", "extra information")</code> If you want to add a
+ * profiler marker with a duration (with start and end time) you can use it like this: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * profilerController.addMarker("name", startTime);
+ * </code> Or you can capture start and end time in somewhere, then add the marker in somewhere
+ * else: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure (or end time can be collected in a callback)...
+ * Double endTime = profilerController.getProfilerTime();
+ *
+ * ...somewhere else in the codebase...
+ * profilerController.addMarker("name", startTime, endTime);
+ * </code> Here's an <code>addMarker</code> example with all the possible parameters: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * Double endTime = profilerController.getProfilerTime();
+ *
+ * ...somewhere else in the codebase...
+ * profilerController.addMarker("name", startTime, endTime, "extra information");
+ * </code> <code>isProfilerActive</code> method is handy when you want to get more information to
+ * add inside the marker, but you think it's going to be computationally heavy (and useless) when
+ * profiler is not running:
+ *
+ * <pre>
+ * <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * if (profilerController.isProfilerActive()) {
+ * String info = aFunctionYouDoNotWantToCallWhenProfilerIsNotActive();
+ * profilerController.addMarker("name", startTime, info);
+ * }
+ * </code>
+ * </pre>
+ *
+ * FIXME(bug 1618560): Currently only works in the main thread.
+ */
+@UiThread
+public class ProfilerController {
+ private static final String LOGTAG = "ProfilerController";
+
+ /**
+ * Returns true if profiler is active and it's allowed the add markers. It's useful when it's
+ * computationally heavy to get startTime or the additional text for the marker. That code can be
+ * wrapped with isProfilerActive if check to reduce the overhead of it.
+ *
+ * @return true if profiler is active and safe to add a new marker.
+ */
+ public boolean isProfilerActive() {
+ return GeckoJavaSampler.isProfilerActive();
+ }
+
+ /**
+ * Get the profiler time to be able to mark the start of the marker events. can be used like this:
+ * <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * profilerController.addMarker("name", startTime);
+ * </code>
+ *
+ * @return profiler time as double or null if the profiler is not active.
+ */
+ public @Nullable Double getProfilerTime() {
+ return GeckoJavaSampler.tryToGetProfilerTime();
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. No-op if profiler is not
+ * active.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ GeckoJavaSampler.addMarker(aMarkerName, aStartTime, aEndTime, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final String aText) {
+ GeckoJavaSampler.addMarker(aMarkerName, aStartTime, null, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ */
+ public void addMarker(@NonNull final String aMarkerName, @Nullable final Double aStartTime) {
+ addMarker(aMarkerName, aStartTime, null, null);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(@NonNull final String aMarkerName, @Nullable final String aText) {
+ addMarker(aMarkerName, null, null, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ */
+ public void addMarker(@NonNull final String aMarkerName) {
+ addMarker(aMarkerName, null, null, null);
+ }
+
+ /**
+ * Start the Gecko profiler with the given settings. This is used by embedders which want to
+ * control the profiler from the embedding app. This allows them to provide an easier access point
+ * to profiling, as an alternative to the traditional way of using a desktop Firefox instance
+ * connected via USB + adb.
+ *
+ * @param aFilters The list of threads to profile, as an array of string of thread names filters.
+ * Each filter is used as a case-insensitive substring match against the actual thread names.
+ * @param aFeaturesArr The list of profiler features to enable for profiling, as a string array.
+ */
+ public void startProfiler(
+ @NonNull final String[] aFilters, @NonNull final String[] aFeaturesArr) {
+ GeckoJavaSampler.startProfiler(aFilters, aFeaturesArr);
+ }
+
+ /**
+ * Stop the profiler and capture the recorded profile. This method is asynchronous.
+ *
+ * @return GeckoResult for the captured profile. The profile is returned as a byte[] buffer
+ * containing a gzip-compressed payload (with gzip header) of the profile JSON.
+ */
+ public @NonNull GeckoResult<byte[]> stopProfiler() {
+ return GeckoJavaSampler.stopProfiler();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java
new file mode 100644
index 0000000000..2c4e7238be
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java
@@ -0,0 +1,746 @@
+/* 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.util.Log;
+import java.util.HashMap;
+import java.util.Map;
+import org.json.JSONException;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.Autocomplete.AddressSaveOption;
+import org.mozilla.geckoview.Autocomplete.AddressSelectOption;
+import org.mozilla.geckoview.Autocomplete.CreditCardSaveOption;
+import org.mozilla.geckoview.Autocomplete.CreditCardSelectOption;
+import org.mozilla.geckoview.Autocomplete.LoginSaveOption;
+import org.mozilla.geckoview.Autocomplete.LoginSelectOption;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AlertPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt.AuthOptions;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt.Observer;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BeforeUnloadPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ButtonPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ChoicePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ColorPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PopupPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptInstanceDelegate;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.RepostConfirmPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.TextPrompt;
+
+/* package */ class PromptController {
+ private static final String LOGTAG = "Prompts";
+
+ private static class PromptStorage implements BasePrompt.Observer {
+ private final Map<String, BasePrompt> mPrompts = new HashMap<>();
+
+ public void addPrompt(final String id, final BasePrompt prompt) {
+ if (mPrompts.containsKey(id)) {
+ Log.e(LOGTAG, "Prompt already exists! id=" + id);
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Prompt already exists! id=" + id);
+ }
+ }
+ mPrompts.put(id, prompt);
+ }
+
+ @Override
+ public void onPromptCompleted(final BasePrompt prompt) {
+ // No need to notify this delegate since the prompt has been completed already.
+ mPrompts.remove(prompt.id);
+ }
+
+ public void dismiss(final String id) {
+ final BasePrompt prompt = mPrompts.get(id);
+ if (prompt == null) {
+ return;
+ }
+ final PromptInstanceDelegate delegate = prompt.getDelegate();
+ if (delegate != null) {
+ delegate.onPromptDismiss(prompt);
+ }
+ mPrompts.remove(prompt.id);
+ }
+
+ public boolean contains(final String id) {
+ return mPrompts.containsKey(id);
+ }
+
+ public void update(final BasePrompt prompt) {
+ final BasePrompt previousPrompt = mPrompts.get(prompt.id);
+ if (previousPrompt == null) {
+ return;
+ }
+ final PromptInstanceDelegate delegate = previousPrompt.getDelegate();
+ if (delegate == null) {
+ return;
+ }
+ prompt.setDelegate(delegate);
+ delegate.onPromptUpdate(prompt);
+ mPrompts.put(prompt.id, prompt);
+ }
+ }
+
+ final PromptStorage mStorage = new PromptStorage();
+
+ public void dismissPrompt(final String id) {
+ mStorage.dismiss(id);
+ }
+
+ public void updatePrompt(final GeckoBundle message) {
+ final String type = message.getString("type");
+ final PromptHandler<?> handler = sPromptHandlers.handlerFor(type);
+ if (handler == null) {
+ // Invalid prompt message type to update the prompt.
+ return;
+ }
+ final BasePrompt prompt = handler.newPrompt(message, mStorage);
+ if (prompt == null) {
+ // Invalid prompt message to update the prompt.
+ return;
+ }
+ if (!mStorage.contains(prompt.id)) {
+ // Invalid prompt id to update the prompt. Dismissed?
+ return;
+ }
+
+ mStorage.update(prompt);
+ }
+
+ public void handleEvent(
+ final GeckoSession session, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleEvent " + message.getString("type"));
+ final PromptDelegate delegate = session.getPromptDelegate();
+ if (delegate == null) {
+ // Default behavior is same as calling dismiss() on callback.
+ callback.sendSuccess(null);
+ return;
+ }
+
+ final String type = message.getString("type");
+ final PromptHandler<?> handler = sPromptHandlers.handlerFor(type);
+ if (handler == null) {
+ callback.sendError("Invalid type: " + type);
+ return;
+ }
+ final GeckoResult<PromptResponse> res = getResponse(message, session, delegate, handler);
+
+ if (res == null) {
+ // Adhere to default behavior if the delegate returns null.
+ callback.sendSuccess(null);
+ } else {
+ res.accept(
+ value -> value.dispatch(callback),
+ exception -> callback.sendError("Failed to get prompt response."));
+ }
+ }
+
+ private <PromptType extends BasePrompt> GeckoResult<PromptResponse> getResponse(
+ final GeckoBundle message,
+ final GeckoSession session,
+ final PromptDelegate delegate,
+ final PromptHandler<PromptType> handler) {
+ final PromptType prompt = handler.newPrompt(message, mStorage);
+ if (prompt == null) {
+ try {
+ Log.e(LOGTAG, "Invalid prompt: " + message.toJSONObject().toString());
+ } catch (final JSONException ex) {
+ Log.e(LOGTAG, "Invalid prompt, invalid data", ex);
+ }
+
+ return GeckoResult.fromException(new IllegalArgumentException("Invalid prompt data."));
+ }
+
+ mStorage.addPrompt(prompt.id, prompt);
+ return handler.callDelegate(prompt, session, delegate);
+ }
+
+ private interface PromptHandler<PromptType extends BasePrompt> {
+ PromptType newPrompt(GeckoBundle info, Observer observer);
+
+ GeckoResult<PromptResponse> callDelegate(
+ PromptType prompt, GeckoSession session, PromptDelegate delegate);
+ }
+
+ private static final class AlertHandler implements PromptHandler<AlertPrompt> {
+ @Override
+ public AlertPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new AlertPrompt(
+ info.getString("id"), info.getString("title"), info.getString("msg"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AlertPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onAlertPrompt(session, prompt);
+ }
+ }
+
+ private static final class BeforeUnloadHandler implements PromptHandler<BeforeUnloadPrompt> {
+ @Override
+ public BeforeUnloadPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new BeforeUnloadPrompt(info.getString("id"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final BeforeUnloadPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onBeforeUnloadPrompt(session, prompt);
+ }
+ }
+
+ private static final class ButtonHandler implements PromptHandler<ButtonPrompt> {
+ @Override
+ public ButtonPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new ButtonPrompt(
+ info.getString("id"), info.getString("title"), info.getString("msg"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ButtonPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onButtonPrompt(session, prompt);
+ }
+ }
+
+ private static final class TextHandler implements PromptHandler<TextPrompt> {
+ @Override
+ public TextPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new TextPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ info.getString("value"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final TextPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onTextPrompt(session, prompt);
+ }
+ }
+
+ private static final class AuthHandler implements PromptHandler<AuthPrompt> {
+ @Override
+ public AuthPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new AuthPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ new AuthOptions(info.getBundle("options")),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AuthPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onAuthPrompt(session, prompt);
+ }
+ }
+
+ private static final class ChoiceHandler implements PromptHandler<ChoicePrompt> {
+ @Override
+ public ChoicePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final int intMode;
+ final String mode = info.getString("mode");
+ if ("menu".equals(mode)) {
+ intMode = ChoicePrompt.Type.MENU;
+ } else if ("single".equals(mode)) {
+ intMode = ChoicePrompt.Type.SINGLE;
+ } else if ("multiple".equals(mode)) {
+ intMode = ChoicePrompt.Type.MULTIPLE;
+ } else {
+ return null;
+ }
+
+ final GeckoBundle[] choiceBundles = info.getBundleArray("choices");
+ final ChoicePrompt.Choice[] choices;
+ if (choiceBundles == null || choiceBundles.length == 0) {
+ choices = new ChoicePrompt.Choice[0];
+ } else {
+ choices = new ChoicePrompt.Choice[choiceBundles.length];
+ for (int i = 0; i < choiceBundles.length; i++) {
+ choices[i] = new ChoicePrompt.Choice(choiceBundles[i]);
+ }
+ }
+
+ return new ChoicePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ intMode,
+ choices,
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ChoicePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onChoicePrompt(session, prompt);
+ }
+ }
+
+ private static final class ColorHandler implements PromptHandler<ColorPrompt> {
+ @Override
+ public ColorPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new ColorPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("value"),
+ info.getStringArray("predefinedValues"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ColorPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onColorPrompt(session, prompt);
+ }
+ }
+
+ private static final class DateTimeHandler implements PromptHandler<DateTimePrompt> {
+ @Override
+ public DateTimePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final String mode = info.getString("mode");
+ final int intMode;
+ if ("date".equals(mode)) {
+ intMode = DateTimePrompt.Type.DATE;
+ } else if ("month".equals(mode)) {
+ intMode = DateTimePrompt.Type.MONTH;
+ } else if ("week".equals(mode)) {
+ intMode = DateTimePrompt.Type.WEEK;
+ } else if ("time".equals(mode)) {
+ intMode = DateTimePrompt.Type.TIME;
+ } else if ("datetime-local".equals(mode)) {
+ intMode = DateTimePrompt.Type.DATETIME_LOCAL;
+ } else {
+ return null;
+ }
+
+ final String defaultValue = info.getString("value");
+ final String minValue = info.getString("min");
+ final String maxValue = info.getString("max");
+ final String stepValue = info.getString("step");
+ return new DateTimePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ intMode,
+ defaultValue,
+ minValue,
+ maxValue,
+ stepValue,
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final DateTimePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onDateTimePrompt(session, prompt);
+ }
+ }
+
+ private static final class FileHandler implements PromptHandler<FilePrompt> {
+ @Override
+ public FilePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final String mode = info.getString("mode");
+ final int intMode;
+ if ("single".equals(mode)) {
+ intMode = FilePrompt.Type.SINGLE;
+ } else if ("multiple".equals(mode)) {
+ intMode = FilePrompt.Type.MULTIPLE;
+ } else {
+ return null;
+ }
+
+ final String[] mimeTypes = info.getStringArray("mimeTypes");
+ final int capture = info.getInt("capture");
+ return new FilePrompt(
+ info.getString("id"), info.getString("title"), intMode, capture, mimeTypes, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final FilePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onFilePrompt(session, prompt);
+ }
+ }
+
+ private static final class PopupHandler implements PromptHandler<PopupPrompt> {
+ @Override
+ public PopupPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new PopupPrompt(info.getString("id"), info.getString("targetUri"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final PopupPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onPopupPrompt(session, prompt);
+ }
+ }
+
+ private static final class RepostHandler implements PromptHandler<RepostConfirmPrompt> {
+ @Override
+ public RepostConfirmPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new RepostConfirmPrompt(info.getString("id"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final RepostConfirmPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onRepostConfirmPrompt(session, prompt);
+ }
+ }
+
+ private static final class ShareHandler implements PromptHandler<SharePrompt> {
+ @Override
+ public SharePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new SharePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("text"),
+ info.getString("uri"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final SharePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onSharePrompt(session, prompt);
+ }
+ }
+
+ private static final class LoginSaveHandler
+ implements PromptHandler<AutocompleteRequest<LoginSaveOption>> {
+ @Override
+ public AutocompleteRequest<LoginSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final int hint = info.getInt("hint");
+ final GeckoBundle[] loginBundles = info.getBundleArray("logins");
+
+ if (loginBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.LoginSaveOption[] options =
+ new Autocomplete.LoginSaveOption[loginBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.LoginSaveOption(new Autocomplete.LoginEntry(loginBundles[i]), hint);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<LoginSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onLoginSave(session, prompt);
+ }
+ }
+
+ private static final class CreditCardSaveHandler
+ implements PromptHandler<AutocompleteRequest<CreditCardSaveOption>> {
+ @Override
+ public AutocompleteRequest<CreditCardSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final int hint = info.getInt("hint");
+ final GeckoBundle[] creditCardBundles = info.getBundleArray("creditCards");
+
+ if (creditCardBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.CreditCardSaveOption[] options =
+ new Autocomplete.CreditCardSaveOption[creditCardBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.CreditCardSaveOption(
+ new Autocomplete.CreditCard(creditCardBundles[i]), hint);
+ }
+
+ return new PromptDelegate.AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<CreditCardSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onCreditCardSave(session, prompt);
+ }
+ }
+
+ private static final class AddressSaveHandler
+ implements PromptHandler<AutocompleteRequest<AddressSaveOption>> {
+ @Override
+ public AutocompleteRequest<AddressSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] addressBundles = info.getBundleArray("addresses");
+
+ if (addressBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.AddressSaveOption[] options =
+ new Autocomplete.AddressSaveOption[addressBundles.length];
+
+ final int hint = info.getInt("hint");
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.AddressSaveOption(new Autocomplete.Address(addressBundles[i]), hint);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<AddressSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onAddressSave(session, prompt);
+ }
+ }
+
+ private static final class LoginSelectHandler
+ implements PromptHandler<AutocompleteRequest<LoginSelectOption>> {
+ @Override
+ public AutocompleteRequest<LoginSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.LoginSelectOption[] options =
+ new Autocomplete.LoginSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.LoginSelectOption.fromBundle(optionBundles[i]);
+ }
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<LoginSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onLoginSelect(session, prompt);
+ }
+ }
+
+ private static final class IdentityCredentialSelectProviderHandler
+ implements PromptHandler<ProviderSelectorPrompt> {
+ @Override
+ public ProviderSelectorPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] providerBundles = info.getBundleArray("providers");
+ if (providerBundles == null) {
+ return null;
+ }
+
+ final ProviderSelectorPrompt.Provider[] providers =
+ new ProviderSelectorPrompt.Provider[providerBundles.length];
+
+ for (int i = 0; i < providerBundles.length; ++i) {
+ providers[i] = ProviderSelectorPrompt.Provider.fromBundle(providerBundles[i]);
+ }
+
+ return new ProviderSelectorPrompt(info.getString("id"), providers, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ProviderSelectorPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onSelectIdentityCredentialProvider(session, prompt);
+ }
+ }
+
+ private static final class IdentityCredentialSelectAccountHandler
+ implements PromptHandler<AccountSelectorPrompt> {
+ @Override
+ public AccountSelectorPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final GeckoBundle providerBundle = info.getBundle("accounts");
+ if (providerBundle == null) {
+ return null;
+ }
+ final GeckoBundle[] accountBundles = providerBundle.getBundleArray("accounts");
+ if (accountBundles == null) {
+ return null;
+ }
+
+ final AccountSelectorPrompt.Account[] accounts =
+ new AccountSelectorPrompt.Account[accountBundles.length];
+
+ for (int i = 0; i < accountBundles.length; ++i) {
+ accounts[i] = AccountSelectorPrompt.Account.fromBundle(accountBundles[i]);
+ }
+
+ final AccountSelectorPrompt.Provider provider =
+ AccountSelectorPrompt.Provider.fromBundle(providerBundle.getBundle("provider"));
+
+ return new AccountSelectorPrompt(info.getString("id"), accounts, provider, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AccountSelectorPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onSelectIdentityCredentialAccount(session, prompt);
+ }
+ }
+
+ private static final class IdentityCredentialShowPrivacyPolicyHandler
+ implements PromptHandler<PrivacyPolicyPrompt> {
+ @Override
+ public PrivacyPolicyPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final String privacyPolicyUrl = info.getString("privacyPolicyUrl");
+ final String termsOfServiceUrl = info.getString("termsOfServiceUrl");
+ final String providerDomain = info.getString("providerDomain");
+ final String host = info.getString("host");
+ final String icon = info.getString("icon");
+
+ return new PrivacyPolicyPrompt(
+ info.getString("id"),
+ privacyPolicyUrl,
+ termsOfServiceUrl,
+ providerDomain,
+ host,
+ icon,
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final PrivacyPolicyPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onShowPrivacyPolicyIdentityCredential(session, prompt);
+ }
+ }
+
+ private static final class CreditCardSelectHandler
+ implements PromptHandler<AutocompleteRequest<CreditCardSelectOption>> {
+ @Override
+ public AutocompleteRequest<CreditCardSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.CreditCardSelectOption[] options =
+ new Autocomplete.CreditCardSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.CreditCardSelectOption.fromBundle(optionBundles[i]);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<CreditCardSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onCreditCardSelect(session, prompt);
+ }
+ }
+
+ private static final class AddressSelectHandler
+ implements PromptHandler<AutocompleteRequest<AddressSelectOption>> {
+ @Override
+ public AutocompleteRequest<AddressSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.AddressSelectOption[] options =
+ new Autocomplete.AddressSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.AddressSelectOption.fromBundle(optionBundles[i]);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<AddressSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onAddressSelect(session, prompt);
+ }
+ }
+
+ private static class PromptHandlers {
+ final Map<String, PromptHandler<?>> mPromptHandlers = new HashMap<>();
+
+ public void register(final PromptHandler<?> handler, final String type) {
+ mPromptHandlers.put(type, handler);
+ }
+
+ public PromptHandler<?> handlerFor(final String type) {
+ return mPromptHandlers.get(type);
+ }
+ }
+
+ private static final PromptHandlers sPromptHandlers = new PromptHandlers();
+
+ static {
+ sPromptHandlers.register(new AlertHandler(), "alert");
+ sPromptHandlers.register(new BeforeUnloadHandler(), "beforeUnload");
+ sPromptHandlers.register(new ButtonHandler(), "button");
+ sPromptHandlers.register(new TextHandler(), "text");
+ sPromptHandlers.register(new AuthHandler(), "auth");
+ sPromptHandlers.register(new ChoiceHandler(), "choice");
+ sPromptHandlers.register(new ColorHandler(), "color");
+ sPromptHandlers.register(new DateTimeHandler(), "datetime");
+ sPromptHandlers.register(new FileHandler(), "file");
+ sPromptHandlers.register(new PopupHandler(), "popup");
+ sPromptHandlers.register(new RepostHandler(), "repost");
+ sPromptHandlers.register(new ShareHandler(), "share");
+ sPromptHandlers.register(new LoginSaveHandler(), "Autocomplete:Save:Login");
+ sPromptHandlers.register(new CreditCardSaveHandler(), "Autocomplete:Save:CreditCard");
+ sPromptHandlers.register(new AddressSaveHandler(), "Autocomplete:Save:Address");
+ sPromptHandlers.register(new LoginSelectHandler(), "Autocomplete:Select:Login");
+ sPromptHandlers.register(
+ new IdentityCredentialSelectProviderHandler(), "IdentityCredential:Select:Provider");
+ sPromptHandlers.register(
+ new IdentityCredentialShowPrivacyPolicyHandler(), "IdentityCredential:Show:Policy");
+ sPromptHandlers.register(
+ new IdentityCredentialSelectAccountHandler(), "IdentityCredential:Select:Account");
+ sPromptHandlers.register(new CreditCardSelectHandler(), "Autocomplete:Select:CreditCard");
+ sPromptHandlers.register(new AddressSelectHandler(), "Autocomplete:Select:Address");
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java
new file mode 100644
index 0000000000..de98131908
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java
@@ -0,0 +1,331 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.collection.ArrayMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Map;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * Base class for (nested) runtime settings.
+ *
+ * <p>Handles pref-based settings. Please extend this class when adding nested settings for
+ * GeckoRuntimeSettings.
+ */
+public abstract class RuntimeSettings implements Parcelable {
+ /**
+ * Base class for (nested) runtime settings builders.
+ *
+ * <p>Please extend this class when adding nested settings builders for GeckoRuntimeSettings.
+ */
+ public abstract static class Builder<Settings extends RuntimeSettings> {
+ private final Settings mSettings;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mSettings = newSettings(null);
+ }
+
+ /**
+ * Finalize and return the settings.
+ *
+ * @return The constructed settings.
+ */
+ @AnyThread
+ public @NonNull Settings build() {
+ return newSettings(mSettings);
+ }
+
+ @AnyThread
+ protected @NonNull Settings getSettings() {
+ return mSettings;
+ }
+
+ /**
+ * Create a default or copy settings object.
+ *
+ * @param settings Settings object to copy, null for default settings.
+ * @return The constructed settings object.
+ */
+ @AnyThread
+ protected abstract @NonNull Settings newSettings(final @Nullable Settings settings);
+ }
+
+ /** Used to handle pref-based settings. */
+ /* package */ class Pref<T> {
+ public final String name;
+ public final T defaultValue;
+ private T mValue;
+ private boolean mIsSet;
+
+ public Pref(@NonNull final String name, final T defaultValue) {
+ this.name = name;
+ this.defaultValue = defaultValue;
+ mValue = defaultValue;
+
+ RuntimeSettings.this.addPref(this);
+ }
+
+ public void set(final T newValue) {
+ mValue = newValue;
+ mIsSet = true;
+ }
+
+ public void commit(final T newValue) {
+ if (newValue.equals(mValue)) {
+ return;
+ }
+ set(newValue);
+ commit();
+ }
+
+ public void commit() {
+ final GeckoRuntime runtime = RuntimeSettings.this.getRuntime();
+ if (runtime == null) {
+ return;
+ }
+ final GeckoBundle prefs = new GeckoBundle(1);
+ addToBundle(prefs);
+ runtime.setDefaultPrefs(prefs);
+ }
+
+ public T get() {
+ return mValue;
+ }
+
+ public boolean isSet() {
+ return mIsSet;
+ }
+
+ public boolean hasDefault() {
+ return true;
+ }
+
+ public void reset() {
+ mValue = defaultValue;
+ mIsSet = false;
+ }
+
+ private void addToBundle(final GeckoBundle bundle) {
+ final T value = mIsSet ? mValue : defaultValue;
+ if (value instanceof String) {
+ bundle.putString(name, (String) value);
+ } else if (value instanceof Integer) {
+ bundle.putInt(name, (Integer) value);
+ } else if (value instanceof Boolean) {
+ bundle.putBoolean(name, (Boolean) value);
+ } else {
+ throw new UnsupportedOperationException("Unhandled pref type for " + name);
+ }
+ }
+ }
+
+ /**
+ * Used to handle pref-based settings that should not have a default value, so that they will be
+ * controlled by GeckoView only when they are set.
+ *
+ * <p>When no value is set for a PrefWithoutDefault, its value on the GeckoView side is expected
+ * to be null, and the value set on the Gecko side to stay set to the either the prefs file
+ * included in the GeckoView build, or the user prefs file created by the xpcshell and mochitest
+ * test harness.
+ */
+ /* package */ class PrefWithoutDefault<T> extends Pref<T> {
+ public PrefWithoutDefault(@NonNull final String name) {
+ super(name, null);
+ }
+
+ public boolean hasDefault() {
+ return false;
+ }
+
+ public @Nullable T get() {
+ if (!isSet()) {
+ return null;
+ }
+ return super.get();
+ }
+
+ public void commit() {
+ if (!isSet()) {
+ // Only add to the bundle prefs and
+ // propagate to Gecko when explicitly set.
+ return;
+ }
+ super.commit();
+ }
+
+ private void addToBundle(final GeckoBundle bundle) {
+ if (!isSet()) {
+ return;
+ }
+ super.addToBundle(bundle);
+ }
+ }
+
+ private RuntimeSettings mParent;
+ private final ArrayList<RuntimeSettings> mChildren;
+ private final ArrayList<Pref<?>> mPrefs;
+
+ protected RuntimeSettings() {
+ this(null /* parent */);
+ }
+
+ /**
+ * Create settings object.
+ *
+ * @param parent The parent settings, specify in case of nested settings.
+ */
+ protected RuntimeSettings(final @Nullable RuntimeSettings parent) {
+ mPrefs = new ArrayList<Pref<?>>();
+ mChildren = new ArrayList<RuntimeSettings>();
+
+ setParent(parent);
+ }
+
+ /**
+ * Update the prefs based on the provided settings.
+ *
+ * @param settings Copy from this settings.
+ */
+ @AnyThread
+ protected void updatePrefs(final @NonNull RuntimeSettings settings) {
+ if (mPrefs.size() != settings.mPrefs.size()) {
+ throw new IllegalArgumentException("Settings must be compatible");
+ }
+
+ for (int i = 0; i < mPrefs.size(); ++i) {
+ if (!mPrefs.get(i).name.equals(settings.mPrefs.get(i).name)) {
+ throw new IllegalArgumentException("Settings must be compatible");
+ }
+ if (!settings.mPrefs.get(i).isSet()) {
+ continue;
+ }
+ // We know it is safe.
+ @SuppressWarnings("unchecked")
+ final Pref<Object> uncheckedPref = (Pref<Object>) mPrefs.get(i);
+ uncheckedPref.commit(settings.mPrefs.get(i).get());
+ }
+ }
+
+ /* package */ @Nullable
+ GeckoRuntime getRuntime() {
+ if (mParent != null) {
+ return mParent.getRuntime();
+ }
+ return null;
+ }
+
+ private void setParent(final @Nullable RuntimeSettings parent) {
+ mParent = parent;
+ if (mParent != null) {
+ mParent.addChild(this);
+ }
+ }
+
+ private void addChild(final @NonNull RuntimeSettings child) {
+ mChildren.add(child);
+ }
+
+ /* pacakge */ void addPref(final Pref<?> pref) {
+ mPrefs.add(pref);
+ }
+
+ /**
+ * Return a mapping of the prefs managed in this settings, including child settings.
+ *
+ * @return A key-value mapping of the prefs.
+ */
+ /* package */ @NonNull
+ Map<String, Object> getPrefsMap() {
+ final ArrayMap<String, Object> prefs = new ArrayMap<>();
+ forAllPrefs(pref -> prefs.put(pref.name, pref.get()));
+
+ return Collections.unmodifiableMap(prefs);
+ }
+
+ /**
+ * Iterates through all prefs in this RuntimeSettings instance and in all children, grandchildren,
+ * etc.
+ */
+ private void forAllPrefs(final GeckoResult.Consumer<Pref<?>> visitor) {
+ for (final RuntimeSettings child : mChildren) {
+ child.forAllPrefs(visitor);
+ }
+
+ for (final Pref<?> pref : mPrefs) {
+ visitor.accept(pref);
+ }
+ }
+
+ /**
+ * Reset the prefs managed by this settings and its children.
+ *
+ * <p>The actual prefs values are set via {@link #getPrefsMap} during initialization and via
+ * {@link Pref#commit} during runtime for individual prefs.
+ */
+ /* package */ void commitResetPrefs() {
+ final ArrayList<String> names = new ArrayList<String>();
+ forAllPrefs(
+ pref -> {
+ // Do not reset prefs that don't have a default value
+ // and are not set.
+ if (!pref.hasDefault() && !pref.isSet()) {
+ return;
+ }
+ names.add(pref.name);
+ });
+
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putStringArray("names", names);
+ EventDispatcher.getInstance().dispatch("GeckoView:ResetUserPrefs", data);
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ for (final Pref<?> pref : mPrefs) {
+ out.writeValue(pref.get());
+ }
+ }
+
+ @AnyThread
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ for (final Pref<?> pref : mPrefs) {
+ if (pref.hasDefault()) {
+ // We know this is safe.
+ @SuppressWarnings("unchecked")
+ final Pref<Object> uncheckedPref = (Pref<Object>) pref;
+ uncheckedPref.commit(source.readValue(getClass().getClassLoader()));
+ } else {
+ // Don't commit PrefWithoutDefault instances where the value read
+ // from the Parcel is null.
+ @SuppressWarnings("unchecked")
+ final PrefWithoutDefault<Object> uncheckedPref = (PrefWithoutDefault<Object>) pref;
+ final Object sourceValue = source.readValue(getClass().getClassLoader());
+ if (sourceValue != null) {
+ uncheckedPref.commit(sourceValue);
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java
new file mode 100644
index 0000000000..1fad0cb17e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java
@@ -0,0 +1,171 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/** The telemetry API gives access to telemetry data of the Gecko runtime. */
+public final class RuntimeTelemetry {
+ protected RuntimeTelemetry() {}
+
+ /**
+ * The runtime telemetry metric object.
+ *
+ * @param <T> type of the underlying metric sample
+ */
+ public static class Metric<T> {
+ /** The runtime metric name. */
+ public final @NonNull String name;
+
+ /** The metric values. */
+ public final @NonNull T value;
+
+ /* package */ Metric(final String name, final T value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ @Override
+ public String toString() {
+ return "name: " + name + ", value: " + value;
+ }
+
+ // For testing
+ protected Metric() {
+ name = null;
+ value = null;
+ }
+ }
+
+ /** The Histogram telemetry metric object. */
+ public static class Histogram extends Metric<long[]> {
+ /** Whether or not this is a Categorical Histogram. */
+ public final boolean isCategorical;
+
+ /* package */ Histogram(final boolean isCategorical, final String name, final long[] value) {
+ super(name, value);
+ this.isCategorical = isCategorical;
+ }
+
+ // For testing
+ protected Histogram() {
+ super(null, null);
+ isCategorical = false;
+ }
+ }
+
+ /**
+ * The runtime telemetry delegate. Implement this if you want to receive runtime (Gecko) telemetry
+ * and attach it via {@link GeckoRuntimeSettings.Builder#telemetryDelegate}.
+ */
+ public interface Delegate {
+ /**
+ * A runtime telemetry histogram metric has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onHistogram(final @NonNull Histogram metric) {}
+
+ /**
+ * A runtime telemetry boolean scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onBooleanScalar(final @NonNull Metric<Boolean> metric) {}
+
+ /**
+ * A runtime telemetry long scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onLongScalar(final @NonNull Metric<Long> metric) {}
+
+ /**
+ * A runtime telemetry string scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onStringScalar(final @NonNull Metric<String> metric) {}
+ }
+
+ // The proxy connects to telemetry core and forwards telemetry events
+ // to the attached delegate.
+ /* package */ static final class Proxy extends JNIObject {
+ private final Delegate mDelegate;
+
+ public Proxy(final @NonNull Delegate delegate) {
+ mDelegate = delegate;
+ }
+
+ // Attach to current runtime.
+ // We might have different mechanics of attaching to specific runtimes
+ // in future, for which case we should split the delegate assignment in
+ // the setup phase from the attaching.
+ public void attach() {
+ if (GeckoThread.isRunning()) {
+ registerDelegateProxy(this);
+ } else {
+ GeckoThread.queueNativeCall(Proxy.class, "registerDelegateProxy", Proxy.class, this);
+ }
+ }
+
+ public @NonNull Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void registerDelegateProxy(Proxy proxy);
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchHistogram(
+ final boolean isCategorical, final String name, final long[] values) {
+ if (mDelegate == null) {
+ // TODO throw?
+ return;
+ }
+ mDelegate.onHistogram(new Histogram(isCategorical, name, values));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchStringScalar(final String name, final String value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onStringScalar(new Metric<>(name, value));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchBooleanScalar(final String name, final boolean value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onBooleanScalar(new Metric<>(name, value));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchLongScalar(final String name, final long value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onLongScalar(new Metric<>(name, value));
+ }
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ // We don't hold native references.
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java
new file mode 100644
index 0000000000..1ce4b41659
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java
@@ -0,0 +1,164 @@
+/* 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 androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * ScreenLength is a class that represents a length on the screen using different units. The default
+ * unit is a pixel. However lengths may be also represented by a dimension of the visual viewport or
+ * of the full scroll size of the root document.
+ */
+public class ScreenLength {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({PIXEL, VISUAL_VIEWPORT_WIDTH, VISUAL_VIEWPORT_HEIGHT, DOCUMENT_WIDTH, DOCUMENT_HEIGHT})
+ public @interface ScreenLengthType {}
+
+ /** Pixel units. */
+ public static final int PIXEL = 0;
+
+ /**
+ * Units are in visual viewport width. If the visual viewport is 100 pixels wide, then a value of
+ * 2.0 would represent a length of 200 pixels.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual
+ * Viewport</a>
+ */
+ public static final int VISUAL_VIEWPORT_WIDTH = 1;
+
+ /**
+ * Units are in visual viewport height. If the visual viewport is 100 pixels high, then a value of
+ * 2.0 would represent a length of 200 pixels.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual
+ * Viewport</a>
+ */
+ public static final int VISUAL_VIEWPORT_HEIGHT = 2;
+
+ /**
+ * Units represent the entire scrollable documents width. If the document is 1000 pixels wide then
+ * a value of 1.0 would represent 1000 pixels.
+ */
+ public static final int DOCUMENT_WIDTH = 3;
+
+ /**
+ * Units represent the entire scrollable documents height. If the document is 1000 pixels tall
+ * then a value of 1.0 would represent 1000 pixels.
+ */
+ public static final int DOCUMENT_HEIGHT = 4;
+
+ /**
+ * Create a ScreenLength of zero pixels length. Type is {@link #PIXEL}.
+ *
+ * @return ScreenLength of zero length.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength zero() {
+ return new ScreenLength(0.0, PIXEL);
+ }
+
+ /**
+ * Create a ScreenLength of zero pixels length. Type is {@link #PIXEL}. Can be used to scroll to
+ * the top of a page when used with PanZoomController.scrollTo()
+ *
+ * @return ScreenLength of zero length.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength top() {
+ return zero();
+ }
+
+ /**
+ * Create a ScreenLength of the documents height. Type is {@link #DOCUMENT_HEIGHT}. Can be used to
+ * scroll to the bottom of a page when used with {@link PanZoomController#scrollTo(ScreenLength,
+ * ScreenLength)}
+ *
+ * @return ScreenLength of document height.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength bottom() {
+ return new ScreenLength(1.0, DOCUMENT_HEIGHT);
+ }
+
+ /**
+ * Create a ScreenLength of a specific length. Type is {@link #PIXEL}.
+ *
+ * @param value Pixel length.
+ * @return ScreenLength of document height.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromPixels(final double value) {
+ return new ScreenLength(value, PIXEL);
+ }
+
+ /**
+ * Create a ScreenLength that uses the visual viewport width as units. Type is {@link
+ * #VISUAL_VIEWPORT_WIDTH}. Can be used with {@link PanZoomController#scrollBy(ScreenLength,
+ * ScreenLength)} to scroll a value of the width of visual viewport content.
+ *
+ * @param value Factor used to calculate length. A value of 2.0 would indicate a length twice as
+ * long as the length of the visual viewports width.
+ * @return ScreenLength of specifying a length of value * visual viewport width.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromVisualViewportWidth(final double value) {
+ return new ScreenLength(value, VISUAL_VIEWPORT_WIDTH);
+ }
+
+ /**
+ * Create a ScreenLength that uses the visual viewport width as units. Type is {@link
+ * #VISUAL_VIEWPORT_HEIGHT}. Can be used with {@link PanZoomController#scrollBy(ScreenLength,
+ * ScreenLength)} to scroll a value of the height of visual viewport content.
+ *
+ * @param value Factor used to calculate length. A value of 2.0 would indicate a length twice as
+ * long as the length of the visual viewports height.
+ * @return ScreenLength of specifying a length of value * visual viewport width.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromVisualViewportHeight(final double value) {
+ return new ScreenLength(value, VISUAL_VIEWPORT_HEIGHT);
+ }
+
+ private final double mValue;
+ @ScreenLengthType private final int mType;
+
+ /* package */ ScreenLength(final double value, @ScreenLengthType final int type) {
+ mValue = value;
+ mType = type;
+ }
+
+ /**
+ * Returns the scalar value used to calculate length. The units of the returned valued are defined
+ * by what is returned by {@link #getType()}
+ *
+ * @return Scalar value of the length.
+ */
+ @AnyThread
+ public double getValue() {
+ return mValue;
+ }
+
+ /**
+ * Returns the unit type of the length The length can be one of the following: {@link #PIXEL},
+ * {@link #VISUAL_VIEWPORT_WIDTH}, {@link #VISUAL_VIEWPORT_HEIGHT}, {@link #DOCUMENT_WIDTH},
+ * {@link #DOCUMENT_HEIGHT}
+ *
+ * @return Unit type of the length.
+ */
+ @AnyThread
+ @ScreenLengthType
+ public int getType() {
+ return mType;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
new file mode 100644
index 0000000000..88ed0139df
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
@@ -0,0 +1,884 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.content.Context;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewParent;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityNodeInfo.CollectionInfo;
+import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo;
+import android.view.accessibility.AccessibilityNodeInfo.RangeInfo;
+import android.view.accessibility.AccessibilityNodeProvider;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public class SessionAccessibility {
+ private static final String LOGTAG = "GeckoAccessibility";
+
+ // This is the number BrailleBack uses to start indexing routing keys.
+ private static final int BRAILLE_CLICK_BASE_INDEX = -275000000;
+ private static final String ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE =
+ "ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE";
+
+ @WrapForJNI static final int FLAG_ACCESSIBILITY_FOCUSED = 0;
+ @WrapForJNI static final int FLAG_CHECKABLE = 1 << 1;
+ @WrapForJNI static final int FLAG_CHECKED = 1 << 2;
+ @WrapForJNI static final int FLAG_CLICKABLE = 1 << 3;
+ @WrapForJNI static final int FLAG_CONTENT_INVALID = 1 << 4;
+ @WrapForJNI static final int FLAG_CONTEXT_CLICKABLE = 1 << 5;
+ @WrapForJNI static final int FLAG_EDITABLE = 1 << 6;
+ @WrapForJNI static final int FLAG_ENABLED = 1 << 7;
+ @WrapForJNI static final int FLAG_FOCUSABLE = 1 << 8;
+ @WrapForJNI static final int FLAG_FOCUSED = 1 << 9;
+ @WrapForJNI static final int FLAG_LONG_CLICKABLE = 1 << 10;
+ @WrapForJNI static final int FLAG_MULTI_LINE = 1 << 11;
+ @WrapForJNI static final int FLAG_PASSWORD = 1 << 12;
+ @WrapForJNI static final int FLAG_SCROLLABLE = 1 << 13;
+ @WrapForJNI static final int FLAG_SELECTED = 1 << 14;
+ @WrapForJNI static final int FLAG_VISIBLE_TO_USER = 1 << 15;
+ @WrapForJNI static final int FLAG_SELECTABLE = 1 << 16;
+ @WrapForJNI static final int FLAG_EXPANDABLE = 1 << 17;
+ @WrapForJNI static final int FLAG_EXPANDED = 1 << 18;
+
+ static final int CLASSNAME_UNKNOWN = -1;
+ @WrapForJNI static final int CLASSNAME_VIEW = 0;
+ @WrapForJNI static final int CLASSNAME_BUTTON = 1;
+ @WrapForJNI static final int CLASSNAME_CHECKBOX = 2;
+ @WrapForJNI static final int CLASSNAME_DIALOG = 3;
+ @WrapForJNI static final int CLASSNAME_EDITTEXT = 4;
+ @WrapForJNI static final int CLASSNAME_GRIDVIEW = 5;
+ @WrapForJNI static final int CLASSNAME_IMAGE = 6;
+ @WrapForJNI static final int CLASSNAME_LISTVIEW = 7;
+ @WrapForJNI static final int CLASSNAME_MENUITEM = 8;
+ @WrapForJNI static final int CLASSNAME_PROGRESSBAR = 9;
+ @WrapForJNI static final int CLASSNAME_RADIOBUTTON = 10;
+ @WrapForJNI static final int CLASSNAME_SEEKBAR = 11;
+ @WrapForJNI static final int CLASSNAME_SPINNER = 12;
+ @WrapForJNI static final int CLASSNAME_TABWIDGET = 13;
+ @WrapForJNI static final int CLASSNAME_TOGGLEBUTTON = 14;
+ @WrapForJNI static final int CLASSNAME_WEBVIEW = 15;
+
+ private static final String[] CLASSNAMES = {
+ "android.view.View",
+ "android.widget.Button",
+ "android.widget.CheckBox",
+ "android.app.Dialog",
+ "android.widget.EditText",
+ "android.widget.GridView",
+ "android.widget.Image",
+ "android.widget.ListView",
+ "android.view.MenuItem",
+ "android.widget.ProgressBar",
+ "android.widget.RadioButton",
+ "android.widget.SeekBar",
+ "android.widget.Spinner",
+ "android.widget.TabWidget",
+ "android.widget.ToggleButton",
+ "android.webkit.WebView"
+ };
+
+ @WrapForJNI static final int HTML_GRANULARITY_DEFAULT = -1;
+ @WrapForJNI static final int HTML_GRANULARITY_ARTICLE = 0;
+ @WrapForJNI static final int HTML_GRANULARITY_BUTTON = 1;
+ @WrapForJNI static final int HTML_GRANULARITY_CHECKBOX = 2;
+ @WrapForJNI static final int HTML_GRANULARITY_COMBOBOX = 3;
+ @WrapForJNI static final int HTML_GRANULARITY_CONTROL = 4;
+ @WrapForJNI static final int HTML_GRANULARITY_FOCUSABLE = 5;
+ @WrapForJNI static final int HTML_GRANULARITY_FRAME = 6;
+ @WrapForJNI static final int HTML_GRANULARITY_GRAPHIC = 7;
+ @WrapForJNI static final int HTML_GRANULARITY_H1 = 8;
+ @WrapForJNI static final int HTML_GRANULARITY_H2 = 9;
+ @WrapForJNI static final int HTML_GRANULARITY_H3 = 10;
+ @WrapForJNI static final int HTML_GRANULARITY_H4 = 11;
+ @WrapForJNI static final int HTML_GRANULARITY_H5 = 12;
+ @WrapForJNI static final int HTML_GRANULARITY_H6 = 13;
+ @WrapForJNI static final int HTML_GRANULARITY_HEADING = 14;
+ @WrapForJNI static final int HTML_GRANULARITY_LANDMARK = 15;
+ @WrapForJNI static final int HTML_GRANULARITY_LINK = 16;
+ @WrapForJNI static final int HTML_GRANULARITY_LIST = 17;
+ @WrapForJNI static final int HTML_GRANULARITY_LIST_ITEM = 18;
+ @WrapForJNI static final int HTML_GRANULARITY_MAIN = 19;
+ @WrapForJNI static final int HTML_GRANULARITY_MEDIA = 20;
+ @WrapForJNI static final int HTML_GRANULARITY_RADIO = 21;
+ @WrapForJNI static final int HTML_GRANULARITY_SECTION = 22;
+ @WrapForJNI static final int HTML_GRANULARITY_TABLE = 23;
+ @WrapForJNI static final int HTML_GRANULARITY_TEXT_FIELD = 24;
+ @WrapForJNI static final int HTML_GRANULARITY_UNVISITED_LINK = 25;
+ @WrapForJNI static final int HTML_GRANULARITY_VISITED_LINK = 26;
+
+ private static String[] sHtmlGranularities = {
+ "ARTICLE",
+ "BUTTON",
+ "CHECKBOX",
+ "COMBOBOX",
+ "CONTROL",
+ "FOCUSABLE",
+ "FRAME",
+ "GRAPHIC",
+ "H1",
+ "H2",
+ "H3",
+ "H4",
+ "H5",
+ "H6",
+ "HEADING",
+ "LANDMARK",
+ "LINK",
+ "LIST",
+ "LIST_ITEM",
+ "MAIN",
+ "MEDIA",
+ "RADIO",
+ "SECTION",
+ "TABLE",
+ "TEXT_FIELD",
+ "UNVISITED_LINK",
+ "VISITED_LINK"
+ };
+
+ private static String getClassName(final int index) {
+ if (index >= 0 && index < CLASSNAMES.length) {
+ return CLASSNAMES[index];
+ }
+
+ Log.e(LOGTAG, "Index " + index + " our of CLASSNAME bounds.");
+ return "android.view.View"; // Fallback class is View
+ }
+
+ /* package */ final class NodeProvider extends AccessibilityNodeProvider {
+ @Override
+ public AccessibilityNodeInfo createAccessibilityNodeInfo(final int virtualDescendantId) {
+ AccessibilityNodeInfo node = null;
+ if (mAttached) {
+ node = getNodeFromGecko(virtualDescendantId);
+ }
+
+ if (node == null) {
+ Log.w(
+ LOGTAG,
+ "Failed to retrieve accessible node virtualDescendantId="
+ + virtualDescendantId
+ + " mAttached="
+ + mAttached);
+ node = AccessibilityNodeInfo.obtain(mView, View.NO_ID);
+ if (mView.getDisplay() != null) {
+ // When running junit tests we don't have a display
+ mView.onInitializeAccessibilityNodeInfo(node);
+ }
+ node.setClassName("android.webkit.WebView");
+ }
+
+ return node;
+ }
+
+ @Override
+ public boolean performAction(
+ final int virtualViewId, final int action, final Bundle arguments) {
+ final GeckoBundle data;
+
+ switch (action) {
+ case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
+ sendEvent(
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED,
+ virtualViewId,
+ CLASSNAME_UNKNOWN,
+ null);
+ return true;
+ case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS:
+ sendEvent(
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED,
+ virtualViewId,
+ virtualViewId == View.NO_ID ? CLASSNAME_WEBVIEW : CLASSNAME_UNKNOWN,
+ null);
+ return true;
+ case AccessibilityNodeInfo.ACTION_CLICK:
+ case AccessibilityNodeInfo.ACTION_EXPAND:
+ case AccessibilityNodeInfo.ACTION_COLLAPSE:
+ nativeProvider.click(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_LONG_CLICK:
+ // XXX: Implement long press.
+ return true;
+ case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
+ if (virtualViewId == View.NO_ID) {
+ // Scroll the viewport forwards by approximately 80%.
+ mSession
+ .getPanZoomController()
+ .scrollBy(
+ ScreenLength.zero(),
+ ScreenLength.fromVisualViewportHeight(0.8),
+ PanZoomController.SCROLL_BEHAVIOR_AUTO);
+ } else {
+ // XXX: It looks like we never call scroll on virtual views.
+ // If we did, we should synthesize a wheel event on it's center coordinate.
+ }
+ return true;
+ case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
+ if (virtualViewId == View.NO_ID) {
+ // Scroll the viewport backwards by approximately 80%.
+ mSession
+ .getPanZoomController()
+ .scrollBy(
+ ScreenLength.zero(),
+ ScreenLength.fromVisualViewportHeight(-0.8),
+ PanZoomController.SCROLL_BEHAVIOR_AUTO);
+ } else {
+ // XXX: It looks like we never call scroll on virtual views.
+ // If we did, we should synthesize a wheel event on it's center coordinate.
+ }
+ return true;
+ case AccessibilityNodeInfo.ACTION_SELECT:
+ nativeProvider.click(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
+ requestViewFocus();
+ return pivot(
+ virtualViewId,
+ arguments != null
+ ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING)
+ : "",
+ true,
+ false);
+ case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
+ requestViewFocus();
+ return pivot(
+ virtualViewId,
+ arguments != null
+ ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING)
+ : "",
+ false,
+ false);
+ case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
+ case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
+ // XXX: Self brailling gives this action with a bogus argument instead of an actual click
+ // action;
+ // the argument value is the BRAILLE_CLICK_BASE_INDEX - the index of the routing key that
+ // was hit.
+ // Other negative values are used by ChromeVox, but we don't support them.
+ // FAKE_GRANULARITY_READ_CURRENT = -1
+ // FAKE_GRANULARITY_READ_TITLE = -2
+ // FAKE_GRANULARITY_STOP_SPEECH = -3
+ // FAKE_GRANULARITY_CHANGE_SHIFTER = -4
+ if (arguments == null) {
+ return false;
+ }
+ final int granularity =
+ arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
+ if (granularity <= BRAILLE_CLICK_BASE_INDEX) {
+ // XXX: Use click offset to update caret position in editables (BRAILLE_CLICK_BASE_INDEX
+ // - granularity).
+ nativeProvider.click(virtualViewId);
+ } else if (granularity > 0) {
+ final boolean extendSelection =
+ arguments.getBoolean(
+ AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN);
+ final boolean next =
+ action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY;
+ return nativeProvider.navigateText(
+ virtualViewId, granularity, mStartOffset, mEndOffset, next, extendSelection);
+ }
+ return true;
+ case AccessibilityNodeInfo.ACTION_SET_SELECTION:
+ if (arguments == null) {
+ return false;
+ }
+ final int selectionStart =
+ arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT);
+ final int selectionEnd =
+ arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT);
+ nativeProvider.setSelection(virtualViewId, selectionStart, selectionEnd);
+ return true;
+ case AccessibilityNodeInfo.ACTION_CUT:
+ nativeProvider.cut(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_COPY:
+ nativeProvider.copy(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_PASTE:
+ nativeProvider.paste(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_SET_TEXT:
+ if (arguments == null) {
+ return false;
+ }
+ final String value =
+ arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE);
+ if (mAttached) {
+ nativeProvider.setText(virtualViewId, value);
+ }
+ return true;
+ }
+
+ return mView.performAccessibilityAction(action, arguments);
+ }
+
+ @Override
+ public AccessibilityNodeInfo findFocus(final int focus) {
+ switch (focus) {
+ case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY:
+ if (mAccessibilityFocusedNode != 0) {
+ return createAccessibilityNodeInfo(mAccessibilityFocusedNode);
+ }
+ break;
+ case AccessibilityNodeInfo.FOCUS_INPUT:
+ if (mFocusedNode != 0) {
+ return createAccessibilityNodeInfo(mFocusedNode);
+ }
+ break;
+ }
+
+ return super.findFocus(focus);
+ }
+
+ private AccessibilityNodeInfo getNodeFromGecko(final int virtualViewId) {
+ ThreadUtils.assertOnUiThread();
+ final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(mView, virtualViewId);
+ nativeProvider.getNodeInfo(virtualViewId, node);
+
+ // We set the bounds in parent here because we need to use the client-to-screen matrix
+ // and it is only available in the UI thread.
+ final Rect bounds = new Rect();
+ node.getBoundsInParent(bounds);
+
+ final Matrix matrix = new Matrix();
+ mSession.getClientToScreenMatrix(matrix);
+ final float[] origin = new float[2];
+ matrix.mapPoints(origin);
+ bounds.offset((int) origin[0], (int) origin[1]);
+ node.setBoundsInScreen(bounds);
+
+ return node;
+ }
+ }
+
+ // Gecko session we are proxying
+ /* package */ final GeckoSession mSession;
+ // This is the view that delegates accessibility to us. We also sends event through it.
+ private View mView;
+ // The native portion of the node provider.
+ /* package */ final NativeProvider nativeProvider = new NativeProvider();
+ private boolean mAttached = false;
+ // The current node with accessibility focus
+ private int mAccessibilityFocusedNode = 0;
+ // The current node with focus
+ private int mFocusedNode = 0;
+ private int mStartOffset = -1;
+ private int mEndOffset = -1;
+ private boolean mViewFocusRequested = false;
+
+ /* package */ SessionAccessibility(final GeckoSession session) {
+ mSession = session;
+ Settings.updateAccessibilitySettings();
+ }
+
+ /* package */ static void setForceEnabled(final boolean forceEnabled) {
+ Settings.setForceEnabled(forceEnabled);
+ }
+
+ /**
+ * Get the View instance that delegates accessibility to this session.
+ *
+ * @return View instance.
+ */
+ public @Nullable View getView() {
+ ThreadUtils.assertOnUiThread();
+
+ return mView;
+ }
+
+ /**
+ * Set the View instance that should delegate accessibility to this session.
+ *
+ * @param view View instance.
+ */
+ @UiThread
+ public void setView(final @Nullable View view) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mView != null) {
+ mView.setAccessibilityDelegate(null);
+ }
+
+ mView = view;
+
+ if (mView == null) {
+ return;
+ }
+
+ mView.setAccessibilityDelegate(
+ new View.AccessibilityDelegate() {
+ private NodeProvider mProvider;
+
+ @Override
+ public AccessibilityNodeProvider getAccessibilityNodeProvider(final View hostView) {
+ if (hostView != mView) {
+ return null;
+ }
+ if (mProvider == null) {
+ mProvider = new NodeProvider();
+ }
+ return mProvider;
+ }
+
+ @Override
+ public void sendAccessibilityEvent(final View host, final int eventType) {
+ if (eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED) {
+ // We rely on the focus events sent from Gecko.
+ return;
+ }
+
+ super.sendAccessibilityEvent(host, eventType);
+ }
+ });
+ }
+
+ private boolean isInTest() {
+ return mView != null && mView.getDisplay() == null;
+ }
+
+ private void requestViewFocus() {
+ if (!mView.isFocused() && !isInTest()) {
+ mViewFocusRequested = true;
+ mView.requestFocus();
+ }
+ }
+
+ private static class Settings {
+ private static volatile boolean sEnabled;
+ private static volatile boolean sTouchExplorationEnabled;
+ private static volatile boolean sForceEnabled;
+
+ public static void setForceEnabled(final boolean forceEnabled) {
+ sForceEnabled = forceEnabled;
+ dispatch();
+ }
+
+ static {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final AccessibilityManager accessibilityManager =
+ (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+
+ accessibilityManager.addAccessibilityStateChangeListener(
+ enabled -> updateAccessibilitySettings());
+
+ accessibilityManager.addTouchExplorationStateChangeListener(
+ enabled -> updateAccessibilitySettings());
+ }
+
+ public static boolean isEnabled() {
+ return sEnabled || sForceEnabled;
+ }
+
+ public static boolean isTouchExplorationEnabled() {
+ return sTouchExplorationEnabled || sForceEnabled;
+ }
+
+ public static void updateAccessibilitySettings() {
+ final AccessibilityManager accessibilityManager =
+ (AccessibilityManager)
+ GeckoAppShell.getApplicationContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
+ sEnabled = accessibilityManager.isEnabled();
+ sTouchExplorationEnabled = sEnabled && accessibilityManager.isTouchExplorationEnabled();
+ dispatch();
+ }
+
+ /* package */ static void dispatch() {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ toggleNativeAccessibility(isEnabled());
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ Settings.class,
+ "toggleNativeAccessibility",
+ isEnabled());
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void toggleNativeAccessibility(boolean enable);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public boolean onMotionEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!Settings.isTouchExplorationEnabled()) {
+ return false;
+ }
+
+ if (event.getSource() != InputDevice.SOURCE_TOUCHSCREEN) {
+ return false;
+ }
+
+ final int action = event.getActionMasked();
+ if ((action != MotionEvent.ACTION_HOVER_MOVE)
+ && (action != MotionEvent.ACTION_HOVER_ENTER)
+ && (action != MotionEvent.ACTION_HOVER_EXIT)) {
+ return false;
+ }
+
+ requestViewFocus();
+
+ nativeProvider.exploreByTouch(
+ mAccessibilityFocusedNode != 0 ? mAccessibilityFocusedNode : View.NO_ID,
+ event.getX(),
+ event.getY());
+
+ return true;
+ }
+
+ /* package */ void sendEvent(
+ final int eventType, final int sourceId, final int className, final GeckoBundle eventData) {
+ ThreadUtils.assertOnUiThread();
+ if (mView == null || !mAttached) {
+ return;
+ }
+
+ if (mViewFocusRequested && className == CLASSNAME_WEBVIEW) {
+ // If the view was focused from an accessiblity action or
+ // explore-by-touch, we supress this focus event to avoid noise.
+ mViewFocusRequested = false;
+ return;
+ }
+
+ final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
+ event.setPackageName(GeckoAppShell.getApplicationContext().getPackageName());
+ event.setSource(mView, sourceId);
+ event.setEnabled(true);
+
+ int eventClassName = className;
+ if (eventClassName == CLASSNAME_UNKNOWN) {
+ eventClassName = nativeProvider.getNodeClassName(sourceId);
+ }
+ event.setClassName(getClassName(eventClassName));
+
+ if (eventData != null) {
+ if (eventData.containsKey("text")) {
+ event.getText().add(eventData.getString("text"));
+ }
+ event.setContentDescription(eventData.getString("description", ""));
+ event.setAddedCount(eventData.getInt("addedCount", -1));
+ event.setRemovedCount(eventData.getInt("removedCount", -1));
+ event.setFromIndex(eventData.getInt("fromIndex", -1));
+ event.setItemCount(eventData.getInt("itemCount", -1));
+ event.setCurrentItemIndex(eventData.getInt("currentItemIndex", -1));
+ event.setBeforeText(eventData.getString("beforeText", ""));
+ event.setToIndex(eventData.getInt("toIndex", -1));
+ event.setScrollX(eventData.getInt("scrollX", -1));
+ event.setScrollY(eventData.getInt("scrollY", -1));
+ event.setMaxScrollX(eventData.getInt("maxScrollX", -1));
+ event.setMaxScrollY(eventData.getInt("maxScrollY", -1));
+ event.setChecked((eventData.getInt("flags") & FLAG_CHECKED) != 0);
+ }
+
+ // Update stored state from this event.
+ switch (eventType) {
+ case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED:
+ if (mAccessibilityFocusedNode == sourceId) {
+ mAccessibilityFocusedNode = 0;
+ }
+ break;
+ case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED:
+ mStartOffset = -1;
+ mEndOffset = -1;
+ mAccessibilityFocusedNode = sourceId;
+ break;
+ case AccessibilityEvent.TYPE_VIEW_FOCUSED:
+ mFocusedNode = sourceId;
+ if (!mView.isFocused() && !isInTest()) {
+ // Don't dispatch a focus event if the parent view is not focused
+ return;
+ }
+ break;
+ case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY:
+ mStartOffset = event.getFromIndex();
+ mEndOffset = event.getToIndex();
+ break;
+ }
+
+ try {
+ ((ViewParent) mView).requestSendAccessibilityEvent(mView, event);
+ } catch (final IllegalStateException ex) {
+ // Accessibility could be activated in Gecko via xpcom, for example when using a11y
+ // devtools. Events that are forwarded to the platform will throw an exception.
+ }
+ }
+
+ private boolean pivot(
+ final int id, final String granularity, final boolean forward, final boolean inclusive) {
+ if (!forward && id == View.NO_ID) {
+ // If attempting to pivot backwards from the root view, return false.
+ return false;
+ }
+
+ final int gran = java.util.Arrays.asList(sHtmlGranularities).indexOf(granularity);
+ final boolean success = nativeProvider.pivotNative(id, gran, forward, inclusive);
+ if (!success && !forward) {
+ // If we failed to pivot backwards set the root view as the a11y focus.
+ sendEvent(
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, View.NO_ID, CLASSNAME_WEBVIEW, null);
+ return true;
+ }
+
+ return success;
+ }
+
+ /* package */ final class NativeProvider extends JNIObject {
+ @WrapForJNI(calledFrom = "ui")
+ private void setAttached(final boolean attached) {
+ mAttached = attached;
+ }
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ // Disposal happens in native code.
+ throw new UnsupportedOperationException();
+ }
+
+ @WrapForJNI(dispatchTo = "current")
+ public native void getNodeInfo(int id, AccessibilityNodeInfo nodeInfo);
+
+ @WrapForJNI(dispatchTo = "current")
+ public native int getNodeClassName(int id);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void setText(int id, String text);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void click(int id);
+
+ @WrapForJNI(dispatchTo = "current", stubName = "Pivot")
+ public native boolean pivotNative(int id, int granularity, boolean forward, boolean inclusive);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void exploreByTouch(int id, float x, float y);
+
+ @WrapForJNI(dispatchTo = "current")
+ public native boolean navigateText(
+ int id, int granularity, int startOffset, int endOffset, boolean forward, boolean select);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void setSelection(int id, int start, int end);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void cut(int id);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void copy(int id);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void paste(int id);
+
+ @WrapForJNI(calledFrom = "gecko", stubName = "SendEvent")
+ private void sendEventNative(
+ final int eventType, final int sourceId, final int className, final GeckoBundle eventData) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ sendEvent(eventType, sourceId, className, eventData);
+ }
+ });
+ }
+
+ @WrapForJNI
+ private void populateNodeInfo(
+ final AccessibilityNodeInfo node,
+ final int id,
+ final int parentId,
+ final int[] children,
+ final int flags,
+ final int className,
+ final int[] bounds,
+ @Nullable final String text,
+ @Nullable final String description,
+ @Nullable final String hint,
+ @Nullable final String geckoRole,
+ @Nullable final String roleDescription,
+ @Nullable final String viewIdResourceName,
+ final int inputType) {
+ if (mView == null) {
+ return;
+ }
+
+ final boolean isRoot = id == View.NO_ID;
+ if (isRoot) {
+ if (mView.getDisplay() != null) {
+ // When running junit tests we don't have a display
+ mView.onInitializeAccessibilityNodeInfo(node);
+ }
+ node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
+ node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
+ } else {
+ node.setParent(mView, parentId);
+ }
+
+ // The basics
+ node.setPackageName(GeckoAppShell.getApplicationContext().getPackageName());
+ node.setClassName(getClassName(className));
+
+ if (text != null) {
+ node.setText(text);
+ }
+
+ if (description != null) {
+ node.setContentDescription(description);
+ }
+
+ // Add actions
+ node.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT);
+ node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT);
+ node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
+ node.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
+ node.setMovementGranularities(
+ AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH);
+ if ((flags & FLAG_CLICKABLE) != 0) {
+ node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
+ }
+
+ // Set boolean properties
+ node.setCheckable((flags & FLAG_CHECKABLE) != 0);
+ node.setChecked((flags & FLAG_CHECKED) != 0);
+ node.setClickable((flags & FLAG_CLICKABLE) != 0);
+ node.setEnabled((flags & FLAG_ENABLED) != 0);
+ node.setFocusable((flags & FLAG_FOCUSABLE) != 0);
+ node.setLongClickable((flags & FLAG_LONG_CLICKABLE) != 0);
+ node.setPassword((flags & FLAG_PASSWORD) != 0);
+ node.setScrollable((flags & FLAG_SCROLLABLE) != 0);
+ node.setSelected((flags & FLAG_SELECTED) != 0);
+ node.setVisibleToUser((flags & FLAG_VISIBLE_TO_USER) != 0);
+ // Other boolean properties to consider later:
+ // setHeading, setImportantForAccessibility, setScreenReaderFocusable, setShowingHintText,
+ // setDismissable
+
+ if (mAccessibilityFocusedNode == id) {
+ node.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
+ node.setAccessibilityFocused(true);
+ } else {
+ node.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS);
+ }
+ node.setFocused(mFocusedNode == id);
+
+ final Rect parentBounds = new Rect(bounds[0], bounds[1], bounds[2], bounds[3]);
+ node.setBoundsInParent(parentBounds);
+
+ for (final int childId : children) {
+ node.addChild(mView, childId);
+ }
+
+ node.setViewIdResourceName(viewIdResourceName);
+
+ if ((flags & FLAG_EDITABLE) != 0) {
+ node.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION);
+ node.addAction(AccessibilityNodeInfo.ACTION_CUT);
+ node.addAction(AccessibilityNodeInfo.ACTION_COPY);
+ node.addAction(AccessibilityNodeInfo.ACTION_PASTE);
+ node.setEditable(true);
+ }
+
+ node.setMultiLine((flags & FLAG_MULTI_LINE) != 0);
+ node.setContentInvalid((flags & FLAG_CONTENT_INVALID) != 0);
+
+ // Set bundle keys like role and hint
+ final Bundle bundle = node.getExtras();
+ if (hint != null) {
+ bundle.putCharSequence("AccessibilityNodeInfo.hint", hint);
+ if (Build.VERSION.SDK_INT >= 26) {
+ node.setHintText(hint);
+ }
+ }
+ if (geckoRole != null) {
+ bundle.putCharSequence("AccessibilityNodeInfo.geckoRole", geckoRole);
+ }
+ if (roleDescription != null) {
+ bundle.putCharSequence("AccessibilityNodeInfo.roleDescription", roleDescription);
+ }
+ if (isRoot) {
+ // Argument values for ACTION_NEXT_HTML_ELEMENT/ACTION_PREVIOUS_HTML_ELEMENT.
+ // This is mostly here to let TalkBack know we are a legit "WebView".
+ bundle.putCharSequence(
+ "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES", TextUtils.join(",", sHtmlGranularities));
+ }
+
+ if (inputType != InputType.TYPE_NULL) {
+ node.setInputType(inputType);
+ }
+
+ if ((flags & FLAG_EXPANDABLE) != 0) {
+ if ((flags & FLAG_EXPANDED) != 0) {
+ node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
+ node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
+ } else {
+ node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
+ node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
+ }
+ }
+
+ // SDK 23 and above
+ if (Build.VERSION.SDK_INT >= 23) {
+ node.setContextClickable((flags & FLAG_CONTEXT_CLICKABLE) != 0);
+ }
+ }
+
+ @WrapForJNI
+ private void populateNodeCollectionItemInfo(
+ final AccessibilityNodeInfo node,
+ final int rowIndex,
+ final int rowSpan,
+ final int columnIndex,
+ final int columnSpan) {
+ final CollectionItemInfo collectionItemInfo =
+ CollectionItemInfo.obtain(rowIndex, rowSpan, columnIndex, columnSpan, false);
+ node.setCollectionItemInfo(collectionItemInfo);
+ }
+
+ @WrapForJNI
+ private void populateNodeCollectionInfo(
+ final AccessibilityNodeInfo node,
+ final int rowCount,
+ final int columnCount,
+ final int selectionMode,
+ final boolean isHierarchical) {
+ final CollectionInfo collectionInfo =
+ CollectionInfo.obtain(rowCount, columnCount, isHierarchical, selectionMode);
+ node.setCollectionInfo(collectionInfo);
+ }
+
+ @WrapForJNI
+ private void populateNodeRangeInfo(
+ final AccessibilityNodeInfo node,
+ final int rangeType,
+ final float min,
+ final float max,
+ final float current) {
+ final RangeInfo rangeInfo = RangeInfo.obtain(rangeType, min, max, current);
+ node.setRangeInfo(rangeInfo);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java
new file mode 100644
index 0000000000..2ed0b1a6c3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java
@@ -0,0 +1,131 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Pair;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.Arrays;
+import java.util.List;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoSession.FinderDisplayFlags;
+import org.mozilla.geckoview.GeckoSession.FinderFindFlags;
+import org.mozilla.geckoview.GeckoSession.FinderResult;
+
+/**
+ * {@code SessionFinder} instances returned by {@link GeckoSession#getFinder()} performs
+ * find-in-page operations.
+ */
+@AnyThread
+public final class SessionFinder {
+ private static final String LOGTAG = "GeckoSessionFinder";
+
+ private static final List<Pair<Integer, String>> sFlagNames =
+ Arrays.asList(
+ new Pair<>(GeckoSession.FINDER_FIND_BACKWARDS, "backwards"),
+ new Pair<>(GeckoSession.FINDER_FIND_LINKS_ONLY, "linksOnly"),
+ new Pair<>(GeckoSession.FINDER_FIND_MATCH_CASE, "matchCase"),
+ new Pair<>(GeckoSession.FINDER_FIND_WHOLE_WORD, "wholeWord"));
+
+ private static void addFlagsToBundle(
+ @FinderFindFlags final int flags, @NonNull final GeckoBundle bundle) {
+ for (final Pair<Integer, String> name : sFlagNames) {
+ if ((flags & name.first) != 0) {
+ bundle.putBoolean(name.second, true);
+ }
+ }
+ }
+
+ /* package */ static int getFlagsFromBundle(@Nullable final GeckoBundle bundle) {
+ if (bundle == null) {
+ return 0;
+ }
+
+ int flags = 0;
+ for (final Pair<Integer, String> name : sFlagNames) {
+ if (bundle.getBoolean(name.second)) {
+ flags |= name.first;
+ }
+ }
+ return flags;
+ }
+
+ private final EventDispatcher mDispatcher;
+ @FinderDisplayFlags private int mDisplayFlags;
+
+ /* package */ SessionFinder(@NonNull final EventDispatcher dispatcher) {
+ mDispatcher = dispatcher;
+ setDisplayFlags(0);
+ }
+
+ /**
+ * Find and select a string on the current page, starting from the current selection or the start
+ * of the page if there is no selection. Optionally return results related to the search in a
+ * {@link FinderResult} object. If {@code searchString} is null, search is performed using the
+ * previous search string.
+ *
+ * @param searchString String to search, or null to find again using the previous string.
+ * @param flags Flags for performing the search; either 0 or a combination of {@link
+ * GeckoSession#FINDER_FIND_BACKWARDS FINDER_FIND_*} constants.
+ * @return Result of the search operation as a {@link GeckoResult} object.
+ * @see #clear
+ * @see #setDisplayFlags
+ */
+ @NonNull
+ public GeckoResult<FinderResult> find(
+ @Nullable final String searchString, @FinderFindFlags final int flags) {
+ final GeckoBundle bundle = new GeckoBundle(sFlagNames.size() + 1);
+ bundle.putString("searchString", searchString);
+ addFlagsToBundle(flags, bundle);
+
+ return mDispatcher
+ .queryBundle("GeckoView:FindInPage", bundle)
+ .map(response -> new FinderResult(response));
+ }
+
+ /**
+ * Clear any highlighted find-in-page matches.
+ *
+ * @see #find
+ * @see #setDisplayFlags
+ */
+ public void clear() {
+ mDispatcher.dispatch("GeckoView:ClearMatches", null);
+ }
+
+ /**
+ * Return flags for displaying find-in-page matches.
+ *
+ * @return Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL
+ * FINDER_DISPLAY_*} constants.
+ * @see #setDisplayFlags
+ * @see #find
+ */
+ @FinderDisplayFlags
+ public int getDisplayFlags() {
+ return mDisplayFlags;
+ }
+
+ /**
+ * Set flags for displaying find-in-page matches.
+ *
+ * @param flags Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL
+ * FINDER_DISPLAY_*} constants.
+ * @see #getDisplayFlags
+ * @see #find
+ */
+ public void setDisplayFlags(@FinderDisplayFlags final int flags) {
+ mDisplayFlags = flags;
+
+ final GeckoBundle bundle = new GeckoBundle(3);
+ bundle.putBoolean("highlightAll", (flags & GeckoSession.FINDER_DISPLAY_HIGHLIGHT_ALL) != 0);
+ bundle.putBoolean("dimPage", (flags & GeckoSession.FINDER_DISPLAY_DIM_PAGE) != 0);
+ bundle.putBoolean("drawOutline", (flags & GeckoSession.FINDER_DISPLAY_DRAW_LINK_OUTLINE) != 0);
+ mDispatcher.dispatch("GeckoView:DisplayMatches", bundle);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java
new file mode 100644
index 0000000000..3d92b11e81
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java
@@ -0,0 +1,99 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * {@code PdfFileSaver} instances returned by {@link GeckoSession#getPdfFileSaver()} performs save
+ * operation.
+ */
+@AnyThread
+public final class SessionPdfFileSaver {
+ private static final String LOGTAG = "GeckoPdfFileSaver";
+
+ private final GeckoSession mSession;
+
+ /* package */ SessionPdfFileSaver(@NonNull final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Save the current PDF.
+ *
+ * @return Result of the save operation as a {@link GeckoResult} object.
+ */
+ @NonNull
+ public GeckoResult<WebResponse> save() {
+ final GeckoResult<WebResponse> geckoResult = new GeckoResult<>();
+ mSession
+ .getEventDispatcher()
+ .queryBundle("GeckoView:PDFSave", null)
+ .map(
+ response -> {
+ geckoResult.completeFrom(
+ SessionPdfFileSaver.createResponse(
+ mSession,
+ response.getString("url"),
+ response.getString("filename"),
+ response.getString("originalUrl"),
+ true,
+ false));
+ return null;
+ });
+ return geckoResult;
+ }
+
+ /**
+ * Create a WebResponse from some binary data in order to use it to download a PDF file.
+ *
+ * @param session The session.
+ * @param url The url for fetching the data.
+ * @param filename The file name.
+ * @param originalUrl The original url for the file.
+ * @param skipConfirmation Whether to skip the confirmation dialog.
+ * @param requestExternalApp Whether to request an external app to open the file.
+ * @return a response used to "download" the pdf.
+ */
+ public static @Nullable GeckoResult<WebResponse> createResponse(
+ @NonNull final GeckoSession session,
+ @NonNull final String url,
+ @NonNull final String filename,
+ @NonNull final String originalUrl,
+ final boolean skipConfirmation,
+ final boolean requestExternalApp) {
+ try {
+ final GeckoWebExecutor executor = new GeckoWebExecutor(session.getRuntime());
+ final WebRequest request = new WebRequest(url);
+ return executor
+ .fetch(request)
+ .then(
+ new GeckoResult.OnValueListener<WebResponse, WebResponse>() {
+ @Override
+ public GeckoResult<WebResponse> onValue(final WebResponse response) {
+ final int statusCode = response.statusCode != 0 ? response.statusCode : 200;
+ return GeckoResult.fromValue(
+ new WebResponse.Builder(
+ originalUrl.startsWith("content://") ? url : originalUrl)
+ .statusCode(statusCode)
+ .body(response.body)
+ .skipConfirmation(skipConfirmation)
+ .requestExternalApp(requestExternalApp)
+ .addHeader("Content-Type", "application/pdf")
+ .addHeader(
+ "Content-Disposition", "attachment; filename=\"" + filename + "\"")
+ .build());
+ }
+ });
+ } catch (final Exception e) {
+ Log.d(LOGTAG, e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java
new file mode 100644
index 0000000000..079d1c0160
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java
@@ -0,0 +1,461 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.content.Context;
+import android.graphics.RectF;
+import android.os.Handler;
+import android.text.Editable;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.CursorAnchorInfo;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.NativeQueue;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * {@code SessionTextInput} handles text input for {@code GeckoSession} through key events or input
+ * methods. It is typically used to implement certain methods in {@link android.view.View} such as
+ * {@link android.view.View#onCreateInputConnection}, by forwarding such calls to corresponding
+ * methods in {@code SessionTextInput}.
+ *
+ * <p>For full functionality, {@code SessionTextInput} requires a {@link android.view.View} to be
+ * set first through {@link #setView}. When a {@link android.view.View} is not set or set to null,
+ * {@code SessionTextInput} will operate in a reduced functionality mode. See {@link
+ * #onCreateInputConnection} and methods in {@link GeckoSession.TextInputDelegate} for changes in
+ * behavior in this viewless mode.
+ */
+public final class SessionTextInput {
+ /* package */ static final String LOGTAG = "GeckoSessionTextInput";
+ private static final boolean DEBUG = false;
+
+ // Interface to access GeckoInputConnection from SessionTextInput.
+ /* package */ interface InputConnectionClient {
+ View getView();
+
+ Handler getHandler(Handler defHandler);
+
+ InputConnection onCreateInputConnection(EditorInfo attrs);
+ }
+
+ // Interface to access GeckoEditable from GeckoInputConnection.
+ /* package */ interface EditableClient {
+ // The following value is used by requestCursorUpdates
+ // ONE_SHOT calls updateCompositionRects() after getting current composing
+ // character rects.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ONE_SHOT, START_MONITOR, END_MONITOR})
+ /* package */ @interface CursorMonitorMode {}
+
+ @WrapForJNI int ONE_SHOT = 1;
+ // START_MONITOR start the monitor for composing character rects. If is is
+ // updaed, call updateCompositionRects()
+ @WrapForJNI int START_MONITOR = 2;
+ // ENDT_MONITOR stops the monitor for composing character rects.
+ @WrapForJNI int END_MONITOR = 3;
+
+ void sendKeyEvent(@Nullable View view, int action, @NonNull KeyEvent event);
+
+ Editable getEditable();
+
+ void setBatchMode(boolean isBatchMode);
+
+ Handler setInputConnectionHandler(@NonNull Handler handler);
+
+ void postToInputConnection(@NonNull Runnable runnable);
+
+ void requestCursorUpdates(@CursorMonitorMode int requestMode);
+
+ void insertImage(@NonNull byte[] data, @NonNull String mimeType);
+ }
+
+ // Interface to access GeckoInputConnection from GeckoEditable.
+ /* package */ interface EditableListener {
+ // IME notification type for notifyIME(), corresponding to NotificationToIME enum.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ NOTIFY_IME_OF_TOKEN,
+ NOTIFY_IME_OPEN_VKB,
+ NOTIFY_IME_REPLY_EVENT,
+ NOTIFY_IME_OF_FOCUS,
+ NOTIFY_IME_OF_BLUR,
+ NOTIFY_IME_TO_COMMIT_COMPOSITION,
+ NOTIFY_IME_TO_CANCEL_COMPOSITION
+ })
+ /* package */ @interface IMENotificationType {}
+
+ @WrapForJNI int NOTIFY_IME_OF_TOKEN = -3;
+ @WrapForJNI int NOTIFY_IME_OPEN_VKB = -2;
+ @WrapForJNI int NOTIFY_IME_REPLY_EVENT = -1;
+ @WrapForJNI int NOTIFY_IME_OF_FOCUS = 1;
+ @WrapForJNI int NOTIFY_IME_OF_BLUR = 2;
+ @WrapForJNI int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8;
+ @WrapForJNI int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9;
+
+ // IME enabled state for notifyIMEContext().
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({IME_STATE_UNKNOWN, IME_STATE_DISABLED, IME_STATE_ENABLED, IME_STATE_PASSWORD})
+ /* package */ @interface IMEState {}
+
+ int IME_STATE_UNKNOWN = -1;
+ int IME_STATE_DISABLED = 0;
+ int IME_STATE_ENABLED = 1;
+ int IME_STATE_PASSWORD = 2;
+
+ // Flags for notifyIMEContext().
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {IME_FLAG_PRIVATE_BROWSING, IME_FLAG_USER_ACTION, IME_FOCUS_NOT_CHANGED})
+ /* package */ @interface IMEContextFlags {}
+
+ @WrapForJNI int IME_FLAG_PRIVATE_BROWSING = 1 << 0;
+ @WrapForJNI int IME_FLAG_USER_ACTION = 1 << 1;
+ @WrapForJNI int IME_FOCUS_NOT_CHANGED = 1 << 2;
+
+ void notifyIME(@IMENotificationType int type);
+
+ void notifyIMEContext(
+ @IMEState int state,
+ String typeHint,
+ String modeHint,
+ String actionHint,
+ @IMEContextFlags int flag);
+
+ void onSelectionChange();
+
+ void onTextChange();
+
+ void onDiscardComposition();
+
+ void onDefaultKeyEvent(KeyEvent event);
+
+ void updateCompositionRects(final RectF[] aRects, final RectF aCaretRect);
+ }
+
+ private static final class DefaultDelegate implements GeckoSession.TextInputDelegate {
+ public static final DefaultDelegate INSTANCE = new DefaultDelegate();
+
+ private InputMethodManager getInputMethodManager(@Nullable final View view) {
+ if (view == null) {
+ return null;
+ }
+ return (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ }
+
+ @Override
+ public void restartInput(@NonNull final GeckoSession session, final int reason) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm == null) {
+ return;
+ }
+
+ // InputMethodManager has internal logic to detect if we are restarting input
+ // in an already focused View, which is the case here because all content text
+ // fields are inside one LayerView. When this happens, InputMethodManager will
+ // tell the input method to soft reset instead of hard reset. Stock latin IME
+ // on Android 4.2+ has a quirk that when it soft resets, it does not clear the
+ // composition. The following workaround tricks the IME into clearing the
+ // composition when soft resetting.
+ if (InputMethods.needsSoftResetWorkaround(
+ InputMethods.getCurrentInputMethod(view.getContext()))) {
+ // Fake a selection change, because the IME clears the composition when
+ // the selection changes, even if soft-resetting. Offsets here must be
+ // different from the previous selection offsets, and -1 seems to be a
+ // reasonable, deterministic value
+ imm.updateSelection(view, -1, -1, -1, -1);
+ }
+
+ try {
+ imm.restartInput(view);
+ } catch (final RuntimeException e) {
+ Log.e(LOGTAG, "Error restarting input", e);
+ }
+ }
+
+ @Override
+ public void showSoftInput(@NonNull final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ if (view.hasFocus() && !imm.isActive(view)) {
+ // Marshmallow workaround: The view has focus but it is not the active
+ // view for the input method. (Bug 1211848)
+ view.clearFocus();
+ view.requestFocus();
+ }
+ imm.showSoftInput(view, 0);
+ }
+ }
+
+ @Override
+ public void hideSoftInput(@NonNull final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
+ }
+ }
+
+ @Override
+ public void updateSelection(
+ @NonNull final GeckoSession session,
+ final int selStart,
+ final int selEnd,
+ final int compositionStart,
+ final int compositionEnd) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ // When composition start and end is -1,
+ // InputMethodManager.updateSelection will remove composition
+ // on most IMEs. If not working, we have to add a workaround
+ // to EditableListener.onDiscardComposition.
+ imm.updateSelection(view, selStart, selEnd, compositionStart, compositionEnd);
+ }
+ }
+
+ @Override
+ public void updateExtractedText(
+ @NonNull final GeckoSession session,
+ @NonNull final ExtractedTextRequest request,
+ @NonNull final ExtractedText text) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.updateExtractedText(view, request.token, text);
+ }
+ }
+
+ @Override
+ public void updateCursorAnchorInfo(
+ @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.updateCursorAnchorInfo(view, info);
+ }
+ }
+ }
+
+ private final GeckoSession mSession;
+ private final NativeQueue mQueue;
+ private final GeckoEditable mEditable;
+ private InputConnectionClient mInputConnection;
+ private GeckoSession.TextInputDelegate mDelegate;
+
+ /* package */ SessionTextInput(
+ final @NonNull GeckoSession session, final @NonNull NativeQueue queue) {
+ mSession = session;
+ mQueue = queue;
+ mEditable = new GeckoEditable(session);
+ }
+
+ /* package */ void onWindowChanged(final GeckoSession.Window window) {
+ if (mQueue.isReady()) {
+ window.attachEditable(mEditable);
+ } else {
+ mQueue.queueUntilReady(window, "attachEditable", IGeckoEditableParent.class, mEditable);
+ }
+ }
+
+ /**
+ * Get a Handler for the background input method thread. In order to use a background thread for
+ * input method operations on systems prior to Nougat, first override {@code View.getHandler()}
+ * for the View returning the InputConnection instance, and then call this method from the
+ * overridden method.
+ *
+ * <p>For example:
+ *
+ * <pre>
+ * &#64;Override
+ * public Handler getHandler() {
+ * if (Build.VERSION.SDK_INT &gt;= 24) {
+ * return super.getHandler();
+ * }
+ * return getSession().getTextInput().getHandler(super.getHandler());
+ * }</pre>
+ *
+ * @param defHandler Handler returned by the system {@code getHandler} implementation.
+ * @return Handler to return to the system through {@code getHandler}.
+ */
+ @AnyThread
+ public synchronized @NonNull Handler getHandler(final @NonNull Handler defHandler) {
+ // May be called on any thread.
+ if (mInputConnection != null) {
+ return mInputConnection.getHandler(defHandler);
+ }
+ return defHandler;
+ }
+
+ /**
+ * Get the current {@link android.view.View} for text input.
+ *
+ * @return Current text input View or null if not set.
+ * @see #setView(View)
+ */
+ @UiThread
+ public @Nullable View getView() {
+ ThreadUtils.assertOnUiThread();
+ return mInputConnection != null ? mInputConnection.getView() : null;
+ }
+
+ /**
+ * Set the current {@link android.view.View} for text input. The {@link android.view.View} is used
+ * to interact with the system input method manager and to display certain text input UI elements.
+ * See the {@code SessionTextInput} class documentation for information on viewless mode, when the
+ * current {@link android.view.View} is not set or set to null.
+ *
+ * @param view Text input View or null to clear current View.
+ * @see #getView()
+ */
+ @UiThread
+ public synchronized void setView(final @Nullable View view) {
+ ThreadUtils.assertOnUiThread();
+
+ if (view == null) {
+ mInputConnection = null;
+ } else if (mInputConnection == null || mInputConnection.getView() != view) {
+ mInputConnection = GeckoInputConnection.create(mSession, view, mEditable);
+ }
+ mEditable.setListener((EditableListener) mInputConnection);
+ }
+
+ /**
+ * Get an {@link android.view.inputmethod.InputConnection} instance. In viewless mode, this method
+ * still fills out the {@link android.view.inputmethod.EditorInfo} object, but the return value
+ * will always be null.
+ *
+ * @param attrs EditorInfo instance to be filled on return.
+ * @return InputConnection instance, or null if there is no active input (or if in viewless mode).
+ */
+ @AnyThread
+ public synchronized @Nullable InputConnection onCreateInputConnection(
+ final @NonNull EditorInfo attrs) {
+ // May be called on any thread.
+ mEditable.onCreateInputConnection(attrs);
+
+ if (!mQueue.isReady() || mInputConnection == null) {
+ return null;
+ }
+ return mInputConnection.onCreateInputConnection(attrs);
+ }
+
+ /**
+ * Process a KeyEvent as a pre-IME event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyPreIme(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyPreIme(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a key-down event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyDown(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyDown(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a key-up event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyUp(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyUp(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a long-press event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyLongPress(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyLongPress(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a multiple-press event.
+ *
+ * @param keyCode Key code.
+ * @param repeatCount Key repeat count.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyMultiple(
+ final int keyCode, final int repeatCount, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyMultiple(getView(), keyCode, repeatCount, event);
+ }
+
+ /**
+ * Set the current text input delegate.
+ *
+ * @param delegate TextInputDelegate instance or null to restore to default.
+ */
+ @UiThread
+ public void setDelegate(@Nullable final GeckoSession.TextInputDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Get the current text input delegate.
+ *
+ * @return TextInputDelegate instance or a default instance if no delegate has been set.
+ */
+ @UiThread
+ public @NonNull GeckoSession.TextInputDelegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+ if (mDelegate == null) {
+ mDelegate = DefaultDelegate.INSTANCE;
+ }
+ return mDelegate;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
new file mode 100644
index 0000000000..d25c51ef9a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
@@ -0,0 +1,20 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+
+/**
+ * Used by a ContentDelegate to indicate what action to take on a slow script event.
+ *
+ * @see GeckoSession.ContentDelegate#onSlowScript(GeckoSession,String)
+ */
+@AnyThread
+public enum SlowScriptResponse {
+ STOP,
+ CONTINUE;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java
new file mode 100644
index 0000000000..a49cdf26a5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java
@@ -0,0 +1,405 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.math.BigInteger;
+import java.nio.charset.Charset;
+import java.util.List;
+import java.util.Locale;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission;
+
+/**
+ * Manage runtime storage data.
+ *
+ * <p>Retrieve an instance via {@link GeckoRuntime#getStorageController}.
+ */
+public final class StorageController {
+ private static final String LOGTAG = "StorageController";
+
+ // Keep in sync with GeckoViewStorageController.ClearFlags.
+ /** Flags used for data clearing operations. */
+ public static class ClearFlags {
+ /** Cookies. */
+ public static final long COOKIES = 1 << 0;
+
+ /** Network cache. */
+ public static final long NETWORK_CACHE = 1 << 1;
+
+ /** Image cache. */
+ public static final long IMAGE_CACHE = 1 << 2;
+
+ /** DOM storages. */
+ public static final long DOM_STORAGES = 1 << 4;
+
+ /** Auth tokens and caches. */
+ public static final long AUTH_SESSIONS = 1 << 5;
+
+ /** Site permissions. */
+ public static final long PERMISSIONS = 1 << 6;
+
+ /** All caches. */
+ public static final long ALL_CACHES = NETWORK_CACHE | IMAGE_CACHE;
+
+ /** All site settings (permissions, content preferences, security settings, etc.). */
+ public static final long SITE_SETTINGS = 1 << 7 | PERMISSIONS;
+
+ /** All site-related data (cookies, storages, caches, permissions, etc.). */
+ public static final long SITE_DATA =
+ 1 << 8 | COOKIES | DOM_STORAGES | ALL_CACHES | PERMISSIONS | SITE_SETTINGS;
+
+ /** All data. */
+ public static final long ALL = 1 << 9;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {
+ ClearFlags.COOKIES,
+ ClearFlags.NETWORK_CACHE,
+ ClearFlags.IMAGE_CACHE,
+ ClearFlags.DOM_STORAGES,
+ ClearFlags.AUTH_SESSIONS,
+ ClearFlags.PERMISSIONS,
+ ClearFlags.ALL_CACHES,
+ ClearFlags.SITE_SETTINGS,
+ ClearFlags.SITE_DATA,
+ ClearFlags.ALL
+ })
+ public @interface StorageControllerClearFlags {}
+
+ /**
+ * Clear data for all hosts.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearData(final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearData", bundle);
+ }
+
+ /**
+ * Clear data owned by the given host. Clearing data for a host will not clear data created by its
+ * third-party origins.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param host The host to be used.
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearDataFromHost(
+ final @NonNull String host, final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("host", host);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearHostData", bundle);
+ }
+
+ /**
+ * Clear data owned by the given base domain (eTLD+1). Clearing data for a base domain will also
+ * clear any associated third-party storage. This includes clearing for third-parties embedded by
+ * the domain and for the given domain embedded under other sites.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param baseDomain The base domain to be used.
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearDataFromBaseDomain(
+ final @NonNull String baseDomain, final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("baseDomain", baseDomain);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearBaseDomainData", bundle);
+ }
+
+ /**
+ * Clear data for the given context ID. Use {@link GeckoSessionSettings.Builder#contextId}.to set
+ * a context ID for a session.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions for the given context prior to
+ * clearing data.
+ *
+ * @param contextId The context ID for the storage data to be deleted.
+ */
+ @AnyThread
+ public void clearDataForSessionContext(final @NonNull String contextId) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("contextId", createSafeSessionContextId(contextId));
+
+ EventDispatcher.getInstance().dispatch("GeckoView:ClearSessionContextData", bundle);
+ }
+
+ /* package */ static @Nullable String createSafeSessionContextId(
+ final @Nullable String contextId) {
+ if (contextId == null) {
+ return null;
+ }
+ if (contextId.isEmpty()) {
+ // Let's avoid empty strings for Gecko.
+ return "gvctxempty";
+ }
+ // We don't want to restrict the session context ID string options, so to
+ // ensure that the string is safe for Gecko processing, we translate it to
+ // its hex representation.
+ return String.format("gvctx%x", new BigInteger(contextId.getBytes())).toLowerCase(Locale.ROOT);
+ }
+
+ /* package */ static @Nullable String retrieveUnsafeSessionContextId(
+ final @Nullable String contextId) {
+ if (contextId == null || contextId.isEmpty()) {
+ return null;
+ }
+ if ("gvctxempty".equals(contextId)) {
+ return "";
+ }
+ final byte[] bytes = new BigInteger(contextId.substring(5), 16).toByteArray();
+ return new String(bytes, Charset.forName("UTF-8"));
+ }
+
+ /**
+ * Get all currently stored permissions.
+ *
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getAllPermissions() {
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetAllPermissions")
+ .map(
+ bundle -> {
+ final GeckoBundle[] permsArray = bundle.getBundleArray("permissions");
+ return ContentPermission.fromBundleArray(permsArray);
+ });
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and default (unset) context ID, in normal
+ * mode This API will be deprecated in the future
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1797379
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(final @NonNull String uri) {
+ return getPermissions(uri, null, false);
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and default (unset) context ID.
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal
+ * mode.
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(
+ final @NonNull String uri, final boolean privateMode) {
+ return getPermissions(uri, null, privateMode);
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and context ID.
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @param contextId A String specifying the context ID.
+ * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal
+ * mode
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(
+ final @NonNull String uri, final @Nullable String contextId, final boolean privateMode) {
+ final GeckoBundle msg = new GeckoBundle(2);
+ final int privateBrowsingId = (privateMode) ? 1 : 0;
+ msg.putString("uri", uri);
+ msg.putString("contextId", createSafeSessionContextId(contextId));
+ msg.putInt("privateBrowsingId", privateBrowsingId);
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetPermissionsByURI", msg)
+ .map(
+ bundle -> {
+ final GeckoBundle[] permsArray = bundle.getBundleArray("permissions");
+ return ContentPermission.fromBundleArray(permsArray);
+ });
+ }
+
+ /**
+ * Set a new value for an existing permission.
+ *
+ * <p>Note: in private browsing, this value will only be cleared at the end of the session to add
+ * permanent permissions in private browsing, you can use {@link
+ * #setPrivateBrowsingPermanentPermission}.
+ *
+ * @param perm A {@link ContentPermission} that you wish to update the value of.
+ * @param value The new value for the permission.
+ */
+ @AnyThread
+ public void setPermission(
+ final @NonNull ContentPermission perm, final @ContentPermission.Value int value) {
+ setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ false);
+ }
+
+ /**
+ * Set a permanent value for a permission in a private browsing session.
+ *
+ * <p>Normally permissions in private browsing are cleared at the end of the session. This method
+ * allows you to set a permanent permission bypassing this behavior.
+ *
+ * <p>Note: permanent permissions in private browsing are web discoverable and might make the user
+ * more easily trackable.
+ *
+ * @see #setPermission
+ * @param perm A {@link ContentPermission} that you wish to update the value of.
+ * @param value The new value for the permission.
+ */
+ @AnyThread
+ public void setPrivateBrowsingPermanentPermission(
+ final @NonNull ContentPermission perm, final @ContentPermission.Value int value) {
+ setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ true);
+ }
+
+ private void setPermissionInternal(
+ final @NonNull ContentPermission perm,
+ final @ContentPermission.Value int value,
+ final boolean allowPermanentPrivateBrowsing) {
+ if (perm.permission == GeckoSession.PermissionDelegate.PERMISSION_TRACKING
+ && value == ContentPermission.VALUE_PROMPT) {
+ Log.w(LOGTAG, "Cannot set a tracking permission to VALUE_PROMPT, aborting.");
+ return;
+ }
+ final GeckoBundle msg = perm.toGeckoBundle();
+ msg.putInt("newValue", value);
+ msg.putBoolean("allowPermanentPrivateBrowsing", allowPermanentPrivateBrowsing);
+ EventDispatcher.getInstance().dispatch("GeckoView:SetPermission", msg);
+ }
+
+ /**
+ * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode.
+ *
+ * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode}
+ * value.
+ * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri.
+ * @param isPrivateBrowsing Indicates in which browsing mode the given {@link
+ * ContentBlocking.CBCookieBannerMode} should be applied.
+ * @return A {@link GeckoResult} that will complete when the mode has been set.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> setCookieBannerModeForDomain(
+ final @NonNull String uri,
+ final @ContentBlocking.CBCookieBannerMode int mode,
+ final boolean isPrivateBrowsing) {
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putInt("mode", mode);
+ data.putBoolean("allowPermanentPrivateBrowsing", false);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri in private mode.
+ *
+ * @param uri for which you want to change the {@link ContentBlocking.CBCookieBannerMode} value.
+ * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri.
+ * @return A {@link GeckoResult} that will complete when the mode has been set.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> setCookieBannerModeAndPersistInPrivateBrowsingForDomain(
+ final @NonNull String uri, final @ContentBlocking.CBCookieBannerMode int mode) {
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putInt("mode", mode);
+ data.putBoolean("allowPermanentPrivateBrowsing", true);
+ return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Removes a {@link ContentBlocking.CBCookieBannerMode} for the given uri and and browsing mode.
+ *
+ * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode}
+ * value.
+ * @param isPrivateBrowsing Indicates in which mode the given mode should be applied.
+ * @return A {@link GeckoResult} that will complete when the mode has been removed.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> removeCookieBannerModeForDomain(
+ final @NonNull String uri, final boolean isPrivateBrowsing) {
+
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance()
+ .queryVoid("GeckoView:RemoveCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Gets the actual {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode.
+ *
+ * @param uri An uri for which you want get the {@link ContentBlocking.CBCookieBannerMode}.
+ * @param isPrivateBrowsing Indicates in which browsing mode the given uri should be.
+ * @return A {@link GeckoResult} that resolves to a {@link ContentBlocking.CBCookieBannerMode} for
+ * the given uri and browsing mode.
+ */
+ @AnyThread
+ public @NonNull @ContentBlocking.CBCookieBannerMode GeckoResult<Integer>
+ getCookieBannerModeForDomain(final @NonNull String uri, final boolean isPrivateBrowsing) {
+
+ final GeckoBundle data = new GeckoBundle(2);
+ data.putString("uri", uri);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetCookieBannerModeForDomain", data)
+ .map(StorageController::cookieBannerModeFromBundle, StorageController::fromQueryException);
+ }
+
+ private static @ContentBlocking.CBCookieBannerMode int cookieBannerModeFromBundle(
+ final GeckoBundle bundle) throws Exception {
+ if (bundle == null) {
+ throw new Exception("Unable to parse cookie banner mode");
+ }
+ return bundle.getInt("mode");
+ }
+
+ private static Throwable fromQueryException(final Throwable exception) {
+ final EventDispatcher.QueryException queryException =
+ (EventDispatcher.QueryException) exception;
+ final Object response = queryException.data;
+ return new Exception(response.toString());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TranslationsController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TranslationsController.java
new file mode 100644
index 0000000000..37e5e7139a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TranslationsController.java
@@ -0,0 +1,1358 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * The translations controller coordinates the session and runtime messaging between GeckoView and
+ * the translations toolkit.
+ */
+public class TranslationsController {
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "TranslationsController";
+
+ /**
+ * Runtime translation coordinates runtime messaging between the translations toolkit actor and
+ * GeckoView.
+ *
+ * <p>Performs translations actions that are not dependent on the page. Typical usage is for
+ * setting preferences, managing downloads, and getting information on language models available.
+ */
+ public static class RuntimeTranslation {
+
+ // Events Dispatched to Toolkit Translations
+ private static final String ENGINE_SUPPORTED_EVENT =
+ "GeckoView:Translations:IsTranslationEngineSupported";
+
+ private static final String PREFERRED_LANGUAGES_EVENT =
+ "GeckoView:Translations:PreferredLanguages";
+
+ private static final String MANAGE_MODEL_EVENT = "GeckoView:Translations:ManageModel";
+
+ private static final String TRANSLATION_INFORMATION_EVENT =
+ "GeckoView:Translations:TranslationInformation";
+ private static final String MODEL_INFORMATION_EVENT = "GeckoView:Translations:ModelInformation";
+
+ private static final String GET_LANGUAGE_SETTING_EVENT =
+ "GeckoView:Translations:GetLanguageSetting";
+
+ private static final String GET_LANGUAGE_SETTINGS_EVENT =
+ "GeckoView:Translations:GetLanguageSettings";
+
+ private static final String SET_LANGUAGE_SETTINGS_EVENT =
+ "GeckoView:Translations:SetLanguageSettings";
+
+ private static final String GET_SPECIFIED_SITES_SETTINGS_EVENT =
+ "GeckoView:Translations:GetNeverTranslateSpecifiedSites";
+
+ private static final String SET_SPECIFIED_SITE_SETTINGS_EVENT =
+ "GeckoView:Translations:SetNeverTranslateSpecifiedSite";
+
+ private static final String GET_TRANSLATE_PAIR_DOWNLOAD_SIZE =
+ "GeckoView:Translations:GetTranslateDownloadSize";
+
+ /**
+ * Checks if the device can use the supplied model binary files for translations.
+ *
+ * <p>Use to check if translations are ever possible.
+ *
+ * @return true if translations are supported on the device, or false if not.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<Boolean> isTranslationsEngineSupported() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting if the translations engine supports the device.");
+ }
+ return EventDispatcher.getInstance()
+ .queryBoolean(ENGINE_SUPPORTED_EVENT)
+ .map(
+ result -> result,
+ exception ->
+ new TranslationsException(TranslationsException.ERROR_ENGINE_NOT_SUPPORTED));
+ }
+
+ /**
+ * Returns the preferred languages of the user in the following order: 1. App languages 2. Web
+ * requested languages 3. OS language
+ *
+ * @return a GeckoResult with a user's preferred language(s) or null or an exception
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<List<String>> preferredLanguages() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting the user's preferred languages.");
+ }
+ return EventDispatcher.getInstance()
+ .queryBundle(PREFERRED_LANGUAGES_EVENT)
+ .map(
+ bundle -> {
+ try {
+ final String[] languages = bundle.getStringArray("preferredLanguages");
+ if (languages != null) {
+ return Arrays.asList(languages);
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "Could not deserialize preferredLanguages: " + e);
+ return null;
+ }
+ return null;
+ },
+ exception ->
+ new TranslationsException(TranslationsException.ERROR_COULD_NOT_LOAD_LANGUAGES));
+ }
+
+ /**
+ * Manage the language model or models. Options are to download or delete a BCP 47 language or
+ * all or cache.
+ *
+ * <p>Bug 1869404 will add an option for deleting translations model "cache".
+ *
+ * @param options contain language, operation, and operation level to perform on the model
+ * @return the request proceeded as expected or an exception.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<Void> manageLanguageModel(
+ final @NonNull ModelManagementOptions options) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting management of the language model.");
+ }
+ return EventDispatcher.getInstance()
+ .queryVoid(MANAGE_MODEL_EVENT, options.toBundle())
+ .map(
+ result -> result,
+ exception -> {
+ final String exceptionData =
+ ((EventDispatcher.QueryException) exception).data.toString();
+ if (exceptionData.contains("COULD_NOT_DELETE")) {
+ return new TranslationsException(
+ TranslationsException.ERROR_MODEL_COULD_NOT_DELETE);
+ } else if (exceptionData.contains("LANGUAGE_REQUIRED")) {
+ return new TranslationsException(
+ TranslationsException.ERROR_MODEL_LANGUAGE_REQUIRED);
+ } else if (exceptionData.contains("COULD_NOT_DOWNLOAD")) {
+ return new TranslationsException(
+ TranslationsException.ERROR_MODEL_COULD_NOT_DOWNLOAD);
+ }
+ return new TranslationsException(TranslationsException.ERROR_UNKNOWN);
+ });
+ }
+
+ /**
+ * List languages that can be translated to and from. Use is populating language selection.
+ *
+ * @return a GeckoResult with a TranslationSupport object with "to" and "from" languages or an
+ * exception.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<TranslationSupport> listSupportedLanguages() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting information on the language options.");
+ }
+ return EventDispatcher.getInstance()
+ .queryBundle(TRANSLATION_INFORMATION_EVENT)
+ .map(
+ bundle -> TranslationSupport.fromBundle(bundle),
+ exception ->
+ new TranslationsException(TranslationsException.ERROR_COULD_NOT_LOAD_LANGUAGES));
+ }
+
+ /**
+ * When `translate()` is called on a given pair, then the system will downloaded the necessary
+ * models to complete the translation. This method is to check the exact size of those
+ * downloads. Typical case is informing the user of the download size for users in a low-data
+ * mode.
+ *
+ * <p>If no download is detected, it will return 0. Note, if the model is not present, this will
+ * also result in a value of 0 bytes.
+ *
+ * @param fromLanguage from BCP 47 code
+ * @param toLanguage from BCP 47 code
+ * @return The size of the file size in bytes. If no download is required, will return 0.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<Long> checkPairDownloadSize(
+ @NonNull final String fromLanguage, @NonNull final String toLanguage) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting information on the language pair download size.");
+ }
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("fromLanguage", fromLanguage);
+ bundle.putString("toLanguage", toLanguage);
+
+ return EventDispatcher.getInstance()
+ .queryBundle(GET_TRANSLATE_PAIR_DOWNLOAD_SIZE, bundle)
+ .map(
+ resultBundle -> {
+ return resultBundle.getLong("bytes", 0L);
+ });
+ }
+
+ /**
+ * Convenience method for {@link #checkPairDownloadSize(String, String)}.
+ *
+ * @param pair language pair that will be used by `translate()`
+ * @return The size of the necessary file size in bytes. If no download is required, will return
+ * 0.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<Long> checkPairDownloadSize(
+ @NonNull final SessionTranslation.TranslationPair pair) {
+ return checkPairDownloadSize(pair.fromLanguage, pair.toLanguage);
+ }
+
+ /**
+ * Creates a list of all of the available language models, their size for a full download, and
+ * download state. Expected use is for displaying model state for user management.
+ *
+ * @return A GeckoResult with a list of the available language model's and their states or an
+ * exception.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<List<LanguageModel>> listModelDownloadStates() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting information on the language model.");
+ }
+ return EventDispatcher.getInstance()
+ .queryBundle(MODEL_INFORMATION_EVENT)
+ .map(
+ bundle -> {
+ try {
+ final GeckoBundle[] models = bundle.getBundleArray("models");
+ if (models != null) {
+ final List<LanguageModel> list = new ArrayList<>();
+ for (final var item : models) {
+ list.add(LanguageModel.fromBundle(item));
+ }
+ return list;
+ }
+ } catch (final Exception e) {
+ Log.d(LOGTAG, "Could not deserialize the model states.");
+ return null;
+ }
+ return null;
+ },
+ exception ->
+ new TranslationsException(TranslationsException.ERROR_MODEL_COULD_NOT_RETRIEVE));
+ }
+
+ /**
+ * Returns the given language setting for the corresponding language.
+ *
+ * @param languageCode The BCP 47 language portion of the code to check the settings for. For
+ * example, es, en, de, etc.
+ * @return The {@link LanguageSetting} string for the language.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> getLanguageSetting(
+ @NonNull final String languageCode) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting language setting for " + languageCode + ".");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("language", languageCode);
+ return EventDispatcher.getInstance().queryString(GET_LANGUAGE_SETTING_EVENT, bundle);
+ }
+
+ /**
+ * Creates a map of known language codes with their corresponding language setting.
+ *
+ * @return A GeckoResult with a map of each BCP 47 language portion of the code (key) and its
+ * corresponding {@link LanguageSetting} string (value).
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<Map<String, String>> getLanguageSettings() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting language settings.");
+ }
+ return EventDispatcher.getInstance()
+ .queryBundle(GET_LANGUAGE_SETTINGS_EVENT)
+ .map(
+ bundle -> {
+ final Map<String, String> languageSettings = new HashMap<>();
+ try {
+ final GeckoBundle[] fromBundle = bundle.getBundleArray("settings");
+ for (final var item : fromBundle) {
+ final var languageCode = item.getString("langTag");
+ final @LanguageSetting String setting = item.getString("setting", "offer");
+ if (languageCode != null) {
+ languageSettings.put(languageCode, setting);
+ }
+ }
+ return languageSettings;
+
+ } catch (final Exception e) {
+ Log.w(
+ LOGTAG,
+ "An issue occurred while deserializing translation language settings: " + e);
+ }
+ return null;
+ });
+ }
+
+ /**
+ * Sets the language state for a given language.
+ *
+ * @param languageCode - The specified BCP 47 language portion of the code to update. For
+ * example, es, en, de, etc.
+ * @param languageSetting - The specified setting for a given language.
+ * @return A GeckoResult that will return void if successful or else will complete
+ * exceptionally.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<Void> setLanguageSettings(
+ final @NonNull String languageCode,
+ final @NonNull @LanguageSetting String languageSetting) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Requesting setting language setting.");
+ }
+
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("language", languageCode);
+ bundle.putString("languageSetting", String.valueOf(languageSetting));
+ return EventDispatcher.getInstance().queryVoid(SET_LANGUAGE_SETTINGS_EVENT, bundle);
+ }
+
+ /**
+ * Gets the list of sites that have a never translate site preference set. Should be used for
+ * retrieving a list for global preference setting outside of a specific site.
+ *
+ * <p>Recommend using: {@link SessionTranslation#getNeverTranslateSiteSetting()} to query the
+ * current session's site's never translate preferences.
+ *
+ * @return A list of display ready site URIs to set preferences for.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<List<String>> getNeverTranslateSiteList() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Retrieving specified never translate site settings");
+ }
+ return EventDispatcher.getInstance()
+ .queryBundle(GET_SPECIFIED_SITES_SETTINGS_EVENT)
+ .map(
+ bundle -> {
+ try {
+ final String[] neverTranslateSites = bundle.getStringArray("sites");
+ if (neverTranslateSites != null) {
+ return Arrays.asList(neverTranslateSites);
+ }
+ } catch (final Exception e) {
+ Log.d(LOGTAG, "Could not deserialize the sites.");
+ return null;
+ }
+ return null;
+ });
+ }
+
+ /**
+ * Sets whether the specified site should be translated or not. This function should be used for
+ * global updates to the never translate list.
+ *
+ * <p>Please use: {@link SessionTranslation#setNeverTranslateSiteSetting(Boolean)} when the
+ * session is currently on the site to adjust the permissions for.
+ *
+ * @param origin A site origin URI that will have the specified never translate permission set.
+ * Recommend using URI values returned from {@link #getNeverTranslateSiteList()} and using
+ * the session to set a given site to ensure proper scope when possible.
+ * @param neverTranslate Should be set to true if the site should never be translated or false
+ * if it should be translated.
+ * @return Void if the operation to set the value completed or exceptionally if an issue
+ * occurred.
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<Void> setNeverTranslateSpecifiedSite(
+ final @NonNull Boolean neverTranslate, final @NonNull String origin) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Setting never translate for specified site uri origin: " + origin);
+ }
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBoolean("neverTranslate", neverTranslate);
+ bundle.putString("origin", origin);
+ return EventDispatcher.getInstance().queryVoid(SET_SPECIFIED_SITE_SETTINGS_EVENT, bundle);
+ }
+
+ /** Options for managing the translation language models. */
+ @AnyThread
+ public static class ModelManagementOptions {
+ /** BCP 47 language or null for global operations. */
+ public final @Nullable String language;
+
+ /** Operation to perform on the language model. */
+ public final @NonNull @ModelOperation String operation;
+
+ /** Level of operation */
+ public final @NonNull @OperationLevel String operationLevel;
+
+ /**
+ * Options for managing the toolkit provided language model binaries.
+ *
+ * @param builder model management options builder
+ */
+ protected ModelManagementOptions(
+ final @NonNull RuntimeTranslation.ModelManagementOptions.Builder builder) {
+ this.language = builder.mLanguage;
+ this.operation = builder.mOperation;
+ this.operationLevel = builder.mOperationLevel;
+ }
+
+ /** Serializer for Model Management Options */
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ if (language != null) {
+ bundle.putString("language", language);
+ }
+ bundle.putString("operation", operation.toString());
+ bundle.putString("operationLevel", operationLevel.toString());
+
+ return bundle;
+ }
+
+ /** Builder for Model Management Options */
+ @AnyThread
+ public static class Builder {
+ /* package */ String mLanguage = null;
+ /* package */ @ModelOperation String mOperation;
+ /* package */ @OperationLevel String mOperationLevel = ALL;
+
+ /**
+ * Language builder setter.
+ *
+ * @param language that should be managed. No need to set in the case of a global operation
+ * level.
+ * @return the language parameter for the constructor
+ */
+ public @NonNull RuntimeTranslation.ModelManagementOptions.Builder languageToManage(
+ final @NonNull String language) {
+ mLanguage = language;
+ return this;
+ }
+
+ /**
+ * Operation builder setter.
+ *
+ * @param operation that should be performed
+ * @return the operation parameter for the constructor
+ */
+ public @NonNull RuntimeTranslation.ModelManagementOptions.Builder operation(
+ final @NonNull @ModelOperation String operation) {
+ mOperation = operation;
+ return this;
+ }
+
+ /**
+ * Operation level builder setter.
+ *
+ * @param operationLevel the level of the operation, e.g., language, all, or cache Default
+ * is to operate on all.
+ * @return the operation level parameter for the constructor
+ */
+ public @NonNull RuntimeTranslation.ModelManagementOptions.Builder operationLevel(
+ final @NonNull @OperationLevel String operationLevel) {
+ mOperationLevel = operationLevel;
+ return this;
+ }
+
+ /**
+ * Builder for Model Management Options.
+ *
+ * @return a constructed ModelManagementOptions populated from builder options
+ */
+ @AnyThread
+ public @NonNull ModelManagementOptions build() {
+ return new ModelManagementOptions(this);
+ }
+ }
+ }
+
+ /** Operations toolkit can perform on the language models. */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef(value = {DOWNLOAD, DELETE})
+ public @interface ModelOperation {}
+
+ /** The download operation is for downloading models. */
+ public static final String DOWNLOAD = "download";
+
+ /** The delete operation is for deleting models. */
+ public static final String DELETE = "delete";
+
+ /** Operation type for toolkit to operate on. */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef(value = {LANGUAGE, CACHE, ALL})
+ public @interface OperationLevel {}
+
+ /**
+ * The language type indicates the operation should be performed only on the specified language.
+ */
+ public static final String LANGUAGE = "language";
+
+ /**
+ * The cache type indicates that the operation should be performed on model files that do not
+ * make up a suit.
+ */
+ public static final String CACHE = "cache";
+
+ /** The all type indicates that the operation should be performed on all model files */
+ public static final String ALL = "all";
+
+ /** Language translation options. */
+ public static class TranslationSupport {
+ /** Languages we can translate from. */
+ public final @Nullable List<Language> fromLanguages;
+
+ /** Languages we can translate to. */
+ public final @Nullable List<Language> toLanguages;
+
+ /**
+ * Construction for translation support, will usually be constructed from deserialize toolkit
+ * information.
+ *
+ * @param fromLanguages list of from languages to list as translation options
+ * @param toLanguages list of to languages to list as translation options
+ */
+ public TranslationSupport(
+ @Nullable final List<Language> fromLanguages,
+ @Nullable final List<Language> toLanguages) {
+ this.fromLanguages = fromLanguages;
+ this.toLanguages = toLanguages;
+ }
+
+ @Override
+ public String toString() {
+ return "TranslationSupport {"
+ + "fromLanguages="
+ + fromLanguages
+ + ", toLanguages="
+ + toLanguages
+ + '}';
+ }
+
+ /**
+ * Convenience method for deserializing support information.
+ *
+ * @param bundle contains language support information
+ * @return support object
+ */
+ /* package */
+ static @Nullable TranslationSupport fromBundle(final GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ final List<Language> fromLanguages = new ArrayList<>();
+ final List<Language> toLanguages = new ArrayList<>();
+ try {
+ final GeckoBundle[] fromBundle = bundle.getBundleArray("fromLanguages");
+ for (final var item : fromBundle) {
+ final var result = Language.fromBundle(item);
+ if (result != null) {
+ fromLanguages.add(result);
+ }
+ }
+
+ final GeckoBundle[] toBundle = bundle.getBundleArray("toLanguages");
+ for (final var item : toBundle) {
+ final var result = Language.fromBundle(item);
+ if (result != null) {
+ toLanguages.add(result);
+ }
+ }
+ } catch (final Exception e) {
+ Log.w(
+ LOGTAG,
+ "An issue occurred while deserializing translation support information: " + e);
+ }
+
+ return new TranslationSupport(fromLanguages, toLanguages);
+ }
+ }
+
+ /** Information about a language model. */
+ public static class LanguageModel {
+ /** Display language. */
+ public final @Nullable Language language;
+
+ /** Model download state */
+ public final @NonNull Boolean isDownloaded;
+
+ /** Size in bytes for displaying download information. */
+ public final long size;
+
+ /**
+ * Constructor for the language model.
+ *
+ * @param language the language the model is for.
+ * @param isDownloaded if the model is currently downloaded or not.
+ * @param size the size in bytes of the model.
+ */
+ public LanguageModel(
+ @Nullable final Language language, final Boolean isDownloaded, final long size) {
+ this.language = language;
+ this.isDownloaded = isDownloaded;
+ this.size = size;
+ }
+
+ @Override
+ public String toString() {
+ return "LanguageModel {"
+ + "language="
+ + language
+ + ", isDownloaded="
+ + isDownloaded
+ + ", size="
+ + size
+ + '}';
+ }
+
+ /**
+ * Convenience method for deserializing language model information.
+ *
+ * @param bundle contains language model information
+ * @return language object
+ */
+ /* package */
+ static @Nullable LanguageModel fromBundle(final GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ try {
+ final var language = Language.fromBundle(bundle);
+ final var isDownloaded = bundle.getBoolean("isDownloaded");
+ final var size = bundle.getLong("size");
+ return new LanguageModel(language, isDownloaded, size);
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "Could not deserialize LanguageModel object: " + e);
+ return null;
+ }
+ }
+ }
+
+ /**
+ * The runtime language settings a given language may have that dictates the app's translation
+ * offering behavior.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef(value = {ALWAYS, OFFER, NEVER})
+ public @interface LanguageSetting {}
+
+ /**
+ * The translations engine should always expect this language to be translated and automatically
+ * translate on page load.
+ */
+ public static final String ALWAYS = "always";
+
+ /**
+ * The translations engine should offer this language to be translated. This is the default
+ * state, i.e., no user selection was made.
+ */
+ public static final String OFFER = "offer";
+
+ /** The translations engine should never offer to translate this language. */
+ public static final String NEVER = "never";
+ }
+
+ /**
+ * Session translation coordinates session messaging between the translations toolkit actor and
+ * GeckoView.
+ *
+ * <p>Performs translations actions that are dependent on the page.
+ */
+ public static class SessionTranslation {
+
+ // Events Dispatched to Toolkit Translations
+ private static final String TRANSLATE_EVENT = "GeckoView:Translations:Translate";
+ private static final String RESTORE_PAGE_EVENT = "GeckoView:Translations:RestorePage";
+
+ private static final String GET_NEVER_TRANSLATE_SITE =
+ "GeckoView:Translations:GetNeverTranslateSite";
+
+ private static final String SET_NEVER_TRANSLATE_SITE =
+ "GeckoView:Translations:SetNeverTranslateSite";
+
+ // Events Dispatched from Toolkit Translations
+ private static final String ON_OFFER_EVENT = "GeckoView:Translations:Offer";
+ private static final String ON_STATE_CHANGE_EVENT = "GeckoView:Translations:StateChange";
+
+ private final GeckoSession mSession;
+ private final SessionTranslation.Handler mHandler;
+
+ /**
+ * Construct a new translations session.
+ *
+ * @param session that will be dispatching and receiving events.
+ */
+ public SessionTranslation(final GeckoSession session) {
+ mSession = session;
+ mHandler = new SessionTranslation.Handler(mSession);
+ }
+
+ /**
+ * Handler for receiving messages about translations.
+ *
+ * @return associated session handler
+ */
+ @AnyThread
+ public @NonNull Handler getHandler() {
+ return mHandler;
+ }
+
+ /**
+ * Translates the session's current page based on given language and criteria specified in the
+ * options.
+ *
+ * @param fromLanguage BCP 47 language tag that the page should be translated from. Usually will
+ * be the suggested detected language or user specified.
+ * @param toLanguage BCP 47 language tag that the page should be translated to. Usually will be
+ * the suggested preference language or user specified.
+ * @param options If downloadModel is set to true, then any background downloads will occur
+ * automatically. If downloadModel is set to false, then if any background downloads are
+ * required, then the request will fail with an exception, but will continue if the model is
+ * already present.
+ * @return Void if the translate process begins or exceptionally if an issue occurs.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> translate(
+ @NonNull final String fromLanguage,
+ @NonNull final String toLanguage,
+ @Nullable final TranslationOptions options) {
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "Translate page requested - fromLanguage: "
+ + fromLanguage
+ + " toLanguage: "
+ + toLanguage
+ + " options: "
+ + options);
+ }
+
+ if (options != null && options.downloadModel == false) {
+ final var translateResult = new GeckoResult<Void>();
+ TranslationsController.RuntimeTranslation.checkPairDownloadSize(fromLanguage, toLanguage)
+ .then(
+ (GeckoResult.OnValueListener<Long, Void>)
+ downloadBytes -> {
+ if (downloadBytes > 0) {
+ translateResult.completeExceptionally(
+ new TranslationsException(
+ TranslationsException.ERROR_MODEL_DOWNLOAD_REQUIRED));
+ } else {
+ // No download required
+ translateResult.completeFrom(this.baseTranslate(fromLanguage, toLanguage));
+ }
+ return null;
+ });
+ return translateResult;
+ }
+
+ return this.baseTranslate(fromLanguage, toLanguage);
+ }
+
+ /**
+ * Convenience method for calling {@link #translate(String, String, TranslationOptions)} with a
+ * translation pair.
+ *
+ * @param translationPair the object with a from and to language
+ * @param options If downloadModel is set to true, then any background downloads will occur
+ * automatically. If downloadModel is set to false, then if any background downloads are
+ * required, then the request will fail, but will continue if the model is already present.
+ * @return Void if the translate process begins or exceptionally if an issue occurs.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> translate(
+ @NonNull final TranslationPair translationPair,
+ @Nullable final TranslationOptions options) {
+ return translate(translationPair.fromLanguage, translationPair.toLanguage, options);
+ }
+
+ /**
+ * This will complete a translation using defaults. Before translating, any required models will
+ * be downloaded by the toolkit engine.
+ *
+ * @param fromLanguage BCP 47 language tag that the page should be translated from. Usually will
+ * be the suggested detected language or user specified.
+ * @param toLanguage BCP 47 language tag that the page should be translated to. Usually will be
+ * the suggested preference language or user specified.
+ * @return Void if the translate process begins or exceptionally if an issue occurs.
+ */
+ @AnyThread
+ private @NonNull GeckoResult<Void> baseTranslate(
+ @NonNull final String fromLanguage, @NonNull final String toLanguage) {
+
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("fromLanguage", fromLanguage);
+ bundle.putString("toLanguage", toLanguage);
+ return mSession
+ .getEventDispatcher()
+ .queryVoid(TRANSLATE_EVENT, bundle)
+ .map(
+ result -> result,
+ exception ->
+ new TranslationsException(TranslationsException.ERROR_COULD_NOT_TRANSLATE));
+ }
+
+ /**
+ * Restores a page to the original or pre-translated state.
+ *
+ * @return if page restoration process begins or exceptionally if an issue occurs.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> restoreOriginalPage() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Restore translated page requested");
+ }
+ return mSession
+ .getEventDispatcher()
+ .queryVoid(RESTORE_PAGE_EVENT)
+ .map(
+ result -> result,
+ exception ->
+ new TranslationsException(TranslationsException.ERROR_COULD_NOT_RESTORE));
+ }
+
+ /**
+ * Gets the setting of the site for whether it should be translated or not.
+ *
+ * @return The site setting for the page or exceptionally if an issue occurs.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> getNeverTranslateSiteSetting() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Retrieving never translate site setting.");
+ }
+ return mSession.getEventDispatcher().queryBoolean(GET_NEVER_TRANSLATE_SITE);
+ }
+
+ /**
+ * Sets whether the site should be translated or not.
+ *
+ * @param neverTranslate Should be set to true if the site should never be translated or false
+ * if it should be translated.
+ * @return Void if the operation to set the value completed or exceptionally if an issue
+ * occurred.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> setNeverTranslateSiteSetting(
+ final @NonNull Boolean neverTranslate) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Setting never translate site.");
+ }
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBoolean("neverTranslate", neverTranslate);
+ return mSession.getEventDispatcher().queryVoid(SET_NEVER_TRANSLATE_SITE, bundle);
+ }
+
+ /**
+ * Options available for translating.
+ *
+ * <p>Options (default):
+ *
+ * <p>downloadModel (true) - Downloads any models automatically that are needed for translation.
+ */
+ @AnyThread
+ public static class TranslationOptions {
+ /** If the model should be automatically downloaded or stopped. */
+ public final @NonNull boolean downloadModel;
+
+ /**
+ * Options for translation.
+ *
+ * @param builder that populated the translation options
+ */
+ protected TranslationOptions(final @NonNull Builder builder) {
+ this.downloadModel = builder.mDownloadModel;
+ }
+
+ /** Builder for making translation options. */
+ @AnyThread
+ public static class Builder {
+ /* package */ boolean mDownloadModel = true;
+
+ /**
+ * Build setter for the option for downloading a model.
+ *
+ * @param downloadModel should the model be automatically download or not
+ * @return the model to download for the translation options
+ */
+ public @NonNull Builder downloadModel(final @NonNull boolean downloadModel) {
+ mDownloadModel = downloadModel;
+ return this;
+ }
+
+ /**
+ * Final call to build the specified options.
+ *
+ * @return a constructed translation options
+ */
+ @AnyThread
+ public @NonNull TranslationOptions build() {
+ return new TranslationOptions(this);
+ }
+ }
+ }
+
+ /**
+ * The translations session delegate is used for receiving translation events and information.
+ */
+ @AnyThread
+ public interface Delegate {
+ /**
+ * onOfferTranslate occurs when a page should be offered for translation.
+ *
+ * <p>An offer should occur when all conditions are met:
+ *
+ * <p>* The page is not in the user's preferred language
+ *
+ * <p>* The page language is eligible for translation
+ *
+ * <p>* The host hasn't been offered for translation in this session
+ *
+ * <p>* No user preferences indicate that translation shouldn't be offered
+ *
+ * <p>* It is possible to translate
+ *
+ * <p>Usual use-case is to show a pop-up recommending a translation.
+ *
+ * @param session The associated GeckoSession.
+ */
+ default void onOfferTranslate(@NonNull final GeckoSession session) {}
+
+ /**
+ * onExpectedTranslate occurs when it is likely the user will want to translate and it is
+ * feasible. For example, if the page is in a different language than the user preferred
+ * language or languages.
+ *
+ * <p>Usual use-case is to add a toolbar option for translate.
+ *
+ * @param session The associated GeckoSession.
+ */
+ default void onExpectedTranslate(@NonNull final GeckoSession session) {}
+
+ /**
+ * onTranslationStateChange occurs when new information about the translation state is
+ * available. This includes information when first visiting the page and after calls to
+ * translate.
+ *
+ * @param session The associated GeckoSession.
+ * @param translationState The state of the translation as reported by the translation engine.
+ */
+ default void onTranslationStateChange(
+ @NonNull final GeckoSession session, @Nullable TranslationState translationState) {}
+ }
+
+ /** Translation pair is the from language and to language set on the translation state. */
+ public static class TranslationPair {
+ /** Language the page is translated from originally. */
+ public final @Nullable String fromLanguage;
+
+ /** Language the page is translated to. */
+ public final @Nullable String toLanguage;
+
+ /**
+ * Requested translation pair constructor.
+ *
+ * @param fromLanguage original language of page (detected or specified)
+ * @param toLanguage translated to language of page (detected or specified)
+ */
+ public TranslationPair(
+ @Nullable final String fromLanguage, @Nullable final String toLanguage) {
+ this.fromLanguage = fromLanguage;
+ this.toLanguage = toLanguage;
+ }
+
+ @Override
+ public String toString() {
+ return "TranslationPair {"
+ + "fromLanguage='"
+ + fromLanguage
+ + '\''
+ + ", toLanguage='"
+ + toLanguage
+ + '\''
+ + '}';
+ }
+
+ /**
+ * Convenience method for deserializing translation state information.
+ *
+ * @param bundle contains translation pair information.
+ * @return translation pair
+ */
+ /* package */
+ static @Nullable TranslationPair fromBundle(final GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ return new TranslationPair(
+ bundle.getString("fromLanguage"), bundle.getString("toLanguage"));
+ }
+ }
+
+ /** DetectedLanguages is information that was detected about the page or user preferences. */
+ public static class DetectedLanguages {
+
+ /** The user's preferred language tag */
+ public final @Nullable String userLangTag;
+
+ /** If the engine supports the document language. */
+ public final @NonNull Boolean isDocLangTagSupported;
+
+ /** Detected language tag of page. */
+ public final @Nullable String docLangTag;
+
+ /**
+ * DetectedLanguages constructor.
+ *
+ * @param userLangTag - the user's preferred language tag
+ * @param isDocLangTagSupported - if the engine supports the document language for translation
+ * @param docLangTag - the document's detected language tag
+ */
+ public DetectedLanguages(
+ @Nullable final String userLangTag,
+ @NonNull final Boolean isDocLangTagSupported,
+ @Nullable final String docLangTag) {
+ this.userLangTag = userLangTag;
+ this.isDocLangTagSupported = isDocLangTagSupported;
+ this.docLangTag = docLangTag;
+ }
+
+ @Override
+ public String toString() {
+ return "DetectedLanguages {"
+ + "userLangTag='"
+ + userLangTag
+ + '\''
+ + ", isDocLangTagSupported="
+ + isDocLangTagSupported
+ + ", docLangTag='"
+ + docLangTag
+ + '\''
+ + '}';
+ }
+
+ /**
+ * Convenience method for deserializing detected language state information.
+ *
+ * @param bundle contains detected language information.
+ * @return detected language information
+ */
+ /* package */
+ static @Nullable DetectedLanguages fromBundle(final GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ return new DetectedLanguages(
+ bundle.getString("userLangTag"),
+ bundle.getBoolean("isDocLangTagSupported", false),
+ bundle.getString("docLangTag"));
+ }
+ }
+
+ /** The representation of the translation state. */
+ public static class TranslationState {
+ /** The language pair to translate. */
+ public final @Nullable TranslationPair requestedTranslationPair;
+
+ /** If an error state occurred. */
+ public final @Nullable String error;
+
+ /** Detected information about preferences and page information. */
+ public final @Nullable DetectedLanguages detectedLanguages;
+
+ /** If the translation engine is ready for use or will need to be loaded. */
+ public final @NonNull Boolean isEngineReady;
+
+ /**
+ * Translation State constructor.
+ *
+ * @param requestedTranslationPair the language pair to translate
+ * @param error if an error occurred
+ * @param detectedLanguages detected language
+ * @param isEngineReady if the engine is ready for translations
+ */
+ public TranslationState(
+ final @Nullable TranslationPair requestedTranslationPair,
+ final @Nullable String error,
+ final @Nullable DetectedLanguages detectedLanguages,
+ final @NonNull Boolean isEngineReady) {
+ this.requestedTranslationPair = requestedTranslationPair;
+ this.error = error;
+ this.detectedLanguages = detectedLanguages;
+ this.isEngineReady = isEngineReady;
+ }
+
+ @Override
+ public String toString() {
+ return "TranslationState {"
+ + "requestedTranslationPair="
+ + requestedTranslationPair
+ + ", error='"
+ + error
+ + '\''
+ + ", detectedLanguages="
+ + detectedLanguages
+ + ", isEngineReady="
+ + isEngineReady
+ + '}';
+ }
+
+ /**
+ * Convenience method for deserializing translation state information.
+ *
+ * @param bundle contains information about translation state.
+ * @return translation state
+ */
+ /* package */
+ static @Nullable TranslationState fromBundle(final GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ return new TranslationState(
+ TranslationPair.fromBundle(bundle.getBundle("requestedTranslationPair")),
+ bundle.getString("error"),
+ DetectedLanguages.fromBundle(bundle.getBundle("detectedLanguages")),
+ bundle.getBoolean("isEngineReady", false));
+ }
+ }
+
+ /* package */ static class Handler extends GeckoSessionHandler<SessionTranslation.Delegate> {
+
+ private final GeckoSession mSession;
+
+ private Handler(final GeckoSession session) {
+ super(
+ "GeckoViewTranslations",
+ session,
+ new String[] {
+ ON_OFFER_EVENT, ON_STATE_CHANGE_EVENT,
+ });
+ mSession = session;
+ }
+
+ @Override
+ public void handleMessage(
+ final Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ }
+ if (delegate == null) {
+ Log.w(LOGTAG, "The translations session delegate is not set.");
+ return;
+ }
+ if (ON_OFFER_EVENT.equals(event)) {
+ delegate.onOfferTranslate(mSession);
+ return;
+ } else if (ON_STATE_CHANGE_EVENT.equals(event)) {
+ final GeckoBundle data = message.getBundle("data");
+ final TranslationState translationState = TranslationState.fromBundle(data);
+ if (DEBUG) {
+ Log.d(LOGTAG, "received translation state: " + translationState);
+ }
+ delegate.onTranslationStateChange(mSession, translationState);
+ if (translationState != null
+ && translationState.detectedLanguages != null
+ && translationState.detectedLanguages.docLangTag != null
+ && translationState.detectedLanguages.userLangTag != null
+ && translationState.detectedLanguages.isDocLangTagSupported) {
+ TranslationsController.RuntimeTranslation.isTranslationsEngineSupported()
+ .then(
+ (GeckoResult.OnValueListener<Boolean, Void>)
+ value -> {
+ if (value) {
+ delegate.onExpectedTranslate(mSession);
+ }
+ return null;
+ });
+ return;
+ }
+ }
+ }
+ }
+ }
+
+ /** Language display information. */
+ public static class Language implements Comparable<Language> {
+ /** Language BCP 47 code. */
+ public final @NonNull String code;
+
+ /** Language localized display name. */
+ public final @Nullable String localizedDisplayName;
+
+ /**
+ * Language constructor.
+ *
+ * @param code BCP 47 language code
+ * @param localizedDisplayName how the language should be referred to in the UI.
+ */
+ public Language(@NonNull final String code, @Nullable final String localizedDisplayName) {
+ this.code = code;
+ this.localizedDisplayName = localizedDisplayName;
+ }
+
+ @Override
+ public String toString() {
+ if (localizedDisplayName != null) {
+ return localizedDisplayName;
+ }
+ return code;
+ }
+
+ /**
+ * Comparator for sorting language objects is based on alphabetizing display language {@link
+ * #localizedDisplayName}.
+ *
+ * @param otherLanguage other language being compared
+ * @return 1 if this object is earlier, 0 if equal, -1 if this object should be later for
+ * sorting
+ */
+ @Override
+ @AnyThread
+ public int compareTo(@Nullable final Language otherLanguage) {
+ return this.localizedDisplayName.compareTo(otherLanguage.localizedDisplayName);
+ }
+
+ /**
+ * Equality checker for language objects is based on BCP 47 code equality {@link #code}.
+ *
+ * @param otherLanguage other language being compared
+ * @return true if the BCP 47 codes match, false if they do not
+ */
+ @Override
+ public boolean equals(@Nullable final Object otherLanguage) {
+ if (otherLanguage instanceof Language) {
+ return this.code.equals(((Language) otherLanguage).code);
+ }
+ return false;
+ }
+
+ /**
+ * Required for overriding equals.
+ *
+ * @return object hash.
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hash(code);
+ }
+
+ /**
+ * Convenience method for deserializing language information.
+ *
+ * @param bundle contains language information
+ * @return language for display
+ */
+ /* package */
+ static @Nullable Language fromBundle(final GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ try {
+ final String code = bundle.getString("langTag", "");
+ if (code.equals("")) {
+ Log.w(LOGTAG, "Deserialized an empty language code.");
+ }
+ return new Language(code, bundle.getString("displayName"));
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "Could not deserialize language object: " + e);
+ return null;
+ }
+ }
+ }
+
+ /**
+ * An exception to be used when there is an issue retrieving or sending information to the
+ * translations toolkit engine.
+ */
+ public static class TranslationsException extends Exception {
+
+ /**
+ * Construct a [TranslationsException]
+ *
+ * @param code Error code the given exception corresponds to.
+ */
+ public TranslationsException(final @Code int code) {
+ this.code = code;
+ }
+
+ /** Default error for unexpected issues. */
+ public static final int ERROR_UNKNOWN = -1;
+
+ /** Translations engine does not work on the device architecture. */
+ public static final int ERROR_ENGINE_NOT_SUPPORTED = -2;
+
+ /** Generic could not compete a translation error. */
+ public static final int ERROR_COULD_NOT_TRANSLATE = -3;
+
+ /** Generic could not restore the page after a translation error. */
+ public static final int ERROR_COULD_NOT_RESTORE = -4;
+
+ /** Could not load language options error. */
+ public static final int ERROR_COULD_NOT_LOAD_LANGUAGES = -5;
+
+ /** The language is not supported for translation. */
+ public static final int ERROR_LANGUAGE_NOT_SUPPORTED = -6;
+
+ /** Could not retrieve information on the language model. */
+ public static final int ERROR_MODEL_COULD_NOT_RETRIEVE = -7;
+
+ /** Could not delete the language model. */
+ public static final int ERROR_MODEL_COULD_NOT_DELETE = -8;
+
+ /** Could not download the language model. */
+ public static final int ERROR_MODEL_COULD_NOT_DOWNLOAD = -9;
+
+ /** A language is required for language scoped requests. */
+ public static final int ERROR_MODEL_LANGUAGE_REQUIRED = -10;
+
+ /** A download is required and the translate request specified do not download. */
+ public static final int ERROR_MODEL_DOWNLOAD_REQUIRED = -11;
+
+ /** Translation exception error codes. */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ ERROR_UNKNOWN,
+ ERROR_ENGINE_NOT_SUPPORTED,
+ ERROR_COULD_NOT_TRANSLATE,
+ ERROR_COULD_NOT_RESTORE,
+ ERROR_COULD_NOT_LOAD_LANGUAGES,
+ ERROR_LANGUAGE_NOT_SUPPORTED,
+ ERROR_MODEL_COULD_NOT_RETRIEVE,
+ ERROR_MODEL_COULD_NOT_DELETE,
+ ERROR_MODEL_COULD_NOT_DOWNLOAD,
+ ERROR_MODEL_LANGUAGE_REQUIRED,
+ ERROR_MODEL_DOWNLOAD_REQUIRED
+ })
+ public @interface Code {}
+
+ /** {@link Code} that provides more information about this exception. */
+ public final @Code int code;
+
+ @Override
+ public String toString() {
+ return "TranslationsException: " + code;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java
new file mode 100644
index 0000000000..6fae35f320
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java
@@ -0,0 +1,598 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.net.Uri;
+import android.util.Base64;
+import android.util.Log;
+import com.google.android.gms.fido.Fido;
+import com.google.android.gms.fido.common.Transport;
+import com.google.android.gms.fido.fido2.Fido2ApiClient;
+import com.google.android.gms.fido.fido2.Fido2PrivilegedApiClient;
+import com.google.android.gms.fido.fido2.api.common.Algorithm;
+import com.google.android.gms.fido.fido2.api.common.Attachment;
+import com.google.android.gms.fido.fido2.api.common.AttestationConveyancePreference;
+import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensions;
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse;
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse;
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse;
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorSelectionCriteria;
+import com.google.android.gms.fido.fido2.api.common.BrowserPublicKeyCredentialCreationOptions;
+import com.google.android.gms.fido.fido2.api.common.BrowserPublicKeyCredentialRequestOptions;
+import com.google.android.gms.fido.fido2.api.common.EC2Algorithm;
+import com.google.android.gms.fido.fido2.api.common.FidoAppIdExtension;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialCreationOptions;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialDescriptor;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialParameters;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRpEntity;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity;
+import com.google.android.gms.fido.fido2.api.common.RSAAlgorithm;
+import com.google.android.gms.fido.fido2.api.common.ResidentKeyRequirement;
+import com.google.android.gms.tasks.Task;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/* package */ class WebAuthnTokenManager {
+ private static final String LOGTAG = "WebAuthnTokenManager";
+
+ // from dom/webauthn/WebAuthnTransportIdentifiers.h
+ private static final byte AUTHENTICATOR_TRANSPORT_USB = 1;
+ private static final byte AUTHENTICATOR_TRANSPORT_NFC = 2;
+ private static final byte AUTHENTICATOR_TRANSPORT_BLE = 4;
+ private static final byte AUTHENTICATOR_TRANSPORT_INTERNAL = 8;
+
+ private static final Algorithm[] SUPPORTED_ALGORITHMS = {
+ EC2Algorithm.ES256,
+ EC2Algorithm.ES384,
+ EC2Algorithm.ES512,
+ EC2Algorithm.ED256, /* no ED384 */
+ EC2Algorithm.ED512,
+ RSAAlgorithm.PS256,
+ RSAAlgorithm.PS384,
+ RSAAlgorithm.PS512,
+ RSAAlgorithm.RS256,
+ RSAAlgorithm.RS384,
+ RSAAlgorithm.RS512
+ };
+
+ private static List<Transport> getTransportsForByte(final byte transports) {
+ final ArrayList<Transport> result = new ArrayList<Transport>();
+ if ((transports & AUTHENTICATOR_TRANSPORT_USB) == AUTHENTICATOR_TRANSPORT_USB) {
+ result.add(Transport.USB);
+ }
+ if ((transports & AUTHENTICATOR_TRANSPORT_NFC) == AUTHENTICATOR_TRANSPORT_NFC) {
+ result.add(Transport.NFC);
+ }
+ if ((transports & AUTHENTICATOR_TRANSPORT_BLE) == AUTHENTICATOR_TRANSPORT_BLE) {
+ result.add(Transport.BLUETOOTH_LOW_ENERGY);
+ }
+ if ((transports & AUTHENTICATOR_TRANSPORT_INTERNAL) == AUTHENTICATOR_TRANSPORT_INTERNAL) {
+ result.add(Transport.INTERNAL);
+ }
+ return result;
+ }
+
+ public static class WebAuthnPublicCredential {
+ public final byte[] id;
+ public final byte transports;
+
+ public WebAuthnPublicCredential(final byte[] aId, final byte aTransports) {
+ this.id = aId;
+ this.transports = aTransports;
+ }
+
+ static ArrayList<WebAuthnPublicCredential> CombineBuffers(
+ final Object[] idObjectList, final ByteBuffer transportList) {
+ if (idObjectList.length != transportList.remaining()) {
+ throw new RuntimeException("Couldn't extract allowed list!");
+ }
+
+ final ArrayList<WebAuthnPublicCredential> credList =
+ new ArrayList<WebAuthnPublicCredential>();
+
+ final byte[] transportBytes = new byte[transportList.remaining()];
+ transportList.get(transportBytes);
+
+ for (int i = 0; i < idObjectList.length; i++) {
+ final ByteBuffer id = (ByteBuffer) idObjectList[i];
+ final byte[] idBytes = new byte[id.remaining()];
+ id.get(idBytes);
+
+ credList.add(new WebAuthnPublicCredential(idBytes, transportBytes[i]));
+ }
+ return credList;
+ }
+ }
+
+ // From WebAuthentication.webidl
+ public enum AttestationPreference {
+ NONE,
+ INDIRECT,
+ DIRECT,
+ }
+
+ @WrapForJNI
+ public static class MakeCredentialResponse {
+ public final byte[] clientDataJson;
+ public final byte[] keyHandle;
+ public final byte[] attestationObject;
+ public final String[] transports;
+
+ public MakeCredentialResponse(
+ final byte[] clientDataJson,
+ final byte[] keyHandle,
+ final byte[] attestationObject,
+ final String[] transports) {
+ this.clientDataJson = clientDataJson;
+ this.keyHandle = keyHandle;
+ this.attestationObject = attestationObject;
+ this.transports = transports;
+ }
+ }
+
+ public static class Exception extends RuntimeException {
+ public Exception(final String error) {
+ super(error);
+ }
+ }
+
+ public static GeckoResult<MakeCredentialResponse> makeCredential(
+ final GeckoBundle credentialBundle,
+ final byte[] userId,
+ final byte[] challenge,
+ final WebAuthnTokenManager.WebAuthnPublicCredential[] excludeList,
+ final GeckoBundle authenticatorSelection,
+ final GeckoBundle extensions) {
+ if (!credentialBundle.containsKey("isWebAuthn")) {
+ // FIDO U2F not supported by Android (for us anyway) at this time
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR"));
+ }
+
+ final PublicKeyCredentialCreationOptions.Builder requestBuilder =
+ new PublicKeyCredentialCreationOptions.Builder();
+
+ final List<PublicKeyCredentialParameters> params =
+ new ArrayList<PublicKeyCredentialParameters>();
+
+ // WebAuthn supports more algorithms
+ for (final Algorithm algo : SUPPORTED_ALGORITHMS) {
+ params.add(
+ new PublicKeyCredentialParameters(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(), algo.getAlgoValue()));
+ }
+
+ final PublicKeyCredentialUserEntity user =
+ new PublicKeyCredentialUserEntity(
+ userId,
+ credentialBundle.getString("userName", ""),
+ /* deprecated userIcon field */ "",
+ credentialBundle.getString("userDisplayName", ""));
+
+ AttestationConveyancePreference pref = AttestationConveyancePreference.NONE;
+ final String attestationPreference =
+ authenticatorSelection.getString("attestationPreference", "NONE");
+ if (attestationPreference.equalsIgnoreCase(AttestationConveyancePreference.DIRECT.name())) {
+ pref = AttestationConveyancePreference.DIRECT;
+ } else if (attestationPreference.equalsIgnoreCase(
+ AttestationConveyancePreference.INDIRECT.name())) {
+ pref = AttestationConveyancePreference.INDIRECT;
+ }
+
+ final AuthenticatorSelectionCriteria.Builder selBuild =
+ new AuthenticatorSelectionCriteria.Builder();
+ if (authenticatorSelection.getInt("requirePlatformAttachment", 0) == 1) {
+ selBuild.setAttachment(Attachment.PLATFORM);
+ }
+ if (authenticatorSelection.getInt("requireCrossPlatformAttachment", 0) == 1) {
+ selBuild.setAttachment(Attachment.CROSS_PLATFORM);
+ }
+ final String residentKey = authenticatorSelection.getString("residentKey", "");
+ if (residentKey.equals("required")) {
+ selBuild
+ .setRequireResidentKey(true)
+ .setResidentKeyRequirement(ResidentKeyRequirement.RESIDENT_KEY_REQUIRED);
+ } else if (residentKey.equals("preferred")) {
+ selBuild
+ .setRequireResidentKey(false)
+ .setResidentKeyRequirement(ResidentKeyRequirement.RESIDENT_KEY_PREFERRED);
+ } else if (residentKey.equals("discouraged")) {
+ selBuild
+ .setRequireResidentKey(false)
+ .setResidentKeyRequirement(ResidentKeyRequirement.RESIDENT_KEY_DISCOURAGED);
+ }
+ final AuthenticatorSelectionCriteria sel = selBuild.build();
+
+ final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder();
+ if (extensions.containsKey("fidoAppId")) {
+ extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId")));
+ }
+ final AuthenticationExtensions ext = extBuilder.build();
+
+ // requireUserVerification are not yet consumed by Android's API
+
+ final List<PublicKeyCredentialDescriptor> excludedList =
+ new ArrayList<PublicKeyCredentialDescriptor>();
+ for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : excludeList) {
+ excludedList.add(
+ new PublicKeyCredentialDescriptor(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ cred.id,
+ getTransportsForByte(cred.transports)));
+ }
+
+ final PublicKeyCredentialRpEntity rp =
+ new PublicKeyCredentialRpEntity(
+ credentialBundle.getString("rpId"),
+ credentialBundle.getString("rpName", ""),
+ /* deprecated rpIcon field */ "");
+
+ final PublicKeyCredentialCreationOptions requestOptions =
+ requestBuilder
+ .setUser(user)
+ .setAttestationConveyancePreference(pref)
+ .setAuthenticatorSelection(sel)
+ .setAuthenticationExtensions(ext)
+ .setChallenge(challenge)
+ .setRp(rp)
+ .setParameters(params)
+ .setTimeoutSeconds(credentialBundle.getLong("timeoutMS") / 1000.0)
+ .setExcludeList(excludedList)
+ .build();
+
+ final Uri origin = Uri.parse(credentialBundle.getString("origin"));
+
+ final BrowserPublicKeyCredentialCreationOptions browserOptions =
+ new BrowserPublicKeyCredentialCreationOptions.Builder()
+ .setPublicKeyCredentialCreationOptions(requestOptions)
+ .setOrigin(origin)
+ .build();
+
+ final Task<PendingIntent> intentTask;
+
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ // Certain Fenix builds and signing keys are whitelisted for Web Authentication.
+ // See https://wiki.mozilla.org/Security/Web_Authentication
+ //
+ // Third party apps will need to get whitelisted themselves.
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getRegisterPendingIntent(browserOptions);
+ } else {
+ // For non-official builds, websites have to opt-in to permit the
+ // particular version of Gecko to perform WebAuthn operations on
+ // them. See https://developers.google.com/digital-asset-links
+ // for the general form, and Step 1 of
+ // https://developers.google.com/identity/fido/android/native-apps
+ // for details about doing this correctly for the FIDO2 API.
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getRegisterPendingIntent(requestOptions);
+ }
+
+ final GeckoResult<MakeCredentialResponse> result = new GeckoResult<>();
+
+ intentTask.addOnSuccessListener(
+ pendingIntent -> {
+ GeckoRuntime.getInstance()
+ .startActivityForResult(pendingIntent)
+ .accept(
+ intent -> {
+ final WebAuthnTokenManager.Exception error = parseErrorIntent(intent);
+ if (error != null) {
+ result.completeExceptionally(error);
+ return;
+ }
+
+ final byte[] rspData = intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA);
+ if (rspData != null) {
+ final AuthenticatorAttestationResponse responseData =
+ AuthenticatorAttestationResponse.deserializeFromBytes(rspData);
+
+ Log.d(
+ LOGTAG,
+ "key handle: "
+ + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "clientDataJSON: "
+ + Base64.encodeToString(
+ responseData.getClientDataJSON(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "attestation Object: "
+ + Base64.encodeToString(
+ responseData.getAttestationObject(), Base64.DEFAULT));
+
+ Log.d(
+ LOGTAG, "transports: " + String.join(", ", responseData.getTransports()));
+
+ result.complete(
+ new WebAuthnTokenManager.MakeCredentialResponse(
+ responseData.getClientDataJSON(),
+ responseData.getKeyHandle(),
+ responseData.getAttestationObject(),
+ responseData.getTransports()));
+ }
+ },
+ e -> {
+ Log.w(LOGTAG, "Failed to launch activity: ", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR"));
+ });
+ });
+
+ intentTask.addOnFailureListener(
+ e -> {
+ Log.w(LOGTAG, "Failed to get FIDO intent", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR"));
+ });
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<MakeCredentialResponse> webAuthnMakeCredential(
+ final GeckoBundle credentialBundle,
+ final ByteBuffer userId,
+ final ByteBuffer challenge,
+ final Object[] idList,
+ final ByteBuffer transportList,
+ final GeckoBundle authenticatorSelection,
+ final GeckoBundle extensions) {
+ final ArrayList<WebAuthnPublicCredential> excludeList;
+
+ final byte[] challBytes = new byte[challenge.remaining()];
+ final byte[] userBytes = new byte[userId.remaining()];
+ try {
+ challenge.get(challBytes);
+ userId.get(userBytes);
+
+ excludeList = WebAuthnPublicCredential.CombineBuffers(idList, transportList);
+ } catch (final RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+
+ try {
+ return makeCredential(
+ credentialBundle,
+ userBytes,
+ challBytes,
+ excludeList.toArray(new WebAuthnPublicCredential[0]),
+ authenticatorSelection,
+ extensions);
+ } catch (final Exception e) {
+ // We need to ensure we catch any possible exception here in order to ensure
+ // that the Promise on the content side is appropriately rejected. In particular,
+ // we will get `NoClassDefFoundError` if we're running on a device that does not
+ // have Google Play Services.
+ Log.w(LOGTAG, "Couldn't make credential", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+ }
+
+ @WrapForJNI
+ public static class GetAssertionResponse {
+ public final byte[] clientDataJson;
+ public final byte[] keyHandle;
+ public final byte[] authData;
+ public final byte[] signature;
+ public final byte[] userHandle;
+
+ public GetAssertionResponse(
+ final byte[] clientDataJson,
+ final byte[] keyHandle,
+ final byte[] authData,
+ final byte[] signature,
+ final byte[] userHandle) {
+ this.clientDataJson = clientDataJson;
+ this.keyHandle = keyHandle;
+ this.authData = authData;
+ this.signature = signature;
+ this.userHandle = userHandle;
+ }
+ }
+
+ private static WebAuthnTokenManager.Exception parseErrorIntent(final Intent intent) {
+ if (!intent.hasExtra(Fido.FIDO2_KEY_ERROR_EXTRA)) {
+ return null;
+ }
+
+ final byte[] errData = intent.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA);
+ final AuthenticatorErrorResponse responseData =
+ AuthenticatorErrorResponse.deserializeFromBytes(errData);
+
+ Log.e(LOGTAG, "errorCode.name: " + responseData.getErrorCode());
+ Log.e(LOGTAG, "errorMessage: " + responseData.getErrorMessage());
+
+ return new WebAuthnTokenManager.Exception(responseData.getErrorCode().name());
+ }
+
+ private static GeckoResult<GetAssertionResponse> getAssertion(
+ final byte[] challenge,
+ final WebAuthnTokenManager.WebAuthnPublicCredential[] allowList,
+ final GeckoBundle assertionBundle,
+ final GeckoBundle extensions) {
+
+ if (!assertionBundle.containsKey("isWebAuthn")) {
+ // FIDO U2F not supported by Android (for us anyway) at this time
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR"));
+ }
+
+ final List<PublicKeyCredentialDescriptor> allowedList =
+ new ArrayList<PublicKeyCredentialDescriptor>();
+ for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : allowList) {
+ allowedList.add(
+ new PublicKeyCredentialDescriptor(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ cred.id,
+ getTransportsForByte(cred.transports)));
+ }
+
+ final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder();
+ if (extensions.containsKey("fidoAppId")) {
+ extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId")));
+ }
+ final AuthenticationExtensions ext = extBuilder.build();
+
+ final PublicKeyCredentialRequestOptions requestOptions =
+ new PublicKeyCredentialRequestOptions.Builder()
+ .setChallenge(challenge)
+ .setAllowList(allowedList)
+ .setTimeoutSeconds(assertionBundle.getLong("timeoutMS") / 1000.0)
+ .setRpId(assertionBundle.getString("rpId"))
+ .setAuthenticationExtensions(ext)
+ .build();
+
+ final Uri origin = Uri.parse(assertionBundle.getString("origin"));
+ final BrowserPublicKeyCredentialRequestOptions browserOptions =
+ new BrowserPublicKeyCredentialRequestOptions.Builder()
+ .setPublicKeyCredentialRequestOptions(requestOptions)
+ .setOrigin(origin)
+ .build();
+
+ final Task<PendingIntent> intentTask;
+ // See the makeCredential method for documentation about this
+ // conditional.
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getSignPendingIntent(browserOptions);
+ } else {
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getSignPendingIntent(requestOptions);
+ }
+
+ final GeckoResult<GetAssertionResponse> result = new GeckoResult<>();
+ intentTask.addOnSuccessListener(
+ pendingIntent -> {
+ GeckoRuntime.getInstance()
+ .startActivityForResult(pendingIntent)
+ .accept(
+ intent -> {
+ final WebAuthnTokenManager.Exception error = parseErrorIntent(intent);
+ if (error != null) {
+ result.completeExceptionally(error);
+ return;
+ }
+
+ if (intent.hasExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA)) {
+ final byte[] rspData =
+ intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA);
+ final AuthenticatorAssertionResponse responseData =
+ AuthenticatorAssertionResponse.deserializeFromBytes(rspData);
+
+ Log.d(
+ LOGTAG,
+ "key handle: "
+ + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "clientDataJSON: "
+ + Base64.encodeToString(
+ responseData.getClientDataJSON(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "auth data: "
+ + Base64.encodeToString(
+ responseData.getAuthenticatorData(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "signature: "
+ + Base64.encodeToString(responseData.getSignature(), Base64.DEFAULT));
+
+ // Nullable field
+ byte[] userHandle = responseData.getUserHandle();
+ if (userHandle == null) {
+ userHandle = new byte[0];
+ }
+
+ result.complete(
+ new WebAuthnTokenManager.GetAssertionResponse(
+ responseData.getClientDataJSON(),
+ responseData.getKeyHandle(),
+ responseData.getAuthenticatorData(),
+ responseData.getSignature(),
+ userHandle));
+ }
+ },
+ e -> {
+ Log.w(LOGTAG, "Failed to get FIDO intent", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ });
+ });
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<GetAssertionResponse> webAuthnGetAssertion(
+ final ByteBuffer challenge,
+ final Object[] idList,
+ final ByteBuffer transportList,
+ final GeckoBundle assertionBundle,
+ final GeckoBundle extensions) {
+ final ArrayList<WebAuthnPublicCredential> allowList;
+
+ final byte[] challBytes = new byte[challenge.remaining()];
+ try {
+ challenge.get(challBytes);
+ allowList = WebAuthnPublicCredential.CombineBuffers(idList, transportList);
+ } catch (final RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+
+ try {
+ return getAssertion(
+ challBytes,
+ allowList.toArray(new WebAuthnPublicCredential[0]),
+ assertionBundle,
+ extensions);
+ } catch (final java.lang.Exception e) {
+ Log.w(LOGTAG, "Couldn't get assertion", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<Boolean> webAuthnIsUserVerifyingPlatformAuthenticatorAvailable() {
+ final Task<Boolean> task;
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+ task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable();
+ } else {
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+ task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable();
+ }
+
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+ task.addOnSuccessListener(
+ isUVPAA -> {
+ res.complete(isUVPAA);
+ });
+ task.addOnFailureListener(
+ e -> {
+ Log.w(LOGTAG, "isUserVerifyingPlatformAuthenticatorAvailable is failed", e);
+ res.complete(false);
+ });
+ return res;
+ }
+}
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..d553a1aa3f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
@@ -0,0 +1,2894 @@
+/* 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 =&gt; {
+ * 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 is blocklisted. */
+ public static final int ERROR_BLOCKLISTED = -10;
+
+ /** The extension is incompatible. */
+ public static final int ERROR_INCOMPATIBLE = -11;
+
+ /** The extension type is not supported by the platform. */
+ public static final int ERROR_UNSUPPORTED_ADDON_TYPE = -12;
+
+ /** The extension install was canceled. */
+ public static final int ERROR_USER_CANCELED = -100;
+
+ /** The extension install was postponed until restart. */
+ public static final int ERROR_POSTPONED = -101;
+
+ /** For testing. */
+ protected ErrorCodes() {}
+ }
+
+ /** These states should match gecko's AddonManager.STATE_* constants. */
+ private static class StateCodes {
+ public static final int STATE_POSTPONED = 7;
+ public static final int STATE_CANCELED = 12;
+ }
+
+ /* package */ static Throwable fromQueryException(final Throwable exception) {
+ final EventDispatcher.QueryException queryException =
+ (EventDispatcher.QueryException) exception;
+ final Object response = queryException.data;
+ if (response instanceof GeckoBundle && ((GeckoBundle) response).containsKey("installError")) {
+ final GeckoBundle bundle = (GeckoBundle) response;
+ int errorCode = bundle.getInt("installError");
+ final int installState = bundle.getInt("state");
+ if (errorCode == 0
+ && installState == StateCodes.STATE_CANCELED
+ && bundle.getBoolean("cancelledByUser")) {
+ errorCode = ErrorCodes.ERROR_USER_CANCELED;
+ } else if (errorCode == 0 && installState == StateCodes.STATE_POSTPONED) {
+ errorCode = ErrorCodes.ERROR_POSTPONED;
+ }
+ return new WebExtension.InstallException(errorCode);
+ } else {
+ return new Exception(response.toString());
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ ErrorCodes.ERROR_NETWORK_FAILURE,
+ ErrorCodes.ERROR_INCORRECT_HASH,
+ ErrorCodes.ERROR_CORRUPT_FILE,
+ ErrorCodes.ERROR_FILE_ACCESS,
+ ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED,
+ ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE,
+ ErrorCodes.ERROR_UNEXPECTED_ADDON_VERSION,
+ ErrorCodes.ERROR_INCORRECT_ID,
+ ErrorCodes.ERROR_INVALID_DOMAIN,
+ ErrorCodes.ERROR_BLOCKLISTED,
+ ErrorCodes.ERROR_INCOMPATIBLE,
+ ErrorCodes.ERROR_USER_CANCELED,
+ ErrorCodes.ERROR_POSTPONED,
+ ErrorCodes.ERROR_UNSUPPORTED_ADDON_TYPE,
+ })
+ public @interface Codes {}
+
+ /** One of {@link ErrorCodes} that provides more information about this exception. */
+ public final @Codes int code;
+
+ /** An optional name of the extension that caused the exception. */
+ public final @Nullable String extensionName;
+
+ /** For testing */
+ protected InstallException() {
+ this.code = ErrorCodes.ERROR_NETWORK_FAILURE;
+ this.extensionName = null;
+ }
+
+ @Override
+ public String toString() {
+ return "InstallException: " + code;
+ }
+
+ /* package */ InstallException(final @Codes int code, final @Nullable String extensionName) {
+ this.code = code;
+ this.extensionName = extensionName;
+ }
+
+ /* package */ InstallException(final @Codes int code) {
+ this.code = code;
+ this.extensionName = null;
+ }
+ }
+
+ /**
+ * Set the Action delegate for this WebExtension.
+ *
+ * <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;
+
+ /** The extension has been disabled because it is not correctly signed. */
+ public static final int SIGNATURE = 1 << 4;
+
+ /**
+ * The extension has been disabled because it is not compatible with the application version.
+ */
+ public static final int APP_VERSION = 1 << 5;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ DisabledFlags.USER,
+ DisabledFlags.BLOCKLIST,
+ DisabledFlags.APP,
+ DisabledFlags.SIGNATURE,
+ DisabledFlags.APP_VERSION,
+ })
+ public @interface EnabledFlags {}
+
+ /** Provides information about a {@link WebExtension}. */
+ public class MetaData {
+ /**
+ * Main {@link Image} branding for this {@link WebExtension}. Can be used when displaying
+ * prompts.
+ */
+ public final @NonNull Image icon;
+
+ /**
+ * API permissions requested or granted to this extension.
+ *
+ * <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;
+
+ /** The full description of this extension. See: `AddonWrapper.fullDescription`. */
+ public final @Nullable String fullDescription;
+
+ /** The average rating of this extension. See: `AddonWrapper.averageRating`. */
+ public final double averageRating;
+
+ /** The review count for this extension. See: `AddonWrapper.reviewCount`. */
+ public final int reviewCount;
+
+ /** The link to the review page for this extension. See `AddonWrapper.reviewURL`. */
+ public final @Nullable String reviewUrl;
+
+ /**
+ * The string representation of the date that this extension was most recently updated
+ * (simplified ISO 8601 format). See `AddonWrapper.updateDate`.
+ */
+ public final @Nullable String updateDate;
+
+ /** The URL used to install this extension. See: `AddonInternal.sourceURI`. */
+ public final @Nullable String downloadUrl;
+
+ /**
+ * Version string for this extension.
+ *
+ * <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
+ * &amp; DisabledFlags.USER &gt; 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;
+
+ /** The link to the AMO detail page for this extension. See `AddonWrapper.amoListingURL`. */
+ public final @Nullable String amoListingUrl;
+
+ /**
+ * Indicates how the extension works with private browsing windows.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/incognito">
+ * manifest.json/incognito </a>
+ */
+ public final @Nullable String incognito;
+
+ /** 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;
+ fullDescription = null;
+ averageRating = 0;
+ reviewCount = 0;
+ reviewUrl = null;
+ updateDate = null;
+ downloadUrl = null;
+ amoListingUrl = null;
+ incognito = null;
+ }
+
+ /* package */ MetaData(final GeckoBundle bundle) {
+ // We only expose permissions that the embedder should prompt for
+ permissions = bundle.getStringArray("promptPermissions");
+ origins = bundle.getStringArray("origins");
+ description = bundle.getString("description");
+ version = bundle.getString("version");
+ creatorName = bundle.getString("creatorName");
+ creatorUrl = bundle.getString("creatorURL");
+ homepageUrl = bundle.getString("homepageURL");
+ name = bundle.getString("name");
+ optionsPageUrl = bundle.getString("optionsPageURL");
+ openOptionsPageInTab = bundle.getBoolean("openOptionsPageInTab");
+ isRecommended = bundle.getBoolean("isRecommended");
+ blocklistState = bundle.getInt("blocklistState", BlocklistStateFlags.NOT_BLOCKED);
+ enabled = bundle.getBoolean("enabled", false);
+ temporary = bundle.getBoolean("temporary", false);
+ baseUrl = bundle.getString("baseURL");
+ allowedInPrivateBrowsing = bundle.getBoolean("privateBrowsingAllowed", false);
+ fullDescription = bundle.getString("fullDescription");
+ averageRating = bundle.getDouble("averageRating");
+ reviewCount = bundle.getInt("reviewCount");
+ reviewUrl = bundle.getString("reviewURL");
+ updateDate = bundle.getString("updateDate");
+ downloadUrl = bundle.getString("downloadUrl");
+ amoListingUrl = bundle.getString("amoListingURL");
+ incognito = bundle.getString("incognito");
+
+ final int signedState = bundle.getInt("signedState", SignedStateFlags.UNKNOWN);
+ if (signedState <= SignedStateFlags.LAST) {
+ this.signedState = signedState;
+ } else {
+ Log.e(LOGTAG, "Unrecognized signed state: " + signedState);
+ this.signedState = SignedStateFlags.UNKNOWN;
+ }
+
+ int disabledFlags = 0;
+ final String[] disabledFlagsString = bundle.getStringArray("disabledFlags");
+
+ for (final String flag : disabledFlagsString) {
+ if (flag.equals("userDisabled")) {
+ disabledFlags |= DisabledFlags.USER;
+ } else if (flag.equals("blocklistDisabled")) {
+ disabledFlags |= DisabledFlags.BLOCKLIST;
+ } else if (flag.equals("appDisabled")) {
+ disabledFlags |= DisabledFlags.APP;
+ } else if (flag.equals("signatureDisabled")) {
+ disabledFlags |= DisabledFlags.SIGNATURE;
+ } else if (flag.equals("appVersionDisabled")) {
+ disabledFlags |= DisabledFlags.APP_VERSION;
+ } else {
+ Log.e(LOGTAG, "Unrecognized disabledFlag state: " + flag);
+ }
+ }
+ this.disabledFlags = disabledFlags;
+
+ if (bundle.containsKey("icons")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icons"));
+ } else {
+ icon = null;
+ }
+ }
+ }
+
+ // TODO: make public bug 1595822
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Context.NONE,
+ Context.BOOKMARK,
+ Context.BROWSER_ACTION,
+ Context.PAGE_ACTION,
+ Context.TAB,
+ Context.TOOLS_MENU
+ })
+ public @interface ContextFlags {}
+
+ /**
+ * Flags to determine which contexts a menu item should be shown in. See <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");
+
+ return new Builder(mainRequest)
+ .filename(optionsBundle.getString("filename"))
+ .downloadFlags(downloadFlags)
+ .conflictAction(conflictActionFlags)
+ .saveAs(saveAs)
+ .allowHttpErrors(allowHttpErrors)
+ .build();
+ }
+
+ /* package */ static class Builder {
+ private final WebRequest mRequest;
+ private @GeckoWebExecutor.FetchFlags int mDownloadFlags = 0;
+ private String mFilename = null;
+ private @ConflictActionFlags int mConflictActionFlag = CONFLICT_ACTION_UNIQUIFY;
+ private boolean mSaveAs = false;
+ private boolean mAllowHttpErrors = false;
+
+ /* package */ Builder(final WebRequest request) {
+ this.mRequest = request;
+ }
+
+ /* package */ Builder downloadFlags(final @GeckoWebExecutor.FetchFlags int flags) {
+ this.mDownloadFlags = flags;
+ return this;
+ }
+
+ /* package */ Builder filename(final String filename) {
+ this.mFilename = filename;
+ return this;
+ }
+
+ /* package */ Builder conflictAction(final @ConflictActionFlags int conflictActionFlag) {
+ this.mConflictActionFlag = conflictActionFlag;
+ return this;
+ }
+
+ /* package */ Builder saveAs(final boolean saveAs) {
+ this.mSaveAs = saveAs;
+ return this;
+ }
+
+ /* package */ Builder allowHttpErrors(final boolean allowHttpErrors) {
+ this.mAllowHttpErrors = allowHttpErrors;
+ return this;
+ }
+
+ /* package */ DownloadRequest build() {
+ return new DownloadRequest(this);
+ }
+ }
+ }
+
+ /** Represents initial information on a download provided to Web Extension */
+ public static class DownloadInitData {
+ @NonNull public final WebExtension.Download download;
+ @NonNull public final Download.Info initData;
+
+ public DownloadInitData(final Download download, final Download.Info initData) {
+ this.download = download;
+ this.initData = initData;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
new file mode 100644
index 0000000000..7e936f84f7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
@@ -0,0 +1,1752 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.util.Log;
+import android.util.SparseArray;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import org.json.JSONException;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.MultiMap;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.WebExtension.InstallException;
+
+public class WebExtensionController {
+ private static final String LOGTAG = "WebExtension";
+
+ private AddonManagerDelegate mAddonManagerDelegate;
+ private ExtensionProcessDelegate mExtensionProcessDelegate;
+ private DebuggerDelegate mDebuggerDelegate;
+ private PromptDelegate mPromptDelegate;
+ private final WebExtension.Listener<WebExtension.TabDelegate> mListener;
+
+ // Map [ (extensionId, nativeApp, session) -> message ]
+ private final MultiMap<MessageRecipient, Message> mPendingMessages;
+ private final MultiMap<String, Message> mPendingNewTab;
+ private final MultiMap<String, Message> mPendingBrowsingData;
+ private final MultiMap<String, Message> mPendingDownload;
+
+ private final SparseArray<WebExtension.Download> mDownloads;
+
+ private static class Message {
+ final GeckoBundle bundle;
+ final EventCallback callback;
+ final String event;
+ final GeckoSession session;
+
+ public Message(
+ final String event,
+ final GeckoBundle bundle,
+ final EventCallback callback,
+ final GeckoSession session) {
+ this.bundle = bundle;
+ this.callback = callback;
+ this.event = event;
+ this.session = session;
+ }
+ }
+
+ private static class ExtensionStore {
+ private final Map<String, WebExtension> mData = new HashMap<>();
+ private Observer mObserver;
+
+ interface Observer {
+ /**
+ * * This event is fired every time a new extension object is created by the store.
+ *
+ * @param extension the newly-created extension object
+ */
+ WebExtension onNewExtension(final GeckoBundle extension);
+ }
+
+ public GeckoResult<WebExtension> get(final String id) {
+ final WebExtension extension = mData.get(id);
+ if (extension != null) {
+ return GeckoResult.fromValue(extension);
+ }
+
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Get", bundle)
+ .map(
+ extensionBundle -> {
+ final WebExtension ext = mObserver.onNewExtension(extensionBundle);
+ mData.put(ext.id, ext);
+ return ext;
+ });
+ }
+
+ public void setObserver(final Observer observer) {
+ mObserver = observer;
+ }
+
+ public void remove(final String id) {
+ mData.remove(id);
+ }
+
+ /**
+ * Add this extension to the store and update it's current value if it's already present.
+ *
+ * @param id the {@link WebExtension} id.
+ * @param extension the {@link WebExtension} to add to the store.
+ */
+ public void update(final String id, final WebExtension extension) {
+ mData.put(id, extension);
+ }
+ }
+
+ private ExtensionStore mExtensions = new ExtensionStore();
+
+ private Internals mInternals = new Internals();
+
+ // Avoids exposing listeners to the API
+ private class Internals implements BundleEventListener, ExtensionStore.Observer {
+ @Override
+ // BundleEventListener
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ WebExtensionController.this.handleMessage(event, message, callback, null);
+ }
+
+ @Override
+ public WebExtension onNewExtension(final GeckoBundle bundle) {
+ return WebExtension.fromBundle(mDelegateControllerProvider, bundle);
+ }
+ }
+
+ /* package */ void releasePendingMessages(
+ final WebExtension extension, final String nativeApp, final GeckoSession session) {
+ Log.i(
+ LOGTAG,
+ "releasePendingMessages:"
+ + " extension="
+ + extension.id
+ + " nativeApp="
+ + nativeApp
+ + " session="
+ + session);
+ final List<Message> messages =
+ mPendingMessages.remove(new MessageRecipient(nativeApp, extension.id, session));
+ if (messages == null) {
+ return;
+ }
+
+ for (final Message message : messages) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+ }
+
+ private class DelegateController implements WebExtension.DelegateController {
+ private final WebExtension mExtension;
+
+ public DelegateController(final WebExtension extension) {
+ mExtension = extension;
+ }
+
+ @Override
+ public void onMessageDelegate(
+ final String nativeApp, final WebExtension.MessageDelegate delegate) {
+ mListener.setMessageDelegate(mExtension, delegate, nativeApp);
+ }
+
+ @Override
+ public void onActionDelegate(final WebExtension.ActionDelegate delegate) {
+ mListener.setActionDelegate(mExtension, delegate);
+ }
+
+ @Override
+ public WebExtension.ActionDelegate getActionDelegate() {
+ return mListener.getActionDelegate(mExtension);
+ }
+
+ @Override
+ public void onBrowsingDataDelegate(final WebExtension.BrowsingDataDelegate delegate) {
+ mListener.setBrowsingDataDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingBrowsingData.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingBrowsingData.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.BrowsingDataDelegate getBrowsingDataDelegate() {
+ return mListener.getBrowsingDataDelegate(mExtension);
+ }
+
+ @Override
+ public void onTabDelegate(final WebExtension.TabDelegate delegate) {
+ mListener.setTabDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingNewTab.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingNewTab.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.TabDelegate getTabDelegate() {
+ return mListener.getTabDelegate(mExtension);
+ }
+
+ @Override
+ public void onDownloadDelegate(final WebExtension.DownloadDelegate delegate) {
+ mListener.setDownloadDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingDownload.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingDownload.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.DownloadDelegate getDownloadDelegate() {
+ return mListener.getDownloadDelegate(mExtension);
+ }
+ }
+
+ final WebExtension.DelegateControllerProvider mDelegateControllerProvider =
+ new WebExtension.DelegateControllerProvider() {
+ @Override
+ public WebExtension.DelegateController controllerFor(final WebExtension extension) {
+ return new DelegateController(extension);
+ }
+ };
+
+ /**
+ * This delegate will be called whenever an extension is about to be installed or it needs new
+ * permissions, e.g during an update or because it called <code>permissions.request</code>
+ */
+ @UiThread
+ public interface PromptDelegate {
+ /**
+ * Called whenever a new extension is being installed. This is intended as an opportunity for
+ * the app to prompt the user for the permissions required by this extension.
+ *
+ * @param extension The {@link WebExtension} that is about to be installed. You can use {@link
+ * WebExtension#metaData} to gather information about this extension when building the user
+ * prompt dialog.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if
+ * this extension should be installed or {@link AllowOrDeny#DENY DENY} if this extension
+ * should not be installed. A null value will be interpreted as {@link AllowOrDeny#DENY
+ * DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onInstallPrompt(final @NonNull WebExtension extension) {
+ return null;
+ }
+
+ /**
+ * Called whenever an updated extension has new permissions. This is intended as an opportunity
+ * for the app to prompt the user for the new permissions required by this extension.
+ *
+ * @param currentlyInstalled The {@link WebExtension} that is currently installed.
+ * @param updatedExtension The {@link WebExtension} that will replace the previous extension.
+ * @param newPermissions The new permissions that are needed.
+ * @param newOrigins The new origins that are needed.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if
+ * this extension should be update or {@link AllowOrDeny#DENY DENY} if this extension should
+ * not be update. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onUpdatePrompt(
+ @NonNull final WebExtension currentlyInstalled,
+ @NonNull final WebExtension updatedExtension,
+ @NonNull final String[] newPermissions,
+ @NonNull final String[] newOrigins) {
+ return null;
+ }
+
+ /**
+ * Called whenever permissions are requested. This is intended as an opportunity for the app to
+ * prompt the user for the permissions required by this extension at runtime.
+ *
+ * @param extension The {@link WebExtension} that is about to be installed. You can use {@link
+ * WebExtension#metaData} to gather information about this extension when building the user
+ * prompt dialog.
+ * @param permissions The permissions that are requested.
+ * @param origins The requested host permissions.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if the
+ * request should be approved or {@link AllowOrDeny#DENY DENY} if the request should be
+ * denied. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onOptionalPrompt(
+ final @NonNull WebExtension extension,
+ final @NonNull String[] permissions,
+ final @NonNull String[] origins) {
+ return null;
+ }
+ }
+
+ public interface DebuggerDelegate {
+ /**
+ * Called whenever the list of installed extensions has been modified using the debugger with
+ * tools like web-ext.
+ *
+ * <p>This is intended as an opportunity to refresh the list of installed extensions using
+ * {@link WebExtensionController#list} and to set delegates on the new {@link WebExtension}
+ * objects, e.g. using {@link WebExtension#setActionDelegate} and {@link
+ * WebExtension#setMessageDelegate}.
+ *
+ * @see <a
+ * href="https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext">
+ * Getting started with web-ext</a>
+ */
+ @UiThread
+ default void onExtensionListUpdated() {}
+ }
+
+ /** This delegate will be called whenever the state of an extension has changed. */
+ public interface AddonManagerDelegate {
+ /**
+ * Called whenever an extension is being disabled.
+ *
+ * @param extension The {@link WebExtension} that is being disabled.
+ */
+ @UiThread
+ default void onDisabling(@NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension has been disabled.
+ *
+ * @param extension The {@link WebExtension} that is being disabled.
+ */
+ @UiThread
+ default void onDisabled(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension is being enabled.
+ *
+ * @param extension The {@link WebExtension} that is being enabled.
+ */
+ @UiThread
+ default void onEnabling(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension has been enabled.
+ *
+ * @param extension The {@link WebExtension} that is being enabled.
+ */
+ @UiThread
+ default void onEnabled(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension is being uninstalled.
+ *
+ * @param extension The {@link WebExtension} that is being uninstalled.
+ */
+ @UiThread
+ default void onUninstalling(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension has been uninstalled.
+ *
+ * @param extension The {@link WebExtension} that is being uninstalled.
+ */
+ @UiThread
+ default void onUninstalled(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension is being installed.
+ *
+ * @param extension The {@link WebExtension} that is being installed.
+ */
+ @UiThread
+ default void onInstalling(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an extension has been installed.
+ *
+ * @param extension The {@link WebExtension} that is being installed.
+ */
+ @UiThread
+ default void onInstalled(final @NonNull WebExtension extension) {}
+
+ /**
+ * Called whenever an error happened when installing a WebExtension.
+ *
+ * @param extension {@link WebExtension} which failed to be installed.
+ * @param installException {@link InstallException} indicates which type of error happened.
+ */
+ @UiThread
+ default void onInstallationFailed(
+ final @Nullable WebExtension extension, final @NonNull InstallException installException) {}
+
+ /**
+ * Called whenever an extension startup has been completed (and relative urls to assets packaged
+ * with the extension can be resolved into a full moz-extension url, e.g. optionsPageUrl is
+ * going to be empty until the extension has reached this callback).
+ *
+ * @param extension The {@link WebExtension} that has been fully started.
+ */
+ @UiThread
+ default void onReady(final @NonNull WebExtension extension) {}
+ }
+
+ /** This delegate is used to notify of extension process state changes. */
+ public interface ExtensionProcessDelegate {
+ /** Called when extension process spawning has been disabled. */
+ @UiThread
+ default void onDisabledProcessSpawning() {}
+ }
+
+ /**
+ * @return the current {@link PromptDelegate} instance.
+ * @see PromptDelegate
+ */
+ @UiThread
+ @Nullable
+ public PromptDelegate getPromptDelegate() {
+ return mPromptDelegate;
+ }
+
+ /**
+ * Set the {@link PromptDelegate} for this instance. This delegate will be used to be notified
+ * whenever an extension is being installed or needs new permissions.
+ *
+ * @param delegate the delegate instance.
+ * @see PromptDelegate
+ */
+ @UiThread
+ public void setPromptDelegate(final @Nullable PromptDelegate delegate) {
+ if (delegate == null && mPromptDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:InstallPrompt",
+ "GeckoView:WebExtension:UpdatePrompt",
+ "GeckoView:WebExtension:OptionalPrompt");
+ } else if (delegate != null && mPromptDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:InstallPrompt",
+ "GeckoView:WebExtension:UpdatePrompt",
+ "GeckoView:WebExtension:OptionalPrompt");
+ }
+
+ mPromptDelegate = delegate;
+ }
+
+ /**
+ * Set the {@link DebuggerDelegate} for this instance. This delegate will receive updates about
+ * extension changes using developer tools.
+ *
+ * @param delegate the Delegate instance
+ */
+ @UiThread
+ public void setDebuggerDelegate(final @NonNull DebuggerDelegate delegate) {
+ if (delegate == null && mDebuggerDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(mInternals, "GeckoView:WebExtension:DebuggerListUpdated");
+ } else if (delegate != null && mDebuggerDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mInternals, "GeckoView:WebExtension:DebuggerListUpdated");
+ }
+
+ mDebuggerDelegate = delegate;
+ }
+
+ /**
+ * Set the {@link AddonManagerDelegate} for this instance. This delegate will be used to be
+ * notified whenever the state of an extension has changed.
+ *
+ * @param delegate the delegate instance
+ * @see AddonManagerDelegate
+ */
+ @UiThread
+ public void setAddonManagerDelegate(final @Nullable AddonManagerDelegate delegate) {
+ if (delegate == null && mAddonManagerDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:OnDisabling",
+ "GeckoView:WebExtension:OnDisabled",
+ "GeckoView:WebExtension:OnEnabling",
+ "GeckoView:WebExtension:OnEnabled",
+ "GeckoView:WebExtension:OnUninstalling",
+ "GeckoView:WebExtension:OnUninstalled",
+ "GeckoView:WebExtension:OnInstalling",
+ "GeckoView:WebExtension:OnInstallationFailed",
+ "GeckoView:WebExtension:OnInstalled",
+ "GeckoView:WebExtension:OnReady");
+ } else if (delegate != null && mAddonManagerDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:OnDisabling",
+ "GeckoView:WebExtension:OnDisabled",
+ "GeckoView:WebExtension:OnEnabling",
+ "GeckoView:WebExtension:OnEnabled",
+ "GeckoView:WebExtension:OnUninstalling",
+ "GeckoView:WebExtension:OnUninstalled",
+ "GeckoView:WebExtension:OnInstalling",
+ "GeckoView:WebExtension:OnInstallationFailed",
+ "GeckoView:WebExtension:OnInstalled",
+ "GeckoView:WebExtension:OnReady");
+ }
+
+ mAddonManagerDelegate = delegate;
+ }
+
+ /**
+ * Set the {@link ExtensionProcessDelegate} for this instance. This delegate will be used to
+ * notify when the state of the extension process has changed.
+ *
+ * @param delegate the extension process delegate
+ * @see ExtensionProcessDelegate
+ */
+ @UiThread
+ public void setExtensionProcessDelegate(final @Nullable ExtensionProcessDelegate delegate) {
+ if (delegate == null && mExtensionProcessDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ mInternals, "GeckoView:WebExtension:OnDisabledProcessSpawning");
+ } else if (delegate != null && mExtensionProcessDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mInternals, "GeckoView:WebExtension:OnDisabledProcessSpawning");
+ }
+
+ mExtensionProcessDelegate = delegate;
+ }
+
+ /**
+ * Enable extension process spawning.
+ *
+ * <p>Extension process spawning can be disabled when the extension process has been killed or
+ * crashed beyond the threshold set for Gecko. This method can be called to reset the threshold
+ * count and allow the spawning again. If the threshold is reached again, {@link
+ * ExtensionProcessDelegate#onDisabledProcessSpawning()} will still be called.
+ *
+ * @see ExtensionProcessDelegate#onDisabledProcessSpawning()
+ */
+ @AnyThread
+ public void enableExtensionProcessSpawning() {
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:EnableProcessSpawning", null);
+ }
+
+ /**
+ * Disable extension process spawning.
+ *
+ * <p>Extension process spawning can be re-enabled with {@link
+ * WebExtensionController#enableExtensionProcessSpawning()}. This method does the opposite and
+ * stops the extension process. This method can be called when we no longer want to run extensions
+ * for the rest of the session.
+ *
+ * @see ExtensionProcessDelegate#onDisabledProcessSpawning()
+ */
+ @AnyThread
+ public void disableExtensionProcessSpawning() {
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:DisableProcessSpawning", null);
+ }
+
+ private static class InstallCanceller implements GeckoResult.CancellationDelegate {
+ public final String installId;
+
+ public InstallCanceller() {
+ installId = UUID.randomUUID().toString();
+ }
+
+ @Override
+ public GeckoResult<Boolean> cancel() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("installId", installId);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:CancelInstall", bundle)
+ .map(response -> response.getBoolean("cancelled"));
+ }
+ }
+
+ /**
+ * Install an extension.
+ *
+ * <p>An installed extension will persist and will be available even when restarting the {@link
+ * GeckoRuntime}.
+ *
+ * <p>Installed extensions through this method need to be signed by Mozilla, see <a
+ * href="https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/#distributing-your-addon">
+ * Distributing your add-on </a>.
+ *
+ * <p>When calling this method, the GeckoView library will download the extension, validate its
+ * manifest and signature, and give you an opportunity to verify its permissions through {@link
+ * PromptDelegate#installPrompt}, you can use this method to prompt the user if appropriate.
+ *
+ * @param uri URI to the extension's <code>.xpi</code> package. This can be a remote <code>https:
+ * </code> URI or a local <code>file:</code> or <code>resource:</code> URI. Note: the app
+ * needs the appropriate permissions for local URIs.
+ * @param installationMethod The method used by the embedder to install the {@link WebExtension}.
+ * @return A {@link GeckoResult} that will complete when the installation process finishes. For
+ * successful installations, the GeckoResult will return the {@link WebExtension} object that
+ * you can use to set delegates and retrieve information about the WebExtension using {@link
+ * WebExtension#metaData}.
+ * <p>If an error occurs during the installation process, the GeckoResult will complete
+ * exceptionally with a {@link WebExtension.InstallException InstallException} that will
+ * contain the relevant error code in {@link WebExtension.InstallException#code
+ * InstallException#code}.
+ * @see PromptDelegate#installPrompt
+ * @see WebExtension.InstallException.ErrorCodes
+ * @see WebExtension#metaData
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> install(
+ final @NonNull String uri, final @Nullable @InstallationMethod String installationMethod) {
+ final InstallCanceller canceller = new InstallCanceller();
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("locationUri", uri);
+ bundle.putString("installId", canceller.installId);
+ bundle.putString("installMethod", installationMethod);
+
+ final GeckoResult<WebExtension> result =
+ EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Install", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ result.setCancellationDelegate(canceller);
+ return result;
+ }
+
+ /**
+ * Install an extension.
+ *
+ * <p>An installed extension will persist and will be available even when restarting the {@link
+ * GeckoRuntime}.
+ *
+ * <p>Installed extensions through this method need to be signed by Mozilla, see <a
+ * href="https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/#distributing-your-addon">
+ * Distributing your add-on </a>.
+ *
+ * <p>When calling this method, the GeckoView library will download the extension, validate its
+ * manifest and signature, and give you an opportunity to verify its permissions through {@link
+ * PromptDelegate#installPrompt}, you can use this method to prompt the user if appropriate. If
+ * you are looking to provide an {@link InstallationMethod}, please use {@link
+ * WebExtensionController#install(String, String)}
+ *
+ * @param uri URI to the extension's <code>.xpi</code> package. This can be a remote <code>https:
+ * </code> URI or a local <code>file:</code> or <code>resource:</code> URI. Note: the app
+ * needs the appropriate permissions for local URIs.
+ * @return A {@link GeckoResult} that will complete when the installation process finishes. For
+ * successful installations, the GeckoResult will return the {@link WebExtension} object that
+ * you can use to set delegates and retrieve information about the WebExtension using {@link
+ * WebExtension#metaData}.
+ * <p>If an error occurs during the installation process, the GeckoResult will complete
+ * exceptionally with a {@link WebExtension.InstallException InstallException} that will
+ * contain the relevant error code in {@link WebExtension.InstallException#code
+ * InstallException#code}.
+ * @see PromptDelegate#installPrompt
+ * @see WebExtension.InstallException.ErrorCodes
+ * @see WebExtension#metaData
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> install(final @NonNull String uri) {
+ return install(uri, null);
+ }
+
+ /** The method used by the embedder to install the {@link WebExtension}. */
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({INSTALLATION_METHOD_MANAGER, INSTALLATION_METHOD_FROM_FILE})
+ public @interface InstallationMethod {};
+
+ /** Indicates the {@link WebExtension} was installed using from the embedder's add-ons manager. */
+ public static final String INSTALLATION_METHOD_MANAGER = "manager";
+
+ /** Indicates the {@link WebExtension} was installed from a file. */
+ public static final String INSTALLATION_METHOD_FROM_FILE = "install-from-file";
+
+ /**
+ * Set whether an extension should be allowed to run in private browsing or not.
+ *
+ * @param extension the {@link WebExtension} instance to modify.
+ * @param allowed true if this extension should be allowed to run in private browsing pages, false
+ * otherwise.
+ * @return the updated {@link WebExtension} instance.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> setAllowedInPrivateBrowsing(
+ final @NonNull WebExtension extension, final boolean allowed) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("extensionId", extension.id);
+ bundle.putBoolean("allowed", allowed);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:SetPBAllowed", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Install a built-in extension.
+ *
+ * <p>Built-in extensions have access to native messaging, don't need to be signed and are
+ * installed from a folder in the APK instead of a .xpi bundle.
+ *
+ * <p>Example:
+ *
+ * <p><code>
+ * controller.installBuiltIn("resource://android/assets/example/");
+ * </code> Will install the built-in extension located at <code>/assets/example/</code> in the
+ * app's APK.
+ *
+ * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only
+ * <code>resource://android</code> URIs are allowed.
+ * @see WebExtension.MessageDelegate
+ * @return A {@link GeckoResult} that completes with the extension once it's installed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> installBuiltIn(final @NonNull String uri) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("locationUri", uri);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:InstallBuiltIn", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Ensure that a built-in extension is installed.
+ *
+ * <p>Similar to {@link #installBuiltIn}, except the extension is not re-installed if it's already
+ * present and it has the same version.
+ *
+ * <p>Example:
+ *
+ * <p><code>
+ * controller.ensureBuiltIn("resource://android/assets/example/", "example@example.com");
+ * </code> Will install the built-in extension located at <code>/assets/example/</code> in the
+ * app's APK.
+ *
+ * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only
+ * <code>resource://android</code> URIs are allowed.
+ * @param id Extension ID as present in the manifest.json file.
+ * @see WebExtension.MessageDelegate
+ * @return A {@link GeckoResult} that completes with the extension once it's installed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> ensureBuiltIn(
+ final @NonNull String uri, final @Nullable String id) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("locationUri", uri);
+ bundle.putString("webExtensionId", id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:EnsureBuiltIn", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Uninstall an extension.
+ *
+ * <p>Uninstalling an extension will remove it from the current {@link GeckoRuntime} instance,
+ * delete all its data and trigger a request to close all extension pages currently open.
+ *
+ * @param extension The {@link WebExtension} to be uninstalled.
+ * @return A {@link GeckoResult} that will complete when the uninstall process is completed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<Void> uninstall(final @NonNull WebExtension extension) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("webExtensionId", extension.id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Uninstall", bundle)
+ .accept(result -> unregisterWebExtension(extension));
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({EnableSource.USER, EnableSource.APP})
+ public @interface EnableSources {}
+
+ /**
+ * Contains the possible values for the <code>source</code> parameter in {@link #enable} and
+ * {@link #disable}.
+ */
+ public static class EnableSource {
+ /** Action has been requested by the user. */
+ public static final int USER = 1;
+
+ /**
+ * Action requested by the app itself, e.g. to disable an extension that is not supported in
+ * this version of the app.
+ */
+ public static final int APP = 2;
+
+ static String toString(final @EnableSources int flag) {
+ if (flag == USER) {
+ return "user";
+ } else if (flag == APP) {
+ return "app";
+ } else {
+ throw new IllegalArgumentException("Value provided in flags is not valid.");
+ }
+ }
+ }
+
+ /**
+ * Enable an extension that has been disabled. If the extension is already enabled, this method
+ * has no effect.
+ *
+ * @param extension The {@link WebExtension} to be enabled.
+ * @param source The agent that initiated this action, e.g. if the action has been initiated by
+ * the user,use {@link EnableSource#USER}.
+ * @return the new {@link WebExtension} instance, updated to reflect the enablement.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> enable(
+ final @NonNull WebExtension extension, final @EnableSources int source) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("webExtensionId", extension.id);
+ bundle.putString("source", EnableSource.toString(source));
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Enable", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Disable an extension that is enabled. If the extension is already disabled, this method has no
+ * effect.
+ *
+ * @param extension The {@link WebExtension} to be disabled.
+ * @param source The agent that initiated this action, e.g. if the action has been initiated by
+ * the user, use {@link EnableSource#USER}.
+ * @return the new {@link WebExtension} instance, updated to reflect the disablement.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> disable(
+ final @NonNull WebExtension extension, final @EnableSources int source) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("webExtensionId", extension.id);
+ bundle.putString("source", EnableSource.toString(source));
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Disable", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ private List<WebExtension> listFromBundle(final GeckoBundle response) {
+ final GeckoBundle[] bundles = response.getBundleArray("extensions");
+ final List<WebExtension> list = new ArrayList<>(bundles.length);
+
+ for (final GeckoBundle bundle : bundles) {
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, bundle);
+ list.add(registerWebExtension(extension));
+ }
+
+ return list;
+ }
+
+ /**
+ * List installed extensions for this {@link GeckoRuntime}.
+ *
+ * <p>The returned list can be used to set delegates on the {@link WebExtension} objects using
+ * {@link WebExtension#setActionDelegate}, {@link WebExtension#setMessageDelegate}.
+ *
+ * @return a {@link GeckoResult} that will resolve when the list of extensions is available.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<List<WebExtension>> list() {
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:List")
+ .map(this::listFromBundle);
+ }
+
+ /**
+ * Update a web extension.
+ *
+ * <p>When checking for an update, GeckoView will download the update manifest that is defined by
+ * the web extension's manifest property <a
+ * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/">browser_specific_settings.gecko.update_url</a>.
+ * If an update is found it will be downloaded and installed. If the extension needs any new
+ * permissions the {@link PromptDelegate#updatePrompt} will be triggered.
+ *
+ * <p>More information about the update manifest format is available <a
+ * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/#manifest-structure">here</a>.
+ *
+ * @param extension The extension to update.
+ * @return A {@link GeckoResult} that will complete when the update process finishes. If an update
+ * is found and installed successfully, the GeckoResult will return the updated {@link
+ * WebExtension}. If no update is available, null will be returned. If the updated extension
+ * requires new permissions, the {@link PromptDelegate#installPrompt} will be called.
+ * @see PromptDelegate#updatePrompt
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> update(final @NonNull WebExtension extension) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("webExtensionId", extension.id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Update", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /* package */ WebExtensionController(final GeckoRuntime runtime) {
+ mListener = new WebExtension.Listener<>(runtime);
+ mPendingMessages = new MultiMap<>();
+ mPendingNewTab = new MultiMap<>();
+ mPendingBrowsingData = new MultiMap<>();
+ mPendingDownload = new MultiMap<>();
+ mExtensions.setObserver(mInternals);
+ mDownloads = new SparseArray<>();
+ }
+
+ /* package */ WebExtension registerWebExtension(final WebExtension webExtension) {
+ if (webExtension != null) {
+ mExtensions.update(webExtension.id, webExtension);
+ }
+ return webExtension;
+ }
+
+ /* package */ void handleMessage(
+ final String event,
+ final GeckoBundle bundle,
+ final EventCallback callback,
+ final GeckoSession session) {
+ final Message message = new Message(event, bundle, callback, session);
+
+ Log.d(LOGTAG, "handleMessage " + event);
+
+ if ("GeckoView:WebExtension:InstallPrompt".equals(event)) {
+ installPrompt(bundle, callback);
+ return;
+ } else if ("GeckoView:WebExtension:UpdatePrompt".equals(event)) {
+ updatePrompt(bundle, callback);
+ return;
+ } else if ("GeckoView:WebExtension:DebuggerListUpdated".equals(event)) {
+ if (mDebuggerDelegate != null) {
+ mDebuggerDelegate.onExtensionListUpdated();
+ }
+ return;
+ } else if ("GeckoView:WebExtension:OnDisabling".equals(event)) {
+ onDisabling(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnDisabled".equals(event)) {
+ onDisabled(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnEnabling".equals(event)) {
+ onEnabling(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnEnabled".equals(event)) {
+ onEnabled(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnUninstalling".equals(event)) {
+ onUninstalling(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnUninstalled".equals(event)) {
+ onUninstalled(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnInstalling".equals(event)) {
+ onInstalling(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnInstalled".equals(event)) {
+ onInstalled(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnDisabledProcessSpawning".equals(event)) {
+ onDisabledProcessSpawning();
+ return;
+ } else if ("GeckoView:WebExtension:OnInstallationFailed".equals(event)) {
+ onInstallationFailed(bundle);
+ return;
+ } else if ("GeckoView:WebExtension:OnReady".equals(event)) {
+ onReady(bundle);
+ return;
+ }
+
+ extensionFromBundle(bundle)
+ .accept(
+ extension -> {
+ if ("GeckoView:WebExtension:NewTab".equals(event)) {
+ newTab(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:UpdateTab".equals(event)) {
+ updateTab(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:CloseTab".equals(event)) {
+ closeTab(message, extension);
+ return;
+ } else if ("GeckoView:BrowserAction:Update".equals(event)) {
+ actionUpdate(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION);
+ return;
+ } else if ("GeckoView:PageAction:Update".equals(event)) {
+ actionUpdate(message, extension, WebExtension.Action.TYPE_PAGE_ACTION);
+ return;
+ } else if ("GeckoView:BrowserAction:OpenPopup".equals(event)) {
+ openPopup(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION);
+ return;
+ } else if ("GeckoView:PageAction:OpenPopup".equals(event)) {
+ openPopup(message, extension, WebExtension.Action.TYPE_PAGE_ACTION);
+ return;
+ } else if ("GeckoView:WebExtension:OpenOptionsPage".equals(event)) {
+ openOptionsPage(message, extension);
+ return;
+ } else if ("GeckoView:BrowsingData:GetSettings".equals(event)) {
+ getSettings(message, extension);
+ return;
+ } else if ("GeckoView:BrowsingData:Clear".equals(event)) {
+ browsingDataClear(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:Download".equals(event)) {
+ download(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:OptionalPrompt".equals(event)) {
+ optionalPrompt(message, extension);
+ return;
+ }
+
+ // GeckoView:WebExtension:Connect and GeckoView:WebExtension:Message
+ // are handled below.
+ final String nativeApp = bundle.getString("nativeApp");
+ if (nativeApp == null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing required nativeApp message parameter.");
+ }
+ callback.sendError("Missing nativeApp parameter.");
+ return;
+ }
+
+ final GeckoBundle senderBundle = bundle.getBundle("sender");
+ final WebExtension.MessageSender sender =
+ fromBundle(extension, senderBundle, session);
+ if (sender == null) {
+ if (callback != null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ try {
+ Log.e(
+ LOGTAG, "Could not find recipient for message: " + bundle.toJSONObject());
+ } catch (final JSONException ex) {
+ }
+ }
+ callback.sendError("Could not find recipient for " + bundle.getBundle("sender"));
+ }
+ return;
+ }
+
+ if ("GeckoView:WebExtension:Connect".equals(event)) {
+ connect(nativeApp, bundle.getLong("portId", -1), message, sender);
+ } else if ("GeckoView:WebExtension:Message".equals(event)) {
+ message(nativeApp, message, sender);
+ }
+ });
+ }
+
+ private void installPrompt(final GeckoBundle message, final EventCallback callback) {
+ final GeckoBundle extensionBundle = message.getBundle("extension");
+ if (extensionBundle == null
+ || !extensionBundle.containsKey("webExtensionId")
+ || !extensionBundle.containsKey("locationURI")) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing webExtensionId or locationURI");
+ }
+
+ Log.e(LOGTAG, "Missing webExtensionId or locationURI");
+ return;
+ }
+
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG, "Tried to install extension " + extension.id + " but no delegate is registered");
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> promptResponse = mPromptDelegate.onInstallPrompt(extension);
+ if (promptResponse == null) {
+ return;
+ }
+
+ callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void updatePrompt(final GeckoBundle message, final EventCallback callback) {
+ final GeckoBundle currentBundle = message.getBundle("currentlyInstalled");
+ final GeckoBundle updatedBundle = message.getBundle("updatedExtension");
+ final String[] newPermissions = message.getStringArray("newPermissions");
+ final String[] newOrigins = message.getStringArray("newOrigins");
+ if (currentBundle == null || updatedBundle == null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing bundle");
+ }
+
+ Log.e(LOGTAG, "Missing bundle");
+ return;
+ }
+
+ final WebExtension currentExtension =
+ new WebExtension(mDelegateControllerProvider, currentBundle);
+
+ final WebExtension updatedExtension =
+ new WebExtension(mDelegateControllerProvider, updatedBundle);
+
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG,
+ "Tried to update extension " + currentExtension.id + " but no delegate is registered");
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> promptResponse =
+ mPromptDelegate.onUpdatePrompt(
+ currentExtension, updatedExtension, newPermissions, newOrigins);
+ if (promptResponse == null) {
+ return;
+ }
+
+ callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void optionalPrompt(final Message message, final WebExtension extension) {
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG,
+ "Tried to request optional permissions for extension "
+ + extension.id
+ + " but no delegate is registered");
+ return;
+ }
+
+ final String[] permissions =
+ message.bundle.getBundle("permissions").getStringArray("permissions");
+ final String[] origins = message.bundle.getBundle("permissions").getStringArray("origins");
+ final GeckoResult<AllowOrDeny> promptResponse =
+ mPromptDelegate.onOptionalPrompt(extension, permissions, origins);
+ if (promptResponse == null) {
+ return;
+ }
+
+ message.callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void onInstallationFailed(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final int errorCode = bundle.getInt("error");
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ WebExtension extension = null;
+ final String extensionName = bundle.getString("addonName");
+
+ if (extensionBundle != null) {
+ extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ }
+ mAddonManagerDelegate.onInstallationFailed(
+ extension, new InstallException(errorCode, extensionName));
+ }
+
+ private void onDisabling(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onDisabling(extension);
+ }
+
+ private void onDisabled(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onDisabled(extension);
+ }
+
+ private void onEnabling(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onEnabling(extension);
+ }
+
+ private void onEnabled(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onEnabled(extension);
+ }
+
+ private void onUninstalling(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onUninstalling(extension);
+ }
+
+ private void onUninstalled(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onUninstalled(extension);
+ }
+
+ private void onInstalling(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onInstalling(extension);
+ }
+
+ private void onInstalled(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onInstalled(extension);
+ }
+
+ private void onReady(final GeckoBundle bundle) {
+ if (mAddonManagerDelegate == null) {
+ Log.e(LOGTAG, "no AddonManager delegate registered");
+ return;
+ }
+
+ final GeckoBundle extensionBundle = bundle.getBundle("extension");
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+ mAddonManagerDelegate.onReady(extension);
+ }
+
+ private void onDisabledProcessSpawning() {
+ if (mExtensionProcessDelegate == null) {
+ Log.e(LOGTAG, "no extension process delegate registered");
+ return;
+ }
+
+ mExtensionProcessDelegate.onDisabledProcessSpawning();
+ }
+
+ @SuppressLint("WrongThread") // for .toGeckoBundle
+ private void getSettings(final Message message, final WebExtension extension) {
+ final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension);
+ if (delegate == null) {
+ mPendingBrowsingData.add(extension.id, message);
+ return;
+ }
+
+ final GeckoResult<WebExtension.BrowsingDataDelegate.Settings> settingsResult =
+ delegate.onGetSettings();
+ if (settingsResult == null) {
+ message.callback.sendError("browsingData.settings is not supported");
+ return;
+ }
+ message.callback.resolveTo(settingsResult.map(settings -> settings.toGeckoBundle()));
+ }
+
+ private void browsingDataClear(final Message message, final WebExtension extension) {
+ final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension);
+ if (delegate == null) {
+ mPendingBrowsingData.add(extension.id, message);
+ return;
+ }
+
+ final long unixTimestamp = message.bundle.getLong("since");
+ final String dataType = message.bundle.getString("dataType");
+
+ final GeckoResult<Void> response;
+ if ("downloads".equals(dataType)) {
+ response = delegate.onClearDownloads(unixTimestamp);
+ } else if ("formData".equals(dataType)) {
+ response = delegate.onClearFormData(unixTimestamp);
+ } else if ("history".equals(dataType)) {
+ response = delegate.onClearHistory(unixTimestamp);
+ } else if ("passwords".equals(dataType)) {
+ response = delegate.onClearPasswords(unixTimestamp);
+ } else {
+ throw new IllegalStateException("Illegal clear data type: " + dataType);
+ }
+
+ message.callback.resolveTo(response);
+ }
+
+ /* package */ void download(final Message message, final WebExtension extension) {
+ final WebExtension.DownloadDelegate delegate = mListener.getDownloadDelegate(extension);
+ if (delegate == null) {
+ mPendingDownload.add(extension.id, message);
+ return;
+ }
+
+ final GeckoBundle optionsBundle = message.bundle.getBundle("options");
+
+ final WebExtension.DownloadRequest request =
+ WebExtension.DownloadRequest.fromBundle(optionsBundle);
+
+ final GeckoResult<WebExtension.DownloadInitData> result =
+ delegate.onDownload(extension, request);
+ if (result == null) {
+ message.callback.sendError("downloads.download is not supported");
+ return;
+ }
+
+ message.callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == null) {
+ Log.e(LOGTAG, "onDownload returned invalid null value");
+ throw new IllegalArgumentException("downloads.download is not supported");
+ }
+
+ final GeckoBundle returnMessage =
+ WebExtension.Download.downloadInfoToBundle(value.initData);
+ returnMessage.putInt("id", value.download.id);
+
+ return returnMessage;
+ }));
+ }
+
+ /* package */ void openOptionsPage(final Message message, final WebExtension extension) {
+ final GeckoBundle bundle = message.bundle;
+ final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension);
+
+ if (delegate != null) {
+ delegate.onOpenOptionsPage(extension);
+ } else {
+ message.callback.sendError("runtime.openOptionsPage is not supported");
+ }
+
+ message.callback.sendSuccess(null);
+ }
+
+ /* package */
+ @SuppressLint("WrongThread") // for .isOpen
+ void newTab(final Message message, final WebExtension extension) {
+ final GeckoBundle bundle = message.bundle;
+
+ final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension);
+ final WebExtension.CreateTabDetails details =
+ new WebExtension.CreateTabDetails(bundle.getBundle("createProperties"));
+
+ final GeckoResult<GeckoSession> result;
+ if (delegate != null) {
+ result = delegate.onNewTab(extension, details);
+ } else {
+ mPendingNewTab.add(extension.id, message);
+ return;
+ }
+
+ if (result == null) {
+ message.callback.sendSuccess(false);
+ return;
+ }
+
+ final String newSessionId = message.bundle.getString("newSessionId");
+ message.callback.resolveTo(
+ result.map(
+ session -> {
+ if (session == null) {
+ return false;
+ }
+
+ if (session.isOpen()) {
+ throw new IllegalArgumentException("Must use an unopened GeckoSession instance");
+ }
+
+ session.open(mListener.runtime, newSessionId);
+ return true;
+ }));
+ }
+
+ /* package */ void updateTab(final Message message, final WebExtension extension) {
+ final WebExtension.SessionTabDelegate delegate =
+ message.session.getWebExtensionController().getTabDelegate(extension);
+ final EventCallback callback = message.callback;
+
+ if (delegate == null) {
+ callback.sendError("tabs.update is not supported");
+ return;
+ }
+
+ final WebExtension.UpdateTabDetails details =
+ new WebExtension.UpdateTabDetails(message.bundle.getBundle("updateProperties"));
+ callback.resolveTo(
+ delegate
+ .onUpdateTab(extension, message.session, details)
+ .map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return null;
+ } else {
+ throw new Exception("tabs.update is not supported");
+ }
+ }));
+ }
+
+ /* package */ void closeTab(final Message message, final WebExtension extension) {
+ final WebExtension.SessionTabDelegate delegate =
+ message.session.getWebExtensionController().getTabDelegate(extension);
+
+ final GeckoResult<AllowOrDeny> result;
+ if (delegate != null) {
+ result = delegate.onCloseTab(extension, message.session);
+ } else {
+ result = GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+
+ message.callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return null;
+ } else {
+ throw new Exception("tabs.remove is not supported");
+ }
+ }));
+ }
+
+ /**
+ * Notifies extensions about a active tab change over the `tabs.onActivated` event.
+ *
+ * @param session The {@link GeckoSession} of the newly selected session/tab.
+ * @param active true if the tab became active, false if the tab became inactive.
+ */
+ @AnyThread
+ public void setTabActive(@NonNull final GeckoSession session, final boolean active) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putBoolean("active", active);
+ session.getEventDispatcher().dispatch("GeckoView:WebExtension:SetTabActive", bundle);
+ }
+
+ /* package */ void unregisterWebExtension(final WebExtension webExtension) {
+ mExtensions.remove(webExtension.id);
+ mListener.unregisterWebExtension(webExtension);
+ }
+
+ private WebExtension.MessageSender fromBundle(
+ final WebExtension extension, final GeckoBundle sender, final GeckoSession session) {
+ if (extension == null) {
+ // All senders should have an extension
+ return null;
+ }
+
+ final String envType = sender.getString("envType");
+ @WebExtension.MessageSender.EnvType final int environmentType;
+
+ if ("content_child".equals(envType)) {
+ environmentType = WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT;
+ } else if ("addon_child".equals(envType)) {
+ // TODO Bug 1554277: check that this message is coming from the right process
+ environmentType = WebExtension.MessageSender.ENV_TYPE_EXTENSION;
+ } else {
+ environmentType = WebExtension.MessageSender.ENV_TYPE_UNKNOWN;
+ }
+
+ if (environmentType == WebExtension.MessageSender.ENV_TYPE_UNKNOWN) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing or unknown envType: " + envType);
+ }
+
+ return null;
+ }
+
+ final String url = sender.getString("url");
+ final boolean isTopLevel;
+ if (session == null || environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
+ // This message is coming from the background page, a popup, or an extension page
+ isTopLevel = true;
+ } else {
+ // If session is present we are either receiving this message from a content script or
+ // an extension page, let's make sure we have the proper identification so that
+ // embedders can check the origin of this message.
+ // -1 is an invalid frame id
+ final boolean hasFrameId =
+ sender.containsKey("frameId") && sender.getInt("frameId", -1) != -1;
+ final boolean hasUrl = sender.containsKey("url");
+ if (!hasFrameId || !hasUrl) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException(
+ "Missing sender information. hasFrameId: " + hasFrameId + " hasUrl: " + hasUrl);
+ }
+
+ // This message does not have the proper identification and may be compromised,
+ // let's ignore it.
+ return null;
+ }
+
+ isTopLevel = sender.getInt("frameId", -1) == 0;
+ }
+
+ return new WebExtension.MessageSender(extension, session, url, environmentType, isTopLevel);
+ }
+
+ private WebExtension.MessageDelegate getDelegate(
+ final String nativeApp,
+ final WebExtension.MessageSender sender,
+ final EventCallback callback) {
+ if ((sender.webExtension.flags & WebExtension.Flags.ALLOW_CONTENT_MESSAGING) == 0
+ && sender.environmentType == WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT) {
+ callback.sendError("This NativeApp can't receive messages from Content Scripts.");
+ return null;
+ }
+
+ WebExtension.MessageDelegate delegate = null;
+
+ if (sender.session != null) {
+ delegate =
+ sender
+ .session
+ .getWebExtensionController()
+ .getMessageDelegate(sender.webExtension, nativeApp);
+ } else if (sender.environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
+ delegate = mListener.getMessageDelegate(sender.webExtension, nativeApp);
+ }
+
+ return delegate;
+ }
+
+ private static class MessageRecipient {
+ public final String webExtensionId;
+ public final String nativeApp;
+ public final GeckoSession session;
+
+ public MessageRecipient(
+ final String webExtensionId, final String nativeApp, final GeckoSession session) {
+ this.webExtensionId = webExtensionId;
+ this.nativeApp = nativeApp;
+ this.session = session;
+ }
+
+ private static boolean equals(final Object a, final Object b) {
+ return Objects.equals(a, b);
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof MessageRecipient)) {
+ return false;
+ }
+
+ final MessageRecipient o = (MessageRecipient) other;
+ return equals(webExtensionId, o.webExtensionId)
+ && equals(nativeApp, o.nativeApp)
+ && equals(session, o.session);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {webExtensionId, nativeApp, session});
+ }
+ }
+
+ private void connect(
+ final String nativeApp,
+ final long portId,
+ final Message message,
+ final WebExtension.MessageSender sender) {
+ if (portId == -1) {
+ message.callback.sendError("Missing portId.");
+ return;
+ }
+
+ final WebExtension.Port port = new WebExtension.Port(nativeApp, portId, sender);
+
+ final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, message.callback);
+ if (delegate == null) {
+ mPendingMessages.add(
+ new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message);
+ return;
+ }
+
+ delegate.onConnect(port);
+ message.callback.sendSuccess(true);
+ }
+
+ private void message(
+ final String nativeApp, final Message message, final WebExtension.MessageSender sender) {
+ final EventCallback callback = message.callback;
+
+ final Object content;
+ try {
+ content = message.bundle.toJSONObject().get("data");
+ } catch (final JSONException ex) {
+ callback.sendError(ex.getMessage());
+ return;
+ }
+
+ final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, callback);
+ if (delegate == null) {
+ mPendingMessages.add(
+ new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message);
+ return;
+ }
+
+ final GeckoResult<Object> response = delegate.onMessage(nativeApp, content, sender);
+ if (response == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(response);
+ }
+
+ private GeckoResult<WebExtension> extensionFromBundle(final GeckoBundle message) {
+ final String extensionId = message.getString("extensionId");
+ return mExtensions.get(extensionId);
+ }
+
+ private void openPopup(
+ final Message message,
+ final WebExtension extension,
+ final @WebExtension.Action.ActionType int actionType) {
+ if (extension == null) {
+ return;
+ }
+
+ final WebExtension.Action action =
+ new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension);
+ final String popupUri = message.bundle.getString("popupUri");
+
+ final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final GeckoResult<GeckoSession> popup = delegate.onOpenPopup(extension, action);
+ action.openPopup(popup, popupUri);
+ }
+
+ private WebExtension.ActionDelegate actionDelegateFor(
+ final WebExtension extension, final GeckoSession session) {
+ if (session == null) {
+ return mListener.getActionDelegate(extension);
+ }
+
+ return session.getWebExtensionController().getActionDelegate(extension);
+ }
+
+ private void actionUpdate(
+ final Message message,
+ final WebExtension extension,
+ final @WebExtension.Action.ActionType int actionType) {
+ if (extension == null) {
+ return;
+ }
+
+ final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final WebExtension.Action action =
+ new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension);
+ if (actionType == WebExtension.Action.TYPE_BROWSER_ACTION) {
+ delegate.onBrowserAction(extension, message.session, action);
+ } else if (actionType == WebExtension.Action.TYPE_PAGE_ACTION) {
+ delegate.onPageAction(extension, message.session, action);
+ }
+ }
+
+ // TODO: implement bug 1595822
+ /* package */ static GeckoResult<List<WebExtension.Menu>> getMenu(
+ final GeckoBundle menuArrayBundle) {
+ return null;
+ }
+
+ @Nullable
+ @UiThread
+ public WebExtension.Download createDownload(final int id) {
+ if (mDownloads.indexOfKey(id) >= 0) {
+ throw new IllegalArgumentException("Download with this id already exists");
+ } else {
+ final WebExtension.Download download = new WebExtension.Download(id);
+ mDownloads.put(id, download);
+
+ return download;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java
new file mode 100644
index 0000000000..520cb9faa0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java
@@ -0,0 +1,117 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.Map;
+import java.util.TreeMap;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/** This is an abstract base class for HTTP request and response types. */
+@WrapForJNI
+@AnyThread
+public abstract class WebMessage {
+
+ /** The URI for the request or response. */
+ public final @NonNull String uri;
+
+ /** An unmodifiable Map of headers. Defaults to an empty instance. */
+ public final @NonNull Map<String, String> headers;
+
+ protected WebMessage(final @NonNull Builder builder) {
+ uri = builder.mUri;
+ headers = Collections.unmodifiableMap(builder.mHeaders);
+ }
+
+ // This is only used via JNI.
+ private String[] getHeaderKeys() {
+ final String[] keys = new String[headers.size()];
+ headers.keySet().toArray(keys);
+ return keys;
+ }
+
+ // This is only used via JNI.
+ private String[] getHeaderValues() {
+ final String[] values = new String[headers.size()];
+ headers.values().toArray(values);
+ return values;
+ }
+
+ /** This is a Builder used by subclasses of {@link WebMessage}. */
+ @AnyThread
+ public abstract static class Builder {
+ /* package */ String mUri;
+ /* package */ Map<String, String> mHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ /* package */ ByteBuffer mBody;
+
+ /**
+ * Construct a Builder instance with the specified URI.
+ *
+ * @param uri A URI String.
+ */
+ /* package */ Builder(final @NonNull String uri) {
+ uri(uri);
+ }
+
+ /**
+ * Set the URI
+ *
+ * @param uri A URI String
+ * @return This Builder instance.
+ */
+ public @NonNull Builder uri(final @NonNull String uri) {
+ mUri = uri;
+ return this;
+ }
+
+ /**
+ * Set a HTTP header. This may be called multiple times for additional headers. If an existing
+ * header of the same name exists, it will be replaced by this value.
+ *
+ * <p>Please note that the HTTP header keys are case-insensitive. It means you can retrieve
+ * "Content-Type" with map.get("content-type"), and value for "Content-Type" will be overwritten
+ * by map.put("cONTENt-TYpe", value); The keys are also sorted in natural order.
+ *
+ * @param key The key for the HTTP header, e.g. "content-type".
+ * @param value The value for the HTTP header, e.g. "application/json".
+ * @return This Builder instance.
+ */
+ public @NonNull Builder header(final @NonNull String key, final @NonNull String value) {
+ mHeaders.put(key, value);
+ return this;
+ }
+
+ /**
+ * Add a HTTP header. This may be called multiple times for additional headers. If an existing
+ * header of the same name exists, the values will be merged.
+ *
+ * <p>Please note that the HTTP header keys are case-insensitive. It means you can retrieve
+ * "Content-Type" with map.get("content-type"), and value for "Content-Type" will be overwritten
+ * by map.put("cONTENt-TYpe", value); The keys are also sorted in natural order.
+ *
+ * @param key The key for the HTTP header, e.g. "content-type".
+ * @param value The value for the HTTP header, e.g. "application/json".
+ * @return This Builder instance.
+ */
+ public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) {
+ final String existingValue = mHeaders.get(key);
+ if (existingValue != null) {
+ final StringBuilder builder = new StringBuilder(existingValue);
+ builder.append(", ");
+ builder.append(value);
+ mHeaders.put(key, builder.toString());
+ } else {
+ mHeaders.put(key, value);
+ }
+
+ return this;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java
new file mode 100644
index 0000000000..c2de231f80
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java
@@ -0,0 +1,233 @@
+/* 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.os.Parcel;
+import android.os.ParcelFormatException;
+import android.os.Parcelable;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This class represents a single <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">Web Notification</a>. These
+ * can be received by connecting a {@link WebNotificationDelegate} to {@link GeckoRuntime} via
+ * {@link GeckoRuntime#setWebNotificationDelegate(WebNotificationDelegate)}.
+ */
+public class WebNotification implements Parcelable {
+
+ /**
+ * Title is shown at the top of the notification window.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/title">Web
+ * Notification - title</a>
+ */
+ public final @Nullable String title;
+
+ /**
+ * Tag is the ID of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/tag">Web
+ * Notification - tag</a>
+ */
+ public final @NonNull String tag;
+
+ private final @Nullable String mCookie;
+
+ /**
+ * Text represents the body of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/body">Web
+ * Notification - text</a>
+ */
+ public final @Nullable String text;
+
+ /**
+ * ImageURL contains the URL of an icon to be displayed as part of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/icon">Web
+ * Notification - icon</a>
+ */
+ public final @Nullable String imageUrl;
+
+ /**
+ * TextDirection indicates the direction that the language of the text is displayed. Possible
+ * values are: auto: adopts the browser's language setting behaviour (the default.) ltr: left to
+ * right. rtl: right to left.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir">Web
+ * Notification - dir</a>
+ */
+ public final @Nullable String textDirection;
+
+ /**
+ * Lang indicates the notification's language, as specified using a DOMString representing a BCP
+ * 47 language tag.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/DOMString">DOM String</a>
+ * @see <a href="http://www.rfc-editor.org/rfc/bcp/bcp47.txt">BCP 47</a>
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/lang">Web
+ * Notification - lang</a>
+ */
+ public final @Nullable String lang;
+
+ /**
+ * RequireInteraction indicates whether a notification should remain active until the user clicks
+ * or dismisses it, rather than closing automatically.
+ *
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/requireInteraction">Web
+ * Notification - requireInteraction</a>
+ */
+ public final @NonNull boolean requireInteraction;
+
+ /**
+ * This is the URL of the page or Service Worker that generated the notification. Null if this
+ * notification was not generated by a Web Page (e.g. from an Extension).
+ *
+ * <p>TODO: make NonNull once we have Bug 1589693
+ */
+ public final @Nullable String source;
+
+ /**
+ * When set, indicates that no sounds or vibrations should be made.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent">Web
+ * Notification - silent</a>
+ */
+ public final boolean silent;
+
+ /** indicates whether the notification came from private browsing mode or not. */
+ public final boolean privateBrowsing;
+
+ /**
+ * A vibration pattern to run with the display of the notification. A vibration pattern can be an
+ * array with as few as one member. The values are times in milliseconds where the even indices
+ * (0, 2, 4, etc.) indicate how long to vibrate and the odd indices indicate how long to pause.
+ * For example, [300, 100, 400] would vibrate 300ms, pause 100ms, then vibrate 400ms.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate">Web
+ * Notification - vibrate</a>
+ */
+ public final @NonNull int[] vibrate;
+
+ @WrapForJNI
+ /* package */ WebNotification(
+ @Nullable final String title,
+ @NonNull final String tag,
+ @Nullable final String cookie,
+ @Nullable final String text,
+ @Nullable final String imageUrl,
+ @Nullable final String textDirection,
+ @Nullable final String lang,
+ @NonNull final boolean requireInteraction,
+ @NonNull final String source,
+ final boolean silent,
+ final boolean privateBrowsing,
+ @NonNull final int[] vibrate) {
+ this.tag = tag;
+ this.mCookie = cookie;
+ this.title = title;
+ this.text = text;
+ this.imageUrl = imageUrl;
+ this.textDirection = textDirection;
+ this.lang = lang;
+ this.requireInteraction = requireInteraction;
+ this.source = "".equals(source) ? null : source;
+ this.silent = silent;
+ this.vibrate = vibrate;
+ this.privateBrowsing = privateBrowsing;
+ }
+
+ /**
+ * This should be called when the user taps or clicks a notification. Note that this does not
+ * automatically dismiss the notification as far as Web Content is concerned. For that, see {@link
+ * #dismiss()}.
+ */
+ @UiThread
+ public void click() {
+ ThreadUtils.assertOnUiThread();
+ GeckoAppShell.onNotificationClick(tag, mCookie);
+ }
+
+ /**
+ * This should be called when the app stops showing the notification. This is important, as there
+ * may be a limit to the number of active notifications each site can display.
+ */
+ @UiThread
+ public void dismiss() {
+ ThreadUtils.assertOnUiThread();
+ GeckoAppShell.onNotificationClose(tag, mCookie);
+ }
+
+ // Increment this value whenever anything changes in the parcelable representation.
+ private static final int VERSION = 1;
+
+ // To avoid TransactionTooLargeException, we only store small imageUrls
+ private static final int IMAGE_URL_LENGTH_MAX = 150;
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(VERSION);
+ dest.writeString(title);
+ dest.writeString(tag);
+ dest.writeString(mCookie);
+ dest.writeString(text);
+ if (imageUrl.length() < IMAGE_URL_LENGTH_MAX) {
+ dest.writeString(imageUrl);
+ } else {
+ dest.writeString("");
+ }
+ dest.writeString(textDirection);
+ dest.writeString(lang);
+ dest.writeInt(requireInteraction ? 1 : 0);
+ dest.writeString(source);
+ dest.writeInt(silent ? 1 : 0);
+ dest.writeInt(privateBrowsing ? 1 : 0);
+ dest.writeIntArray(vibrate);
+ }
+
+ private WebNotification(final Parcel in) {
+ title = in.readString();
+ tag = in.readString();
+ mCookie = in.readString();
+ text = in.readString();
+ imageUrl = in.readString();
+ textDirection = in.readString();
+ lang = in.readString();
+ requireInteraction = in.readInt() == 1;
+ source = in.readString();
+ silent = in.readInt() == 1;
+ privateBrowsing = in.readInt() == 1;
+ vibrate = in.createIntArray();
+ }
+
+ public static final Creator<WebNotification> CREATOR =
+ new Creator<>() {
+ @Override
+ public WebNotification createFromParcel(final Parcel in) {
+ final int version = in.readInt();
+ if (version != VERSION) {
+ throw new ParcelFormatException(
+ "Mismatched version: " + version + " expected: " + VERSION);
+ }
+ return new WebNotification(in);
+ }
+
+ @Override
+ public WebNotification[] newArray(final int size) {
+ return new WebNotification[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java
new file mode 100644
index 0000000000..40db55fa3c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java
@@ -0,0 +1,29 @@
+/* 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 androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public interface WebNotificationDelegate {
+ /**
+ * This is called when a new notification is created.
+ *
+ * @param notification The WebNotification received.
+ */
+ @AnyThread
+ @WrapForJNI
+ default void onShowNotification(@NonNull final WebNotification notification) {}
+
+ /**
+ * This is called when an existing notification is closed.
+ *
+ * @param notification The WebNotification received.
+ */
+ @AnyThread
+ @WrapForJNI
+ default void onCloseNotification(@NonNull final WebNotification notification) {}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
new file mode 100644
index 0000000000..f5ea153bfe
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
@@ -0,0 +1,165 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class WebPushController {
+ private static final String LOGTAG = "WebPushController";
+
+ private WebPushDelegate mDelegate;
+ private BundleEventListener mEventListener;
+
+ /* package */ WebPushController() {
+ mEventListener = new EventListener();
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ mEventListener,
+ "GeckoView:PushSubscribe",
+ "GeckoView:PushUnsubscribe",
+ "GeckoView:PushGetSubscription");
+ }
+
+ /**
+ * Sets the {@link WebPushDelegate} for this instance.
+ *
+ * @param delegate The {@link WebPushDelegate} instance.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable WebPushDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Gets the {@link WebPushDelegate} for this instance.
+ *
+ * @return delegate The {@link WebPushDelegate} instance.
+ */
+ @UiThread
+ @Nullable
+ public WebPushDelegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mDelegate;
+ }
+
+ /**
+ * Send a push event for a given subscription.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ */
+ @UiThread
+ public void onPushEvent(final @NonNull String scope) {
+ ThreadUtils.assertOnUiThread();
+ onPushEvent(scope, null);
+ }
+
+ /**
+ * Send a push event with a payload for a given subscription.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ * @param data The unencrypted payload.
+ */
+ @UiThread
+ public void onPushEvent(final @NonNull String scope, final @Nullable byte[] data) {
+ ThreadUtils.assertOnUiThread();
+
+ GeckoThread.waitForState(GeckoThread.State.JNI_READY)
+ .accept(
+ val -> {
+ final GeckoBundle msg = new GeckoBundle(2);
+ msg.putString("scope", scope);
+ msg.putString("data", Base64Utils.encode(data));
+ EventDispatcher.getInstance().dispatch("GeckoView:PushEvent", msg);
+ },
+ e -> Log.e(LOGTAG, "Unable to deliver Web Push message", e));
+ }
+
+ /**
+ * Notify that a given subscription has changed. This is normally a signal to the content that it
+ * needs to re-subscribe.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ */
+ @UiThread
+ public void onSubscriptionChanged(final @NonNull String scope) {
+ ThreadUtils.assertOnUiThread();
+
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putString("scope", scope);
+ EventDispatcher.getInstance().dispatch("GeckoView:PushSubscriptionChanged", msg);
+ }
+
+ private class EventListener implements BundleEventListener {
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (mDelegate == null) {
+ callback.sendError("Not allowed");
+ return;
+ }
+
+ switch (event) {
+ case "GeckoView:PushSubscribe":
+ {
+ byte[] appServerKey = null;
+ if (message.containsKey("appServerKey")) {
+ appServerKey = Base64Utils.decode(message.getString("appServerKey"));
+ }
+
+ final GeckoResult<WebPushSubscription> result =
+ mDelegate.onSubscribe(message.getString("scope"), appServerKey);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ result.accept(
+ subscription ->
+ callback.sendSuccess(subscription != null ? subscription.toBundle() : null),
+ error -> callback.sendSuccess(null));
+ break;
+ }
+ case "GeckoView:PushUnsubscribe":
+ {
+ final GeckoResult<Void> result = mDelegate.onUnsubscribe(message.getString("scope"));
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(result.map(val -> null));
+ break;
+ }
+ case "GeckoView:PushGetSubscription":
+ {
+ final GeckoResult<WebPushSubscription> result =
+ mDelegate.onGetSubscription(message.getString("scope"));
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(subscription -> subscription != null ? subscription.toBundle() : null));
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
new file mode 100644
index 0000000000..d9e9c39274
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
@@ -0,0 +1,62 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+
+public interface WebPushDelegate {
+ /**
+ * Creates a push subscription for the given service worker scope. A scope uniquely identifies a
+ * service worker. `appServerKey` optionally creates a restricted subscription.
+ *
+ * <p>Applications will likely want to persist the returned {@link WebPushSubscription} in order
+ * to support {@link #onGetSubscription(String)}.
+ *
+ * @param scope The Service Worker scope.
+ * @param appServerKey An optional application server key.
+ * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription}
+ * @see <a href="http://w3c.github.io/push-api/#dom-pushmanager-subscribe">subscribe()</a>
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushsubscriptionoptionsinit-applicationserverkey">Application
+ * server key</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<WebPushSubscription> onSubscribe(
+ @NonNull final String scope, @Nullable final byte[] appServerKey) {
+ return null;
+ }
+
+ /**
+ * Retrieves a subscription for the given service worker scope.
+ *
+ * @param scope The scope for the requested {@link WebPushSubscription}.
+ * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription}
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushmanager-getsubscription">getSubscription()</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<WebPushSubscription> onGetSubscription(
+ @NonNull final String scope) {
+ return null;
+ }
+
+ /**
+ * Removes a push subscription. If this fails, apps should resolve the returned {@link
+ * GeckoResult} with an exception.
+ *
+ * @param scope The Service Worker scope for the subscription.
+ * @return A {@link GeckoResult}, which if non-exceptional indicates successfully unsubscribing.
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushsubscription-unsubscribe">unsubscribe()</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<Void> onUnsubscribe(@NonNull final String scope) {
+ return null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
new file mode 100644
index 0000000000..7ce9a3d60c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
@@ -0,0 +1,180 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.Arrays;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * This class represents a single Web Push subscription, as described in the <a
+ * href="https://www.w3.org/TR/push-api/">Web Push API</a> specification.
+ *
+ * <p>This is a low-level interface, allowing applications to do all of the heavy lifting
+ * themselves. It is recommended that consumers have a thorough understanding of the Web Push API,
+ * especially <a href="https://tools.ietf.org/html/rfc8291">RFC 8291</a>.
+ *
+ * <p>Only trivial sanity checks are performed on the values held here. The application must ensure
+ * it is generating compliant keys/secrets itself.
+ */
+public class WebPushSubscription implements Parcelable {
+ private static final int P256_PUBLIC_KEY_LENGTH = 65;
+
+ /**
+ * The Service Worker scope associated with this subscription.
+ *
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register">ServiceWorker
+ * registration</a>
+ */
+ @NonNull public final String scope;
+
+ /**
+ * The Web Push endpoint for this subscription. This is the URL of a web service which implements
+ * the Web Push protocol.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc8030#section-5">RFC 8030</a>
+ */
+ @NonNull public final String endpoint;
+
+ /**
+ * This is an optional public key provided by the application server to authenticate itself with
+ * the endpoint, formatted according to X9.62.
+ *
+ * <p>This key is used for VAPID, the Voluntary Application Server Identification (VAPID) for Web
+ * Push, from <a href="https://tools.ietf.org/html/rfc8292">RFC 8292</a>.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushsubscriptionoptions-applicationserverkey">applicationServerKey</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291">Message Encryption for Web Push</a>
+ */
+ @Nullable public final byte[] appServerKey;
+
+ /**
+ * The P-256 EC public key, formatted as X9.62, generated by the embedder, to be provided to the
+ * app server for message encryption.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-p256dh">PushEncryptionKeyName
+ * - p256dh</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.1">RFC 8291 section 3.1</a>
+ */
+ @NonNull public final byte[] browserPublicKey;
+
+ /**
+ * 16 byte secret key, generated by the embedder, to be provided to the app server for use in
+ * encrypting and authenticating messages sent to the {@link #endpoint}.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-auth">PushEncryptionKeyName
+ * - auth</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.2">RFC 8291, section 3.2</a>
+ */
+ @NonNull public final byte[] authSecret;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public WebPushSubscription(
+ final @NonNull String scope,
+ final @NonNull String endpoint,
+ final @Nullable byte[] appServerKey,
+ final @NonNull byte[] browserPublicKey,
+ final @NonNull byte[] authSecret) {
+ this.scope = scope;
+ this.endpoint = endpoint;
+ this.appServerKey = appServerKey;
+ this.browserPublicKey = browserPublicKey;
+ this.authSecret = authSecret;
+
+ if (appServerKey != null) {
+ if (appServerKey.length != P256_PUBLIC_KEY_LENGTH) {
+ throw new IllegalArgumentException(
+ String.format("appServerKey should be %d bytes", P256_PUBLIC_KEY_LENGTH));
+ }
+
+ if (Arrays.equals(appServerKey, browserPublicKey)) {
+ throw new IllegalArgumentException("appServerKey and browserPublicKey must differ");
+ }
+ }
+
+ if (browserPublicKey.length != P256_PUBLIC_KEY_LENGTH) {
+ throw new IllegalArgumentException(
+ String.format("browserPublicKey should be %d bytes", P256_PUBLIC_KEY_LENGTH));
+ }
+
+ if (authSecret.length != 16) {
+ throw new IllegalArgumentException("authSecret must be 128 bits");
+ }
+ }
+
+ private WebPushSubscription(final Parcel in) {
+ this.scope = in.readString();
+ this.endpoint = in.readString();
+
+ if (ParcelableUtils.readBoolean(in)) {
+ this.appServerKey = new byte[P256_PUBLIC_KEY_LENGTH];
+ in.readByteArray(this.appServerKey);
+ } else {
+ appServerKey = null;
+ }
+
+ this.browserPublicKey = new byte[P256_PUBLIC_KEY_LENGTH];
+ in.readByteArray(this.browserPublicKey);
+
+ this.authSecret = new byte[16];
+ in.readByteArray(this.authSecret);
+ }
+
+ /* package */ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(5);
+ bundle.putString("scope", scope);
+ bundle.putString("endpoint", endpoint);
+ if (appServerKey != null) {
+ bundle.putString("appServerKey", Base64Utils.encode(appServerKey));
+ }
+ bundle.putString("browserPublicKey", Base64Utils.encode(browserPublicKey));
+ bundle.putString("authSecret", Base64Utils.encode(authSecret));
+ return bundle;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeString(scope);
+ out.writeString(endpoint);
+
+ ParcelableUtils.writeBoolean(out, appServerKey != null);
+ if (appServerKey != null) {
+ out.writeByteArray(appServerKey);
+ }
+
+ out.writeByteArray(browserPublicKey);
+ out.writeByteArray(authSecret);
+ }
+
+ public static final Parcelable.Creator<WebPushSubscription> CREATOR =
+ new Parcelable.Creator<WebPushSubscription>() {
+ @Override
+ @AnyThread
+ public WebPushSubscription createFromParcel(final Parcel parcel) {
+ return new WebPushSubscription(parcel);
+ }
+
+ @Override
+ @AnyThread
+ public WebPushSubscription[] newArray(final int size) {
+ return new WebPushSubscription[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java
new file mode 100644
index 0000000000..30ee5451aa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java
@@ -0,0 +1,248 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * WebRequest represents an HTTP[S] request. The typical pattern is to create instances of this
+ * class via {@link WebRequest.Builder}, and fetch responses via {@link
+ * GeckoWebExecutor#fetch(WebRequest)}.
+ */
+@WrapForJNI
+@AnyThread
+public class WebRequest extends WebMessage {
+ /** The HTTP method for the request. Defaults to "GET". */
+ public final @NonNull String method;
+
+ /** The body of the request. Must be a directly-allocated ByteBuffer. May be null. */
+ public final @Nullable ByteBuffer body;
+
+ /**
+ * The cache mode for the request. See {@link #CACHE_MODE_DEFAULT}. These modes match those from
+ * the DOM Fetch API.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Request/cache">DOM Fetch API
+ * cache modes</a>
+ */
+ public final @CacheMode int cacheMode;
+
+ /**
+ * If true, do not use newer protocol features that might have interop problems on the Internet.
+ * Intended only for use with critical infrastructure.
+ */
+ public final boolean beConservative;
+
+ /** The value of the Referer header for this request. */
+ public final @Nullable String referrer;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ CACHE_MODE_DEFAULT,
+ CACHE_MODE_NO_STORE,
+ CACHE_MODE_RELOAD,
+ CACHE_MODE_NO_CACHE,
+ CACHE_MODE_FORCE_CACHE,
+ CACHE_MODE_ONLY_IF_CACHED
+ })
+ public @interface CacheMode {};
+
+ /** Default cache mode. Normal caching rules apply. */
+ public static final int CACHE_MODE_DEFAULT = 1;
+
+ /**
+ * The response will be fetched from the server without looking in the cache, and will not update
+ * the cache with the downloaded response.
+ */
+ public static final int CACHE_MODE_NO_STORE = 2;
+
+ /**
+ * The response will be fetched from the server without looking in the cache. The cache will be
+ * updated with the downloaded response.
+ */
+ public static final int CACHE_MODE_RELOAD = 3;
+
+ /** Forces a conditional request to the server if there is a cache match. */
+ public static final int CACHE_MODE_NO_CACHE = 4;
+
+ /**
+ * If a response is found in the cache, it will be returned, whether it's fresh or not. If there
+ * is no match, a normal request will be made and the cache will be updated with the downloaded
+ * response.
+ */
+ public static final int CACHE_MODE_FORCE_CACHE = 5;
+
+ /**
+ * If a response is found in the cache, it will be returned, whether it's fresh or not. If there
+ * is no match from the cache, 504 Gateway Timeout will be returned.
+ */
+ public static final int CACHE_MODE_ONLY_IF_CACHED = 6;
+
+ /* package */ static final int CACHE_MODE_FIRST = CACHE_MODE_DEFAULT;
+ /* package */ static final int CACHE_MODE_LAST = CACHE_MODE_ONLY_IF_CACHED;
+
+ /**
+ * Constructs a WebRequest with the specified URI.
+ *
+ * @param uri A URI String, e.g. https://mozilla.org
+ */
+ public WebRequest(final @NonNull String uri) {
+ this(new Builder(uri));
+ }
+
+ /** Constructs a new WebRequest from a {@link WebRequest.Builder}. */
+ /* package */ WebRequest(final @NonNull Builder builder) {
+ super(builder);
+ method = builder.mMethod;
+ cacheMode = builder.mCacheMode;
+ referrer = builder.mReferrer;
+ beConservative = builder.mBeConservative;
+
+ if (builder.mBody != null) {
+ body = builder.mBody.asReadOnlyBuffer();
+ } else {
+ body = null;
+ }
+ }
+
+ /** Builder offers a convenient way for constructing {@link WebRequest} instances. */
+ @AnyThread
+ public static class Builder extends WebMessage.Builder {
+ /* package */ String mMethod = "GET";
+ /* package */ int mCacheMode = CACHE_MODE_DEFAULT;
+ /* package */ String mReferrer;
+ /* package */ boolean mBeConservative;
+
+ /**
+ * Construct a Builder instance with the specified URI.
+ *
+ * @param uri A URI String.
+ */
+ public Builder(final @NonNull String uri) {
+ super(uri);
+ }
+
+ @Override
+ public @NonNull Builder uri(final @NonNull String uri) {
+ super.uri(uri);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder header(final @NonNull String key, final @NonNull String value) {
+ super.header(key, value);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) {
+ super.addHeader(key, value);
+ return this;
+ }
+
+ /**
+ * Set the body.
+ *
+ * @param buffer A {@link ByteBuffer} with the data. Must be allocated directly via {@link
+ * ByteBuffer#allocateDirect(int)}.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder body(final @Nullable ByteBuffer buffer) {
+ if (buffer != null && !buffer.isDirect()) {
+ throw new IllegalArgumentException("body must be directly allocated");
+ }
+ mBody = buffer;
+ return this;
+ }
+
+ /**
+ * Set the body.
+ *
+ * @param bodyString A {@link String} with the data.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder body(final @Nullable String bodyString) {
+ if (bodyString == null) {
+ mBody = null;
+ return this;
+ }
+ final CharBuffer chars = CharBuffer.wrap(bodyString);
+ final ByteBuffer buffer = ByteBuffer.allocateDirect(bodyString.length());
+ Charset.forName("UTF-8").newEncoder().encode(chars, buffer, true);
+
+ mBody = buffer;
+ return this;
+ }
+
+ /**
+ * Set the HTTP method.
+ *
+ * @param method The HTTP method String.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder method(final @NonNull String method) {
+ mMethod = method;
+ return this;
+ }
+
+ /**
+ * Set the cache mode.
+ *
+ * @param mode One of the {@link #CACHE_MODE_DEFAULT CACHE_*} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder cacheMode(final @CacheMode int mode) {
+ if (mode < CACHE_MODE_FIRST || mode > CACHE_MODE_LAST) {
+ throw new IllegalArgumentException("Unknown cache mode");
+ }
+ mCacheMode = mode;
+ return this;
+ }
+
+ /**
+ * Set the HTTP Referer header.
+ *
+ * @param referrer A URI String
+ * @return This Builder instance.
+ */
+ public @NonNull Builder referrer(final @Nullable String referrer) {
+ mReferrer = referrer;
+ return this;
+ }
+
+ /**
+ * Set the beConservative property.
+ *
+ * @param beConservative If true, do not use newer protocol features that might have interop
+ * problems on the Internet. Intended only for use with critical infrastructure.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder beConservative(final boolean beConservative) {
+ mBeConservative = beConservative;
+ return this;
+ }
+
+ /**
+ * @return A {@link WebRequest} constructed with the values from this Builder instance.
+ */
+ public @NonNull WebRequest build() {
+ if (mUri == null) {
+ throw new IllegalStateException("Must set URI");
+ }
+ return new WebRequest(this);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java
new file mode 100644
index 0000000000..4b081483e5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java
@@ -0,0 +1,380 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import java.io.ByteArrayInputStream;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.XPCOMError;
+
+/**
+ * WebRequestError is simply a container for error codes and categories used by {@link
+ * GeckoSession.NavigationDelegate#onLoadError(GeckoSession, String, WebRequestError)}.
+ */
+@AnyThread
+public class WebRequestError extends Exception {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ ERROR_CATEGORY_UNKNOWN,
+ ERROR_CATEGORY_SECURITY,
+ ERROR_CATEGORY_NETWORK,
+ ERROR_CATEGORY_CONTENT,
+ ERROR_CATEGORY_URI,
+ ERROR_CATEGORY_PROXY,
+ ERROR_CATEGORY_SAFEBROWSING
+ })
+ public @interface ErrorCategory {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ ERROR_UNKNOWN,
+ ERROR_SECURITY_SSL,
+ ERROR_SECURITY_BAD_CERT,
+ ERROR_NET_RESET,
+ ERROR_NET_INTERRUPT,
+ ERROR_NET_TIMEOUT,
+ ERROR_CONNECTION_REFUSED,
+ ERROR_UNKNOWN_PROTOCOL,
+ ERROR_UNKNOWN_HOST,
+ ERROR_UNKNOWN_SOCKET_TYPE,
+ ERROR_UNKNOWN_PROXY_HOST,
+ ERROR_MALFORMED_URI,
+ ERROR_REDIRECT_LOOP,
+ ERROR_SAFEBROWSING_PHISHING_URI,
+ ERROR_SAFEBROWSING_MALWARE_URI,
+ ERROR_SAFEBROWSING_UNWANTED_URI,
+ ERROR_SAFEBROWSING_HARMFUL_URI,
+ ERROR_CONTENT_CRASHED,
+ ERROR_OFFLINE,
+ ERROR_PORT_BLOCKED,
+ ERROR_PROXY_CONNECTION_REFUSED,
+ ERROR_FILE_NOT_FOUND,
+ ERROR_FILE_ACCESS_DENIED,
+ ERROR_INVALID_CONTENT_ENCODING,
+ ERROR_UNSAFE_CONTENT_TYPE,
+ ERROR_CORRUPTED_CONTENT,
+ ERROR_DATA_URI_TOO_LONG,
+ ERROR_HTTPS_ONLY,
+ ERROR_BAD_HSTS_CERT
+ })
+ public @interface Error {}
+
+ /**
+ * This is normally used for error codes that don't currently fit into any of the other
+ * categories.
+ */
+ public static final int ERROR_CATEGORY_UNKNOWN = 0x1;
+
+ /** This is used for error codes that relate to SSL certificate validation. */
+ public static final int ERROR_CATEGORY_SECURITY = 0x2;
+
+ /** This is used for error codes relating to network problems. */
+ public static final int ERROR_CATEGORY_NETWORK = 0x3;
+
+ /** This is used for error codes relating to invalid or corrupt web pages. */
+ public static final int ERROR_CATEGORY_CONTENT = 0x4;
+
+ public static final int ERROR_CATEGORY_URI = 0x5;
+ public static final int ERROR_CATEGORY_PROXY = 0x6;
+ public static final int ERROR_CATEGORY_SAFEBROWSING = 0x7;
+
+ /** An unknown error occurred */
+ public static final int ERROR_UNKNOWN = 0x11;
+
+ // Security
+ /** This is used for a variety of SSL negotiation problems. */
+ public static final int ERROR_SECURITY_SSL = 0x22;
+
+ /** This is used to indicate an untrusted or otherwise invalid SSL certificate. */
+ public static final int ERROR_SECURITY_BAD_CERT = 0x32;
+
+ // Network
+ /** The network connection was interrupted. */
+ public static final int ERROR_NET_INTERRUPT = 0x23;
+
+ /** The network request timed out. */
+ public static final int ERROR_NET_TIMEOUT = 0x33;
+
+ /** The network request was refused by the server. */
+ public static final int ERROR_CONNECTION_REFUSED = 0x43;
+
+ /** The network request tried to use an unknown socket type. */
+ public static final int ERROR_UNKNOWN_SOCKET_TYPE = 0x53;
+
+ /** A redirect loop was detected. */
+ public static final int ERROR_REDIRECT_LOOP = 0x63;
+
+ /** This device does not have a network connection. */
+ public static final int ERROR_OFFLINE = 0x73;
+
+ /** The request tried to use a port that is blocked by either the OS or Gecko. */
+ public static final int ERROR_PORT_BLOCKED = 0x83;
+
+ /** The connection was reset. */
+ public static final int ERROR_NET_RESET = 0x93;
+
+ /**
+ * GeckoView could not connect to this website in HTTPS-only mode. Call
+ * document.reloadWithHttpsOnlyException() in the error page to temporarily disable HTTPS only
+ * mode for this request.
+ *
+ * <p>See also {@link GeckoSession.NavigationDelegate#onLoadError}
+ */
+ public static final int ERROR_HTTPS_ONLY = 0xA3;
+
+ /**
+ * A certificate validation error occurred when connecting to a site that does not allow error
+ * overrides.
+ */
+ public static final int ERROR_BAD_HSTS_CERT = 0xB3;
+
+ // Content
+ /** A content type was returned which was deemed unsafe. */
+ public static final int ERROR_UNSAFE_CONTENT_TYPE = 0x24;
+
+ /** The content returned was corrupted. */
+ public static final int ERROR_CORRUPTED_CONTENT = 0x34;
+
+ /** The content process crashed. */
+ public static final int ERROR_CONTENT_CRASHED = 0x44;
+
+ /** The content has an invalid encoding. */
+ public static final int ERROR_INVALID_CONTENT_ENCODING = 0x54;
+
+ // URI
+ /** The host could not be resolved. */
+ public static final int ERROR_UNKNOWN_HOST = 0x25;
+
+ /** An invalid URL was specified. */
+ public static final int ERROR_MALFORMED_URI = 0x35;
+
+ /** An unknown protocol was specified. */
+ public static final int ERROR_UNKNOWN_PROTOCOL = 0x45;
+
+ /** A file was not found (usually used for file:// URIs). */
+ public static final int ERROR_FILE_NOT_FOUND = 0x55;
+
+ /** The OS blocked access to a file. */
+ public static final int ERROR_FILE_ACCESS_DENIED = 0x65;
+
+ /** A data:// URI is too long to load at the top level. */
+ public static final int ERROR_DATA_URI_TOO_LONG = 0x75;
+
+ // Proxy
+ /** The proxy server refused the connection. */
+ public static final int ERROR_PROXY_CONNECTION_REFUSED = 0x26;
+
+ /** The host name of the proxy server could not be resolved. */
+ public static final int ERROR_UNKNOWN_PROXY_HOST = 0x36;
+
+ // Safebrowsing
+ /** The requested URI was present in the "malware" blocklist. */
+ public static final int ERROR_SAFEBROWSING_MALWARE_URI = 0x27;
+
+ /** The requested URI was present in the "unwanted" blocklist. */
+ public static final int ERROR_SAFEBROWSING_UNWANTED_URI = 0x37;
+
+ /** The requested URI was present in the "harmful" blocklist. */
+ public static final int ERROR_SAFEBROWSING_HARMFUL_URI = 0x47;
+
+ /** The requested URI was present in the "phishing" blocklist. */
+ public static final int ERROR_SAFEBROWSING_PHISHING_URI = 0x57;
+
+ /** The error code, e.g. {@link #ERROR_MALFORMED_URI}. */
+ public final int code;
+
+ /** The error category, e.g. {@link #ERROR_CATEGORY_URI}. */
+ public final int category;
+
+ /**
+ * The server certificate used. This can be useful if the error code is is e.g. {@link
+ * #ERROR_SECURITY_BAD_CERT}.
+ */
+ public final @Nullable X509Certificate certificate;
+
+ /**
+ * Construct a new WebRequestError with the specified code and category.
+ *
+ * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI}
+ * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI}
+ */
+ public WebRequestError(final @Error int code, final @ErrorCategory int category) {
+ this(code, category, null);
+ }
+
+ /**
+ * Construct a new WebRequestError with the specified code and category.
+ *
+ * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI}
+ * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI}
+ * @param certificate The X509Certificate server certificate used, if applicable.
+ */
+ public WebRequestError(
+ final @Error int code, final @ErrorCategory int category, final X509Certificate certificate) {
+ super(String.format("Request failed, error=0x%x, category=0x%x", code, category));
+ this.code = code;
+ this.category = category;
+ this.certificate = certificate;
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof WebRequestError)) {
+ return false;
+ }
+
+ final WebRequestError otherError = (WebRequestError) other;
+
+ // We don't compare the certificate here because it's almost never what you want.
+ return otherError.code == this.code && otherError.category == this.category;
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {category, code});
+ }
+
+ @WrapForJNI
+ /* package */ static WebRequestError fromGeckoError(
+ final long geckoError,
+ final int geckoErrorModule,
+ final int geckoErrorClass,
+ final byte[] certificateBytes) {
+ // XXX: the geckoErrorModule argument is redundant
+ assert geckoErrorModule == XPCOMError.getErrorModule(geckoError);
+ final int code = convertGeckoError(geckoError, geckoErrorClass);
+ final int category = getErrorCategory(XPCOMError.getErrorModule(geckoError), code);
+ X509Certificate certificate = null;
+ if (certificateBytes != null) {
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ certificate =
+ (X509Certificate)
+ factory.generateCertificate(new ByteArrayInputStream(certificateBytes));
+ } catch (final CertificateException e) {
+ throw new IllegalArgumentException("Unable to parse DER certificate");
+ }
+ }
+
+ return new WebRequestError(code, category, certificate);
+ }
+
+ @SuppressLint("WrongConstant")
+ @WrapForJNI
+ /* package */ static @ErrorCategory int getErrorCategory(
+ final long errorModule, final @Error int error) {
+ if (errorModule == XPCOMError.NS_ERROR_MODULE_SECURITY) {
+ return ERROR_CATEGORY_SECURITY;
+ }
+ return error & 0xF;
+ }
+
+ @WrapForJNI
+ /* package */ static @Error int convertGeckoError(
+ final long geckoError, final int geckoErrorClass) {
+ // safebrowsing
+ if (geckoError == XPCOMError.NS_ERROR_PHISHING_URI) {
+ return ERROR_SAFEBROWSING_PHISHING_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_MALWARE_URI) {
+ return ERROR_SAFEBROWSING_MALWARE_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNWANTED_URI) {
+ return ERROR_SAFEBROWSING_UNWANTED_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_HARMFUL_URI) {
+ return ERROR_SAFEBROWSING_HARMFUL_URI;
+ }
+ // content
+ if (geckoError == XPCOMError.NS_ERROR_CONTENT_CRASHED) {
+ return ERROR_CONTENT_CRASHED;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_INVALID_CONTENT_ENCODING) {
+ return ERROR_INVALID_CONTENT_ENCODING;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNSAFE_CONTENT_TYPE) {
+ return ERROR_UNSAFE_CONTENT_TYPE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_CORRUPTED_CONTENT) {
+ return ERROR_CORRUPTED_CONTENT;
+ }
+ // network
+ if (geckoError == XPCOMError.NS_ERROR_NET_RESET) {
+ return ERROR_NET_RESET;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_NET_RESET) {
+ return ERROR_NET_INTERRUPT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_NET_TIMEOUT) {
+ return ERROR_NET_TIMEOUT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_CONNECTION_REFUSED) {
+ return ERROR_CONNECTION_REFUSED;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_SOCKET_TYPE) {
+ return ERROR_UNKNOWN_SOCKET_TYPE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_REDIRECT_LOOP) {
+ return ERROR_REDIRECT_LOOP;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_HTTPS_ONLY) {
+ return ERROR_HTTPS_ONLY;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_BAD_HSTS_CERT) {
+ return ERROR_BAD_HSTS_CERT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_OFFLINE) {
+ return ERROR_OFFLINE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_PORT_ACCESS_NOT_ALLOWED) {
+ return ERROR_PORT_BLOCKED;
+ }
+ // uri
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROTOCOL) {
+ return ERROR_UNKNOWN_PROTOCOL;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_HOST) {
+ return ERROR_UNKNOWN_HOST;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_MALFORMED_URI) {
+ return ERROR_MALFORMED_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_FILE_NOT_FOUND) {
+ return ERROR_FILE_NOT_FOUND;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_FILE_ACCESS_DENIED) {
+ return ERROR_FILE_ACCESS_DENIED;
+ }
+ // proxy
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROXY_HOST) {
+ return ERROR_UNKNOWN_PROXY_HOST;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_PROXY_CONNECTION_REFUSED) {
+ return ERROR_PROXY_CONNECTION_REFUSED;
+ }
+
+ if (XPCOMError.getErrorModule(geckoError) == XPCOMError.NS_ERROR_MODULE_SECURITY) {
+ if (geckoErrorClass == 1) {
+ return ERROR_SECURITY_SSL;
+ }
+ if (geckoErrorClass == 2) {
+ return ERROR_SECURITY_BAD_CERT;
+ }
+ }
+
+ return ERROR_UNKNOWN;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java
new file mode 100644
index 0000000000..8c224ed2e3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java
@@ -0,0 +1,227 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * WebResponse represents an HTTP[S] response. It is normally created by {@link
+ * GeckoWebExecutor#fetch(WebRequest)}.
+ */
+@WrapForJNI
+@AnyThread
+public class WebResponse extends WebMessage {
+ /** The default read timeout for the {@link #body} stream. */
+ public static final long DEFAULT_READ_TIMEOUT_MS = 30000;
+
+ /** The HTTP status code for the response, e.g. 200. */
+ public final int statusCode;
+
+ /** A boolean indicating whether or not this response is the result of a redirection. */
+ public final boolean redirected;
+
+ /** Whether or not this response was delivered via a secure connection. */
+ public final boolean isSecure;
+
+ /** The server certificate used with this response, if any. */
+ public final @Nullable X509Certificate certificate;
+
+ /**
+ * An {@link InputStream} containing the response body, if available. Attention: the stream must
+ * be closed whenever the app is done with it, even when the body is ignored. Otherwise the
+ * connection will not be closed until the stream is garbage collected
+ */
+ public final @Nullable InputStream body;
+
+ /**
+ * Specifies that the contents should request to be opened in another Android application. For
+ * example, provide PDF content and set this to true to request that Android opens the PDF in a
+ * system PDF viewer (if possible and allowed by the user).
+ */
+ public final @Nullable boolean requestExternalApp;
+
+ /**
+ * Specifies that the app may skip requesting the download in the UI. A confirmation of the
+ * download will still be shown.
+ */
+ public final @Nullable boolean skipConfirmation;
+
+ protected WebResponse(final @NonNull Builder builder) {
+ super(builder);
+ this.statusCode = builder.mStatusCode;
+ this.redirected = builder.mRedirected;
+ this.body = builder.mBody;
+ this.requestExternalApp = builder.mRequestExternalApp;
+ this.skipConfirmation = builder.mSkipConfirmation;
+ this.isSecure = builder.mIsSecure;
+ this.certificate = builder.mCertificate;
+
+ this.setReadTimeoutMillis(DEFAULT_READ_TIMEOUT_MS);
+ }
+
+ /**
+ * Sets the maximum amount of time to wait for data in the {@link #body} read() method. By
+ * default, the read timeout is set to {@link #DEFAULT_READ_TIMEOUT_MS}.
+ *
+ * <p>If 0, there will be no timeout and read() will block indefinitely.
+ *
+ * @param millis The duration in milliseconds for the timeout.
+ */
+ public void setReadTimeoutMillis(final long millis) {
+ if (this.body != null && this.body instanceof GeckoInputStream) {
+ ((GeckoInputStream) this.body).setReadTimeoutMillis(millis);
+ }
+ }
+
+ /** Builder offers a convenient way to create WebResponse instances. */
+ @WrapForJNI
+ @AnyThread
+ public static class Builder extends WebMessage.Builder {
+ /* package */ int mStatusCode;
+ /* package */ boolean mRedirected;
+ /* package */ InputStream mBody;
+ /* package */ boolean mRequestExternalApp = false;
+ /* package */ boolean mSkipConfirmation = false;
+ /* package */ boolean mIsSecure;
+ /* package */ X509Certificate mCertificate;
+
+ /**
+ * Constructs a new Builder instance with the specified URI.
+ *
+ * @param uri A URI String.
+ */
+ public Builder(final @NonNull String uri) {
+ super(uri);
+ }
+
+ @Override
+ public @NonNull Builder uri(final @NonNull String uri) {
+ super.uri(uri);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder header(final @NonNull String key, final @NonNull String value) {
+ super.header(key, value);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) {
+ super.addHeader(key, value);
+ return this;
+ }
+
+ /**
+ * Sets the {@link InputStream} containing the body of this response.
+ *
+ * @param stream An {@link InputStream} with the body of the response.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder body(final @NonNull InputStream stream) {
+ mBody = stream;
+ return this;
+ }
+
+ /**
+ * Requests that the content be passed to an external Android application. The default is false.
+ * For example, set to true to request that the user have the option to open the content in
+ * another Android application.
+ *
+ * @param requestExternalApp request that the content be opened in another application.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder requestExternalApp(final boolean requestExternalApp) {
+ mRequestExternalApp = requestExternalApp;
+ return this;
+ }
+
+ /**
+ * Specifies if a confirmation to begin downloading is necessary or not. (The confirmation that
+ * a download occurred will still be shown.) The default is false, which is to request a
+ * download confirmation. Skipping the confirmation is only advisable if the user has already
+ * opted to download.
+ *
+ * @param skipConfirmation whether to skip or show the confirm download flow
+ * @return This Builder instance.
+ */
+ public @NonNull Builder skipConfirmation(final boolean skipConfirmation) {
+ mSkipConfirmation = skipConfirmation;
+ return this;
+ }
+
+ /**
+ * @param isSecure Whether or not this response is secure.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder isSecure(final boolean isSecure) {
+ mIsSecure = isSecure;
+ return this;
+ }
+
+ /**
+ * @param certificate The certificate used.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder certificate(final @NonNull X509Certificate certificate) {
+ mCertificate = certificate;
+ return this;
+ }
+
+ /**
+ * @param encodedCert The certificate used, encoded via DER. Only used via JNI.
+ */
+ @WrapForJNI(exceptionMode = "nsresult")
+ private void certificateBytes(final @NonNull byte[] encodedCert) {
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ final X509Certificate cert =
+ (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(encodedCert));
+ certificate(cert);
+ } catch (final CertificateException e) {
+ throw new IllegalArgumentException("Unable to parse DER certificate");
+ }
+ }
+
+ /**
+ * Set the HTTP status code, e.g. 200.
+ *
+ * @param code A int representing the HTTP status code.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder statusCode(final int code) {
+ mStatusCode = code;
+ return this;
+ }
+
+ /**
+ * Set whether or not this response was the result of a redirect.
+ *
+ * @param redirected A boolean representing whether or not the request was redirected.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder redirected(final boolean redirected) {
+ mRedirected = redirected;
+ return this;
+ }
+
+ /**
+ * @return A {@link WebResponse} constructed with the values from this Builder instance.
+ */
+ public @NonNull WebResponse build() {
+ return new WebResponse(this);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
new file mode 100644
index 0000000000..10a6eb16cd
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -0,0 +1,1522 @@
+---
+layout: default
+title: API Changelog
+description: GeckoView API Changelog.
+nav_exclude: true
+exclude: true
+---
+
+{% capture javadoc_uri %}{{ site.url }}{{ site.baseurl}}/javadoc/mozilla-central/org/mozilla/geckoview{% endcapture %}
+{% capture bugzilla %}https://bugzilla.mozilla.org/show_bug.cgi?id={% endcapture %}
+
+# GeckoView API Changelog.
+
+⚠️ breaking change and deprecation notices
+
+## v124
+
+- Added [`GeckoRuntimeSettings#setTrustedRecursiveResolverMode`][124.1] to enable DNS-over-HTTPS using different resolver modes ([bug 1591533]({{bugzilla}}1591533)).
+- Added [`GeckoRuntimeSettings#setTrustedRecursiveResolverUri`][124.2] to specify the DNS-over-HTTPS server to be used if DoH is enabled ([bug 1591533]({{bugzilla}}1591533)).
+- Added [`GeckoRuntimeSettings#setLargeKeepaliveFactor`][124.3] to increase the keepalive timeout used for a connection ([bug 1591533]({{bugzilla}}1591533)).
+- Added [`PanZoomController.onDragEvent`][124.4] to support drag and drop.
+ ([bug 1586471]({{bugzilla}}1586471))
+- Added [`WebExtension.MetaData.incognito`][124.5] property. ([bug 1875229]({{bugzilla}}1875229))
+
+[124.1]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setTrustedRecursiveResolverMode-int-
+[124.2]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setTrustedRecursiveResolverUri-java.lang.String-
+[124.3]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setLargeKeepaliveFactor-int-
+[124.4]: {{javadoc_uri}}/PanZoomController.html#onDragEvent(android.view.DragEvent)
+[124.5]: {{javadoc_uri}}/WebExtension.MetaData.html#incognito
+
+## v123
+- For Translations, added [`checkPairDownloadSize`][123.1] and [`TranslationsException.ERROR_MODEL_LANGUAGE_REQUIRED`][123.2] as an error state.
+- ⚠️ Deprecated [`GeckoSession.requestAnalysisCreationStatus`][119.2] by 124, please use [`GeckoSession.requestCreateAnalysis`][122.2] instead.
+- ⚠️ Removed deprecated [`GeckoSession.requestAnalysisCreationStatus`][119.2]
+- Added [`GeckoSession.sendPlacementAttributionEvent`][123.3] for sending placement attribution event for a given product recommendation.
+
+[123.1]: {{javadoc_uri}}/TranslationsController.RuntimeTranslation.html#checkPairDownloadSize(java.lang.String,java.lang.String)
+[123.2]: {{javadoc_uri}}/TranslationsController.TranslationsException.html#ERROR_MODEL_LANGUAGE_REQUIRED
+[121.3]: {{javadoc_uri}}/GeckoSession.html#sendPlacementAttributionEvent(String)
+
+## v122
+- ⚠️ Removed [`onGetNimbusFeature`][115.5], please use `ExperimentDelegate.onGetExperimentFeature` instead.
+- Added [`GeckoSession.reportBackInStock`][122.1] for reporting a Shopping product is back in stock.([bug 1858945]({{bugzilla}}1858945))
+- Added [`GeckoSession.requestCreateAnalysis`][122.2] to return a `AnalysisStatusResponse` that contains a status and a progress field. ([bug 1866112]({{bugzilla}}1866112))
+- Added support for controlling `privacy.globalprivacycontrol.enabled` and `privacy.globalprivacycontrol.pbmode.enabled` and `privacy.globalprivacycontrol.functionality.enabled` via [`GeckoRuntimeSettings.Builder.globalPrivacyControlEnabled`][122.3]
+- Added named translations exceptions via [`TranslationsException`][122.4].
+- Added [`ERROR_UNSUPPORTED_ADDON_TYPE`][122.5] to `WebExtension.InstallException.ErrorCodes`. ([bug 1867873]({{bugzilla}}1867873))
+- Added [`WebExtensionController.install`][122.6] requires `WebExtensionController.InstallationMethod`.
+- Added runtime options to set and get specific "never translate this site" preferences on [`RuntimeTranslation`][121.1].
+- Added APIs for toggling `privacy.trackingprotection.emailtracking.pbmode.enabled`. ([bug 1866927]({{bugzilla}}1866927).
+
+[122.1]: {{javadoc_uri}}/GeckoSession.html#reportBackInStock(String)
+[122.2]: {{javadoc_uri}}/GeckoSession.html#requestCreateAnalysis(String)
+[122.3]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#globalPrivacyControlEnabled(boolean)
+[122.4]: {{javadoc_uri}}/TranslationsController.TranslationsException.html
+[122.5]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_UNSUPPORTED_ADDON_TYPE
+[122.6]: {{javadoc_uri}}/WebExtensionController.WebExtensionController.html#install(java.lang.String,java.lang.String,org.mozilla.geckoview.WebExtensionController.InstallationMethod)
+
+## v121
+- Added runtime controller functions. [`RuntimeTranslation`][121.1] has options for retrieving translation languages and managing language models.
+- Added support for controlling `cookiebanners.service.enableGlobalRules` and `cookiebanners.service.enableGlobalRules.subFrames` via [`GeckoSession.ContentDelegate.cookieBannerGlobalRulesEnabled`][121.2] and [`GeckoSession.ContentDelegate.cookieBannerGlobalRulesSubFramesEnabled`][121.3].
+- Added [`GeckoSession.sendClickAttributionEvent`][121.4] for sending click attribution event for a given product recommendation.
+- Added [`GeckoSession.sendImpressionAttributionEvent`][121.5] for sending impression attribution event for a given product recommendation.
+- Added support for controlling `privacy.query_stripping.enabled` and `privacy.query_stripping.enabled.pbmode` via [`GeckoSession.ContentDelegate.queryParameterStrippingEnabled`][121.6] and [`GeckoSession.ContentDelegate.queryParameterStrippingPrivateBrowsingEnabled`][121.7].
+- Added support for controlling `privacy.query_stripping.allow_list` and `privacy.query_stripping.strip_list` via [`GeckoSession.ContentDelegate.queryParameterStrippingAllowList`][121.8] and [`GeckoSession.ContentDelegate.queryParameterStrippingStripList`][121.9].
+- Add [`WebExtensionController.AddonManagerDelegate.onReady`][121.10] ([bug 1859585]({{bugzilla}}1859585).
+- ⚠️ `WebExtensionController.install` method will not be implicitly awaiting for the installed extension to be fully started anymore, callers of the install method should now expect the `WebExtension.MetaData` properties `baseUrl` and `optionsPageUrl` to be not be
+ defined yet until the `WebExtensionController.AddonManagerDelegate.onReady` delegated method has been called ([bug 1859585]({{bugzilla}}1859585).
+- Added additional support for translation settings such as: `getLanguageSetting`, `setLanguageSetting`, `getNeverTranslateSiteSetting`,`setNeverTranslateSiteSetting`, on the Translations Controller [121.11], and `getTranslationsOfferPopup`, `setTranslationsOfferPopup` on the Runtime Settings [121.12].
+- Added `privacy.trackingprotection.emailtracking.enabled` to strict mode for email tracker blocking in GeckoView. Removed unnecessary string manipulation on STP Pref string. [121.13] ([bug 1856634]({{bugzilla}}1856634).
+
+[121.1]: {{javadoc_uri}}/TranslationsController.RuntimeTranslation.html
+[121.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerGlobalRulesEnabled(boolean)
+[121.3]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerGlobalRulesSubFramesEnabled(boolean)
+[121.4]: {{javadoc_uri}}/GeckoSession.html#sendClickAttributionEvent(String)
+[121.5]: {{javadoc_uri}}/GeckoSession.html#sendImpressionAttributionEvent(String)
+[121.6]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#queryParameterStrippingEnabled(boolean)
+[121.7]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#queryParameterStrippingPrivateBrowsingEnabled(boolean)
+[121.8]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#queryParameterStrippingAllowList(String)
+[121.9]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#queryParameterStrippingStripList(boolean)
+[121.10]: {{javadoc_uri}}/WebExtensionController.AddonManagerDelegate.html#onReady
+[121.11]: {{javadoc_uri}}/TranslationsController.html
+[121.12]: {{javadoc_uri}}/GeckoRuntimeSettings.html
+[121.13]: {{javadoc_uri}}/Contentblocking.AntiTracking.html#EMAIL
+
+## v120
+- Added [`disableExtensionProcessSpawning`][120.1] for disabling the extension process spawning. ([bug 1855405]({{bugzilla}}1855405))
+- Added `DisabledFlags.SIGNATURE` for extensions disabled because they aren't correctly signed. ([bug 1847266]({{bugzilla}}1847266))
+- Added `Builder` pattern constructors for [`ReviewAnalysis`][120.2] and [`Recommendation`][120.3] (part of [bug 1846341]({{bugzilla}}1846341))
+- Added `DisabledFlags.APP_VERSION` for extensions disabled because they aren't compatible with the application version. ([bug 1847266]({{bugzilla}}1847266))
+- Added more metadata to the [WebExtension][120.4] class. ([bug 1850674]({{bugzilla}}1850674), [bug 1858925]({{bugzilla}}1858925))
+- Added session and translations controller. Includes [`TranslationsController`][120.5], [`TranslationsController.SessionTranslation`][120.6] (notably [translate][120.7]), and a [translations delegate][120.8].
+
+[120.1]: {{javadoc_uri}}/WebExtensionController.html#disableExtensionProcessSpawning
+[120.2]: {{javadoc_uri}}/GeckoSession.html#ReviewAnalysis.Builder.html
+[120.3]: {{javadoc_uri}}/GeckoSession.html#Recommendation.Builder.html
+[120.4]: {{javadoc_uri}}/WebExtension.html)
+[120.5]: {{javadoc_uri}}/TranslationsController.html
+[120.6]: {{javadoc_uri}}/TranslationsController.SessionTranslation.html
+[120.7]: {{javadoc_uri}}/TranslationsController.SessionTranslation.html#translate(java.lang.String,java.lang.String,org.mozilla.geckoview.TranslationsController.SessionTranslation.TranslationOptions)
+[120.8]: {{javadoc_uri}}/TranslationsController.SessionTranslation.Delegate.html
+
+## v119
+- Added `remoteType` to GeckoView child crash intent. ([bug 1851518]({{bugzilla}}1851518))
+
+[119.1]: {{javadoc_uri}}/GeckoSession.html#requestCreateAnalysis(String)
+[119.2]: {{javadoc_uri}}/GeckoSession.html#requestAnalysisCreationStatus(String)
+[119.3]: {{javadoc_uri}}/GeckoSession.html#pollForAnalysisCompleted(String)
+
+## v118
+- Added [`ExperimentDelegate`][118.1] to allow GeckoView to send and retrieve experiment information from an embedder.
+- Added [`ERROR_BLOCKLISTED`][118.2] to `WebExtension.InstallException.ErrorCodes`. ([bug 1845745]({{bugzilla}}1845745))
+- Added [`ContentDelegate.onProductUrl`][118.3] to notify the app when on a supported product page.
+- Added [`GeckoSession.requestAnalysis`][118.4] for requesting product review analysis.
+- Added [`GeckoSession.requestRecommendations`][118.5] for requesting product recommendations given a specific product url.
+- Added [`ERROR_INCOMPATIBLE`][118.6] to `WebExtension.InstallException.ErrorCodes`. ([bug 1845749]({{bugzilla}}1845749))
+- Added [`GeckoRuntimeSettings.Builder.extensionsWebAPIEnabled`][118.7]. ([bug 1847173]({{bugzilla}}1847173))
+- Changed [`GeckoSession.AccountSelectorPrompt`][118.8]: added the Provider to which the Account belongs ([bug 1847059]({{bugzilla}}1847059))
+- Added [`getExperimentDelegate`][118.9] and [`setExperimentDelegate`][118.10] to the GeckoSession allow GeckoView to get and set the experiment delegate for the session. Default is to use the runtime delegate.
+- ⚠️ Deprecated [`onGetNimbusFeature`][115.5] by 122, please use `ExperimentDelegate.onGetExperimentFeature` instead.
+- Added [`GeckoRuntimeSettings.Builder.extensionsProcessEnabled`][118.11] for setting whether extensions process is enabled. ([bug 1843926]({{bugzilla}}1843926))
+- Added [`ExtensionProcessDelegate`][118.12] to allow GeckoView to notify disabling of the extension process spawning due to excessive crash/kill. ([bug 1819737]({{bugzilla}}1819737))
+- Added [`enableExtensionProcessSpawning`][118.13] for enabling the extension process spawning
+- Add [`WebExtensionController.AddonManagerDelegate.onInstallationFailed`][118.14] ([bug 1848100]({{bugzilla}}1848100).
+- Add [`InstallException.extensionName`][118.15] which indicates the name of the extension that caused the exception.
+
+[118.1]: {{javadoc_uri}}/ExperimentDelegate.html
+[118.2]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_BLOCKLISTED
+[118.3]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onProductUrl(org.mozilla.geckoview.GeckoSession)
+[118.4]: {{javadoc_uri}}/GeckoSession.html#requestAnalysis(String)
+[118.5]: {{javadoc_uri}}/GeckoSession.html#requestRecommendations(String)
+[118.6]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_INCOMPATIBLE
+[118.7]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#extensionsWebAPIEnabled(boolean)
+[118.8]: {{javadoc_uri}}/GeckoSession.html#AccountSelectorPrompt
+[118.9]: {{javadoc_uri}}/GeckoSession.html#getExperimentDelegate()
+[118.10]: {{javadoc_uri}}/GeckoSession.html#setExperimentDelegate(org.mozilla.geckoview.ExperimentDelegate)
+[118.11]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#extensionsProcessEnabled(Boolean)
+[118.12]: {{javadoc_uri}}/WebExtensionController.ExtensionProcessDelegate.html
+[118.13]: {{javadoc_uri}}/WebExtensionController.html#enableExtensionProcessSpawning
+[118.14]: {{javadoc_uri}}/WebExtensionController.AddonManagerDelegate.html#onInstallationFailed
+[118.15]: {{javadoc_uri}}/WebExtension.InstallException.html#extensionName
+
+## v116
+- Added [`GeckoSession.didPrintPageContent`][116.1] to included extra print status for a standard print and new `GeckoPrintException.ERROR_NO_PRINT_DELEGATE`
+- Added [`PromptInstanceDelegate.onSelectIdentityCredentialProvider`][116.2] to allow the user to choose an Identity Credential provider (FedCM) to be used when authenticating.
+ ([bug 1836356]({{bugzilla}}1836356))
+- Changed [`Gecko.CrashHandler`] location to [`GeckoView.CrashHandler`][116.3] ([bug 1550206]({{bugzilla}}1550206))
+- Added [`PromptInstanceDelegate.onSelectIdentityCredentialAccount`][116.4] to allow the user to choose an account on the Identity Credential Provider (FedCM) they previously chose to be used when authenticating.
+ ([bug 1836363]({{bugzilla}}1836363))
+- Added [`PromptInstanceDelegate.onShowPrivacyPolicyIdentityCredential`][116.5] to allow the user to indicate if agrees or not with the privacy policy of the Identity Credential provider.
+ ([bug 1836358]({{bugzilla}}1836358))
+
+[116.1]: {{javadoc_uri}}/GeckoSession.html#didPrintPageContent
+[116.2]:{{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSelectIdentityCredentialProvider(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt)
+[116.3]:{{javadoc_uri}}/CrashHandler.html
+[116.4]:{{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSelectIdentityCredentialAccount(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt)
+[116.5]:{{javadoc_uri}}/GeckoSession.PromptDelegate.html#onShowPrivacyPolicyIdentityCredential(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt)
+
+## v115
+- Changed [`SessionPdfFileSaver.createResponse`][115.1] to response of saving PDF to accept two additional
+ arguments: `skipConfirmation` and `requestExternalApp`.
+- Added [`GeckoDisplay.NewSurfaceProvider`][115.2] interface, which allows Gecko to request a new rendering Surface from the application.
+ ([bug 1824083]({{bugzilla}}1824083))
+- Add [`onPrintWithStatus`][115.3] to retrieve additional printing status information.
+- Added new [`GeckoPrintException`][115.4] errors of `ERROR_NO_ACTIVITY_CONTEXT` and `ERROR_NO_ACTIVITY_CONTEXT_DELEGATE`
+- Added [`GeckoSession.ContentDelegate.onGetNimbusFeature`][115.5]
+- Added [`textContent`][115.6] to [`ContentDelegate.ContextElement`][65.21] and a new [`constructor`][115.7] to [`ContentDelegate.ContextElement`][65.21]
+- Changed [`SessionPdfFileSaver.createResponse`][115.8] to response of saving PDF to accept an url and return a [`GeckoResult<WebResponse>`].
+- ⚠️ Deprecated [`GeckoSession.PdfSaveResult`][111.7]
+
+[115.1]: {{javadoc_uri}}/SessionPdfFileSaver.html#createResponse(byte[], String, String, boolean, boolean)
+[115.2]: {{javadoc_uri}}/GeckoDisplay.NewSurfaceProvider.html
+[115.3]: {{javadoc_uri}}/GeckoSession.PrintDelegate.html#onPrintWithStatus
+[115.4]: {{javadoc_uri}}/GeckoSession.GeckoPrintException.html
+[115.5]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onGetNimbusFeature(org.mozilla.geckoview.GeckoSession)
+[115.6]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#textContent
+[115.7]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#<init>(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String)
+[115.8]: {{javadoc_uri}}/SessionPdfFileSaver.html#createResponse(GeckoSession, String, String, String, boolean, boolean)
+
+## v114
+- Add [`SessionPdfFileSaver.createResponse`][114.1] to response of saving PDF.
+- Added [`requestExternalApp`][114.2] and [`skipConfirmation`][114.3] with builder fields on a WebResponse to request that a downloaded file be opened in an external application or to skip a confirmation, respectively.
+- ⚠️ Removed deprecated [`CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY`][111.1]
+
+[114.1]: {{javadoc_uri}}/SessionPdfFileSaver.html#createResponse(byte[], String, String)
+[114.2]: {{javadoc_uri}}/WebResponse.html#requestExternalApp
+[114.3]: {{javadoc_uri}}/WebResponse.html#skipConfirmation
+
+## v113
+- Add `DisplayMdoe` annotation to [`displayMode`][113.1], [`getDisplayMode`][113.2] and [`setDisplayMode`][113.3].
+ ([bug 1820567]({{bugzilla}}1820567))
+- Add `UserAgentMode` annotation to [`userAgentMode`][113.4], [`getUserAgentMode`][113.5] and [`setUserAgentMode`][113.6].
+ ([bug 1820567]({{bugzilla}}1820567))
+- Add `ViewportMode` annotation to [`viewportMode`][113.7], [`getViewportMode`][113.8] and [`setViewportMode`][113.9].
+ ([bug 1820567]({{bugzilla}}1820567))
+- Add [`WebExtensionController.AddonManagerDelegate`][113.10] ([bug 1822763]({{bugzilla}}1822763), [bug 1826739]({{bugzilla}}1826739))
+
+[113.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#displayMode(int)
+[113.2]: {{javadoc_uri}}/GeckoSessionSettings.html#getDisplayMode()
+[113.3]: {{javadoc_uri}}/GeckoSessionSettings.html#setDisplayMode(int)
+[113.4]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#userAgentMode(int)
+[113.5]: {{javadoc_uri}}/GeckoSessionSettings.html#getUserAgentMode()
+[113.6]: {{javadoc_uri}}/GeckoSessionSettings.html#setUserAgentMode(int)
+[113.7]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#userViewportMode(int)
+[113.8]: {{javadoc_uri}}/GeckoSessionSettings.html#getViewportMode()
+[113.9]: {{javadoc_uri}}/GeckoSessionSettings.html#setViewportMode(int)
+[113.10]: {{javadoc_uri}}/WebExtensionController.AddonManagerDelegate.html
+
+## v112
+- Added `GeckoSession.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE`, see ([bug 1809269]({{bugzilla}}1809269)).
+- Added [`GeckoSession.hasCookieBannerRuleForBrowsingContextTree`][112.1] to expose Gecko API nsICookieBannerService::hasRuleForBrowsingContextTree see ([bug 1806740]({{bugzilla}}1806740))
+- Removed deprecated [`Autofill.Node.getDimensions`][110.6]
+ ([bug 1815830]({{bugzilla}}1815830))
+
+[112.1]: {{javadoc_uri}}/GeckoSession.html#hasCookieBannerRuleForBrowsingContextTree()
+
+## v111
+
+- Removed deprecated [`SelectionActionDelegate.Selection.clientRect`][111.10], [`BasicSelectionActionDelegate.mTempMatrix`][111.11] and [`BasicSelectionActionDelegate.mTempRect`][111.12], ([bug 1801615]({{bugzilla}}1801615))
+- Added [`GeckoSession.ContentDelegate.cookieBannerHandlingDetectOnlyMode`][111.2] see ([bug 1810742]({{bugzilla}}1810742))
+- ⚠️ Deprecated [`CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY`][111.1]
+- Added [`GeckoView.ActivityContextDelegate`][111.3], `setActivityContextDelegate`, and `getActivityContextDelegate` to `GeckoView`
+- Added [`GeckoSession.PrintDelegate`][111.4], a [`PrintDocumentAdapter`][111.5], getters and setters for the `PrintDelegate`, and [`printPageContent`] to print [`session content`][111.6]
+- Added [`GeckoSession.PdfSaveResult`][111.7], a [`SessionPdfFileSaver`][111.8] and [`isPdfJs`][111.9], see ([bug 1810761]({{bugzilla}}1810761))
+
+[111.1]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html#COOKIE_BANNER_MODE_DETECT_ONLY
+[111.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingDetectOnlyMode(boolean)
+[111.3]: {{javadoc_uri}}/GeckoView.ActivityContextDelegate.html
+[111.4]: {{javadoc_uri}}/GeckoSession.PrintDelegate.html
+[111.5]: {{javadoc_uri}}/GeckoViewPrintDocumentAdapter.html
+[111.6]: {{javadoc_uri}}/GeckoSession.html#printPageContent--
+[111.7]: {{javadoc_uri}}/GeckoSession.PdfSaveResult.html
+[111.8]: {{javadoc_uri}}/SessionPdfFileSaver.html
+[111.9]: {{javadoc_uri}}/GeckoSession.html#isPdfJs--
+[111.10]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#clientRect
+[111.11]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempMatrix
+[111.12]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempRect
+
+## v110
+- Added [`GeckoSession.ContentDelegate.onCookieBannerDetected`][110.1] and [`GeckoSession.ContentDelegate.onCookieBannerHandled`][110.2]
+- Added [`CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY`][110.3], for detecting cookie banners but not handle them, see ([bug 1797581]({{bugzilla}}1806188))
+- Added [`StorageController.setCookieBannerModeAndPersistInPrivateBrowsingForDomain`][110.4] see ([bug 1804747]({{bugzilla}}1804747))
+- Added [`Autofill.Node.getScreenRect`][110.5] for fission compatible.
+- ⚠️ Deprecated [`Autofill.Node.getDimensions`][110.6].
+ ([bug 1803733]({{bugzilla}}1803733))
+- Added [`ColorPrompt.predefinedValues`][110.7] to expose predefined values by [`datalist`][110.8] element in the color prompt.
+ ([bug 1805616]({{bugzilla}}1805616))
+
+[110.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onCookieBannerDetected(org.mozilla.geckoview.GeckoSession)
+[110.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onCookieBannerHandled(org.mozilla.geckoview.GeckoSession)
+[110.3]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html#COOKIE_BANNER_MODE_DETECT_ONLY
+[110.4]: {{javadoc_uri}}/StorageController.html#setCookieBannerModeAndPersistInPrivateBrowsingForDomain(java.lang.String,int)
+[110.5]: {{javadoc_uri}}/Autofill.Node.html#getScreenRect()
+[110.6]: {{javadoc_uri}}/Autofill.Node.html#getDimensions()
+[110.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ColorPrompt.html#predefinedValues
+[110.8]: https://developer.mozilla.org/en/docs/Web/HTML/Element/datalist
+
+## v109
+- Added [`SelectionActionDelegate.Selection.screenRect`][109.1] for fission compatible.
+- ⚠️ Deprecated [`SelectionActionDelegate.Selection.clientRect`][109.2],
+ [`BasicSelectionActionDelegate.mTempMatrix`][109.3] and
+ [`BasicSelectionActionDelegate.mTempRect`][109.4].
+ ([bug 1785759]({{bugzilla}}1785759))
+- Added [`StorageController.setCookieBannerModeForDomain`][109.5], [`StorageController.getCookieBannerModeForDomain`][109.6] and [`StorageController.removeCookieBannerModeForDomain`][109.7] see ([bug 1797581]({{bugzilla}}1797581))
+
+[109.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#screenRect
+[109.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#clientRect
+[109.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempMatrix
+[109.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempRect
+[109.5]: {{javadoc_uri}}/StorageController.html#setCookieBannerModeForDomain(java.lang.String,int,boolean)
+[109.6]: {{javadoc_uri}}/StorageController.html#getCookieBannerModeForDomain(java.lang.String,boolean)
+[109.7]: {{javadoc_uri}}/StorageController.html#removeCookieBannerModeForDomain(java.lang.String,boolean)
+
+## v108
+- Added [`ContentBlocking.CookieBannerMode`][108.1]; [`cookieBannerHandlingMode`][108.2] and [`cookieBannerHandlingModePrivateBrowsing`][108.3] to [`ContentBlocking.Settings.Builder`][81.1];
+ [`getCookieBannerMode`][108.4], [`setCookieBannerMode`][108.5], [`getCookieBannerModePrivateBrowsing`][108.6] and [`setCookieBannerModePrivateBrowsing`][108.7] to [`ContentBlocking.Settings`][81.2]
+ ([bug 1790724]({{bugzilla}}1790724))
+- Added [`GeckoSession.GeckoPrintException`][108.9] to improver error reporting while generating a PDF from website, ([bug 1798402]({{bugzilla}}1798402)).
+- Added [`GeckoSession.containsFormData`][108.10] that returns a `GeckoResult<Boolean>` for whether or not a session has form data, ([bug 1777506]({{bugzilla}}1777506)).
+
+[108.1]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html
+[108.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingMode(int)
+[108.3]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingModePrivateBrowsing(int)
+[108.4]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerMode()
+[108.5]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerMode(int)
+[108.6]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerModePrivateBrowsing()
+[108.7]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerModePrivateBrowsing(int)
+[108.9]: {{javadoc_uri}}/GeckoSession.GeckoPrintException.html
+[108.10]: {{javadoc_uri}}/GeckoSession.html#containsFormData()
+
+## v107
+- Removed deprecated [`cookieLifetime`][103.2]
+- Removed deprecated `setPermission`, see deprecation note in [v90](#v90)
+
+## v106
+- Added [`SelectionActionDelegate.onShowClipboardPermissionRequest`][106.1],
+ [`SelectionActionDelegate.onDismissClipboardPermissionRequest`][106.2],
+ [`BasicSelectionActionDelegate.onShowClipboardPermissionRequest`][106.3],
+ [`BasicSelectionActionDelegate.onDismissCancelClipboardPermissionRequest`][106.4] and
+ [`SelectionActionDelegate.ClipboardPermission`][106.5] to handle permission
+ request for reading clipboard data by [`clipboard.readText`][106.6].
+ ([bug 1776829]({{bugzilla}}1776829))
+
+[106.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission)
+[106.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onDismissClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession)
+[106.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission)
+[106.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onDismissClipboardPermission(org.mozilla.geckoview.GeckoSession)
+[106.5]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.ClipboardPermission.html
+[106.6]: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText
+
+## v104
+- Removed deprecated Autofill.Delegate `onAutofill`, Autofill.Node `fillViewStructure`, `getFocused`, `getId`, `getValue`, `getVisible`, Autofill.NodeData `Autofill.Notify`, Autofill.Session `surfaceChanged`.
+ ([bug 1781180]({{bugzilla}}1781180))
+- Removed deprecated `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5]
+- Removed deprecated [`GeckoSession.autofill`][102.18].
+ ([bug 1781180]({{bugzilla}}1781180))
+- Removed deprecated [`onLocationChange(2)`][102.3]
+ ([bug 1781180]({{bugzilla}}1781180))
+
+## v103
+- Added [`GeckoSession.saveAsPdf`][103.1] that returns a `GeckoResult<InputStream>` that contains a PDF of the current session's page.
+- Added missing `@Deprecated` tag for `setPermission`, see deprecation note in [v90](#v90).
+- ⚠️ Deprecated [`cookieLifetime`][103.2], this feature is not available anymore.
+
+[103.1]: {{javadoc_uri}}/GeckoSession.html#saveAsPdf()
+[103.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieLifetime(int)
+
+## v102
+- Added [`DateTimePrompt.stepValue`][102.1] to export [`step`][102.2] attribute of input element.
+ ([bug 1499635]({{bugzilla}}1499635))
+- Deprecated [`onLocationChange(2)`][102.3], please use [`onLocationChange(3)`][102.4].
+- Added [`GeckoSession.setPriorityHint`][102.5] function to set the session to either high priority or default.
+- [`WebRequestError.ERROR_HTTPS_ONLY`][102.6] now has error category
+ `ERROR_CATEGORY_NETWORK` rather than `ERROR_CATEGORY_SECURITY`.
+- ⚠️ The Autofill.Delegate API now receives a [`AutofillNode`][102.7] object instead of
+ the entire [`Node`][102.8] structure. The `onAutofill` delegate method is now split
+ into several methods: [`onNodeAdd`][102.9], [`onNodeBlur`][102.10],
+ [`onNodeFocus`][102.11], [`onNodeRemove`][102.12], [`onNodeUpdate`][102.13],
+ [`onSessionCancel`][102.14], [`onSessionCommit`][102.15],
+ [`onSessionStart`][102.16].
+- Added [`PromptInstanceDelegate.onPromptUpdate`][102.17] to allow GeckoView to update current prompts.
+ ([bug 1758800]({{bugzilla}}1758800))
+- Deprecated [`GeckoSession.autofill`][102.18], use [`Autofill.Session.autofill`][102.19] instead.
+ ([bug 1770010]({{bugzilla}}1770010))
+- Added [`WebRequestError.ERROR_BAD_HSTS_CERT`][102.20] error code to notify the app of a connection to a site that does not allow error overrides.
+ ([bug 1721220]({{bugzilla}}1721220))
+
+[102.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.DateTimePrompt.html#stepValue
+[102.2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#step
+[102.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[102.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List)
+[102.5]: {{javadoc_uri}}/GeckoSession.html#setPriorityHint(int)
+[102.6]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY
+[102.7]: {{javadoc_uri}}/Autofill.AutofillNode.html
+[102.8]: {{javadoc_uri}}/Autofill.Node.html
+[102.9]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeAdd(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.10]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeBlur(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.11]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeFocus(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.12]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeRemove(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.13]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeUpdate(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.14]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCancel(org.mozilla.geckoview.GeckoSession)
+[102.15]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCommit(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.16]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionStart(org.mozilla.geckoview.GeckoSession)
+[102.17]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html#onPromptUpdate(org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt)
+[102.18]: {{javadoc_uri}}/GeckoSession.html#autofill(android.util.SparseArray)
+[102.19]: {{javadoc_uri}}/Autofill.Session.html#autofill(android.util.SparseArray)
+[102.20]: {{javadoc_uri}}/WebRequestError.html#ERROR_BAD_HSTS_CERT
+
+## v101
+- Added [`GeckoDisplay.surfaceChanged`][101.1] function taking new type [`GeckoDisplay.SurfaceInfo`][101.2].
+ This allows the caller to provide a [`SurfaceControl`][101.3] object, which must be set on SDK level 29 and
+ above when rendering in to a `SurfaceView`.
+ ([bug 1762424]({{bugzilla}}1762424))
+- ⚠️ Deprecated old `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5].
+- Add [`WebExtensionController.optionalPrompt`][101.6] to allow handling of optional permission requests from extensions.
+
+[101.1]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(org.mozilla.geckoview.GeckoDisplay.SurfaceInfo)
+[101.2]: {{javadoc_uri}}/GeckoDisplay.SurfaceInfo.html
+[101.3]: https://developer.android.com/reference/android/view/SurfaceControl
+[101.4]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int)
+[101.5]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int,int,int)
+[101.6]: {{javadoc_uri}}/WebExtensionController.html#optionalPrompt(org.mozilla.geckoview.WebExtension.Message,org.mozilla.geckoview.WebExtension)
+
+## v100
+- ⚠️ Changed [`GeckoSession.isOpen`][100.1] to `@UiThread`.
+- [`WebNotification`][100.2] now implements [`Parcelable`][100.3] to support
+ persisting notifications and responding to them while the browser is not
+ running.
+- Removed deprecated `GeckoRuntime.EXTRA_CRASH_FATAL`
+- Removed deprecated `MediaSource.rawId`
+
+[100.1]: {{javadoc_uri}}/GeckoSession.html#isOpen()
+[100.2]: {{javadoc_uri}}/WebNotification.html
+[100.3]: https://developer.android.com/reference/android/os/Parcelable
+
+## v99
+- Removed deprecated `GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`.
+ ([bug 1754244]({{bugzilla}}1754244))
+
+## v98
+- Add [`WebRequest.beConservative`][98.1] to allow critical infrastructure to
+ avoid using bleeding-edge network features.
+ ([bug 1750231]({{bugzilla}}1750231))
+
+[98.1]: {{javadoc_uri}}/WebRequest.html#beConservative
+
+## v97
+- ⚠️ Deprecated [`MediaSource.rawId`][97.1],
+ which now provides the same string as [`id`][97.2].
+ ([bug 1744346]({{bugzilla}}1744346))
+- Added [`EXTRA_CRASH_PROCESS_TYPE`][97.3] field to `ACTION_CRASHED` intents,
+ and corresponding [`CRASHED_PROCESS_TYPE_*`][97.4] constants, indicating which
+ type of process a crash occured in.
+ ([bug 1743454]({{bugzilla}}1743454))
+- ⚠️ Deprecated [`EXTRA_CRASH_FATAL`][97.5]. Use `EXTRA_CRASH_PROCESS_TYPE` instead.
+ ([bug 1743454]({{bugzilla}}1743454))
+- Added [`OrientationController`][97.6] to allow GeckoView to handle orientation locking.
+ ([bug 1697647]({{bugzilla}}1697647))
+- Added [GeckoSession.goBack][97.7] and [GeckoSession.goForward][97.8] with a
+ `userInteraction` parameter. Updated the default goBack/goForward behaviour
+ to also be considered as a user interaction.
+ ([bug 1644595]({{bugzilla}}1644595))
+
+[97.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#rawId
+[97.2]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#id
+[97.3]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_PROCESS_TYPE
+[97.4]: {{javadoc_uri}}/GeckoRuntime.html#CRASHED_PROCESS_TYPE_MAIN
+[97.5]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_FATAL
+[97.6]: {{javadoc_uri}}/OrientationController.html
+[97.7]: {{javadoc_uri}}/GeckoSession.html#goBack(boolean)
+[97.8]: {{javadoc_uri}}/GeckoSession.html#goForward(boolean)
+
+## v96
+- Added [`onLoginFetch`][96.1] which allows apps to provide all saved logins to
+ GeckoView.
+ ([bug 1733423]({{bugzilla}}1733423))
+- Added [`GeckoResult.finally_`][96.2] to unconditionally run an action after
+ the GeckoResult has been completed.
+ ([bug 1736433]({{bugzilla}}1736433))
+- Added [`ERROR_INVALID_DOMAIN`][96.3] to `WebExtension.InstallException.ErrorCodes`.
+ ([bug 1740634]({{bugzilla}}1740634))
+- Added [`Selection.pasteAsPlainText`][96.4] to paste HTML content as plain
+ text.
+ ([bug 1740414]({{bugzilla}}1740414))
+- Removed deprecated Content Blocking APIs.
+ ([bug 1743706]({{bugzilla}}1743706))
+
+[96.1]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html#onLoginFetch()
+[96.2]: {{javadoc_uri}}/GeckoResult.html#finally_(java.lang.Runnable)
+[96.3]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_INVALID_DOMAIN
+[96.4]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#pasteAsPlainText()
+
+## v95
+- Added [`GeckoSession.ContentDelegate.onPointerIconChange()`][95.1] to notify
+ the application of changing pointer icon. If the application wants to handle
+ pointer icon, it should override this.
+ ([bug 1672609]({{bugzilla}}1672609))
+- Deprecated [`ContentBlockingController`][95.2], use
+ [`StorageController`][95.3] instead. A [`PERMISSION_TRACKING`][95.4]
+ permission is now present in [`onLocationChange`][95.5] for every page load,
+ which can be used to set tracking protection exceptions.
+ ([bug 1714945]({{bugzilla}}1714945))
+- Added [`setPrivateBrowsingPermanentPermission`][95.6], which allows apps to set
+ permanent permissions in private browsing (e.g. to set permanent tracking
+ protection permissions in private browsing).
+ ([bug 1714945]({{bugzilla}}1714945))
+- Deprecated [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7] due to typo.
+ ([bug 1708815]({{bugzilla}}1708815))
+- Added [`GeckoRuntimeSettings.Builder.enterpriseRootsEnabled`][95.8] to replace [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7].
+ ([bug 1708815]({{bugzilla}}1708815))
+- Added [`GeckoSession.ContentDelegate.onPreviewImage`][95.9] to notify
+ the application of a preview image URL.
+ ([bug 1732219]({{bugzilla}}1732219))
+
+[95.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPointerIconChange(org.mozilla.geckoview.GeckoSession,android.view.PointerIcon)
+[95.2]: {{javadoc_uri}}/ContentBlockingController.html
+[95.3]: {{javadoc_uri}}/StorageController.java
+[95.4]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_TRACKING
+[95.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List)
+[95.6]: {{javadoc_uri}}/StorageController.html#setPrivateBrowsingPermanentPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int)
+[95.7]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpiseRootsEnabled(boolean)
+[95.8]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpriseRootsEnabled(boolean)
+[95.9]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPreviewImage(org.mozilla.geckoview.GeckoSession,java.lang.String)
+
+## v94
+- Extended [`Autocomplete`][78.7] API to support credit card saving.
+ ([bug 1703976]({{bugzilla}}1703976))
+
+## v93
+- Removed deprecated [`Autocomplete.LoginStorageDelegate`][78.8].
+ ([bug 1725469]({{bugzilla}}1725469))
+- Removed deprecated [`GeckoRuntime.getProfileDir`][90.5].
+ ([bug 1725469]({{bugzilla}}1725469))
+- Added [`PromptInstanceDelegate`][93.1] to allow GeckoView to dismiss stale prompts.
+ ([bug 1710668]({{bugzilla}}1710668))
+- Added [`WebRequestError.ERROR_HTTPS_ONLY`][93.2] error code to allow GeckoView display custom HTTPS-only error pages and bypass them.
+ ([bug 1697866]({{bugzilla}}1697866))
+
+[93.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html
+[93.2]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY
+
+## v92
+- Added [`PermissionDelegate.PERMISSION_STORAGE_ACCESS`][92.1] to
+ control the allowing of third-party frames to access first-party cookies and
+ storage. ([bug 1543720]({{bugzilla}}1543720))
+- Added [`ContentDelegate.onShowDynamicToolbar`][92.2] to notify
+ the app that it must fully-expand its dynamic toolbar ([bug 1690296]({{bugzilla}}1690296))
+- Removed deprecated `GeckoResult.ALLOW` and `GeckoResult.DENY`.
+ Use [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9] instead.
+
+[92.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_STORAGE_ACCESS
+[92.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onShowDynamicToolbar(org.mozilla.geckoview.GeckoSession)
+
+## v91
+- Extended [`Autocomplete`][78.7] API to support addresses.
+ ([bug 1699794]({{bugzilla}}1699794)).
+- Added [`clearDataFromBaseDomain`][91.1] to [`StorageController`][90.2] for
+ clearing site data by base domain. This includes data of associated subdomains
+ and data partitioned via [`State Partitioning`][91.3].
+- Removed deprecated `MediaElement` API.
+
+[91.1]: {{javadoc_uri}}/StorageController.html#clearDataFromBaseDomain(java.lang.String,long)
+[91.2]: {{javadoc_uri}}/StorageController.html
+[91.3]: https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning
+
+## v90
+- Added [`WebNotification.silent`][90.1] and [`WebNotification.vibrate`][90.2]
+ support. See also [Web/API/Notification/silent][90.3] and
+ [Web/API/Notification/vibrate][90.4].
+ ([bug 1696145]({{bugzilla}}1696145))
+- ⚠️ Deprecated [`GeckoRuntime.getProfileDir`][90.5], the API is being kept for
+ compatibility but it always returns null.
+- Added [`forceEnableAccessibility`][90.6] runtime setting to enable
+ accessibility during testing.
+ ([bug 1701269]({{bugzilla}}1701269))
+- Removed deprecated [`GeckoView.onTouchEventForResult`][88.4].
+ ([bug 1706403]({{bugzilla}}1706403))
+- ⚠️ Updated [`onContentPermissionRequest`][90.7] to use [`ContentPermission`][90.8]; added
+ [`setPermission`][90.9] to [`StorageController`][90.10] for modifying existing permissions, and
+ allowed Gecko to handle persisting permissions.
+- ⚠️ Added a deprecation schedule to most existing content blocking exception functionality;
+ other than [`addException`][90.11], content blocking exceptions should be treated as content
+ permissions going forward.
+
+[90.1]: {{javadoc_uri}}/WebNotification.html#silent
+[90.2]: {{javadoc_uri}}/WebNotification.html#vibrate
+[90.3]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent
+[90.4]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate
+[90.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfileDir()
+[90.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setForceEnableAccessibility(boolean)
+[90.7]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission)
+[90.8]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html
+[90.9]: {{javadoc_uri}}/StorageController.html#setPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int)
+[90.10]: {{javadoc_uri}}/StorageController.html
+[90.11]: {{javadoc_uri}}/ContentBlockingController.html#addException(org.mozilla.geckoview.GeckoSession)
+
+## v89
+- Added [`ContentPermission`][89.1], which is used to report what permissions content
+ is loaded with in `onLocationChange`.
+- Added [`StorageController.getPermissions`][89.2] and [`StorageController.getAllPermissions`][89.3],
+ allowing inspection of what permissions have been set for a given URI and for all URIs.
+- ⚠️ Deprecated [`NavigationDelegate.onLocationChange`][89.4], to be removed in v92. The
+ new `onLocationChange` callback simply adds permissions information, migration of existing
+ functionality should only require updating the function signature.
+- Added [`GeckoRuntimeSettings.setEnterpriseRootsEnabled`][89.5] which allows
+ GeckoView to add third party certificate roots from the Android OS CA store.
+ ([bug 1678191]({{bugzilla}}1678191)).
+- ⚠️ [`GeckoSession.load`][89.6] now throws `IllegalArgumentException` if the
+ session has no [`GeckoSession.NavigationDelegate`][89.7] and the request's `data` URI is too long.
+ If a `GeckoSession` *does* have a `GeckoSession.NavigationDelegate` and `GeckoSession.load` is called
+ with a top-level `data` URI that is too long, [`NavigationDelgate.onLoadError`][89.8] will be called
+ with a [`WebRequestError`][89.9] containing error code [`WebRequestError.ERROR_DATA_URI_TOO_LONG`][89.10].
+ ([bug 1668952]({{bugzilla}}1668952))
+- Extended [`Autocomplete`][78.7] API to support credit cards.
+ ([bug 1691819]({{bugzilla}}1691819)).
+- ⚠️ Deprecated [`Autocomplete.LoginStorageDelegate`][78.8] with the intention
+ of removing it in GeckoView v93. Please use
+ [`Autocomplete.StorageDelegate`][89.11] instead.
+ ([bug 1691819]({{bugzilla}}1691819)).
+- Added [`ALLOWED_TRACKING_CONTENT`][89.12] to content blocking API to indicate
+ when unsafe content is allowed by a shim.
+ ([bug 1661330]({{bugzilla}}1661330))
+- ⚠️ Added [`setCookieBehaviorPrivateMode`][89.13] to control cookie behavior for private browsing
+ mode independently of normal browsing mode. To maintain current behavior, set this to the same
+ value as [`setCookieBehavior`][89.14] is set to.
+
+[89.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html
+[89.2]: {{javadoc_uri}}/StorageController.html#getPermissions(java.lang.String)
+[89.3]: {{javadoc_uri}}/StorageController.html#getAllPermissions()
+[89.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[89.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setEnterpriseRootsEnabled(boolean)
+[89.6]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader)
+[89.7]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html
+[89.8]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadError(org.mozilla.geckoview.GeckoSession,java.lang.String,org.mozilla.geckoview.WebRequestError)
+[89.9]: {{javadoc_uri}}/WebRequestError.html
+[89.10]: {{javadoc_uri}}/WebRequestError.html#ERROR_DATA_URI_TOO_LONG
+[89.11]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html
+[89.12]: {{javadoc_uri}}/ContentBlockingController.Event.html#ALLOWED_TRACKING_CONTENT
+[89.13]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehaviorPrivateMode(int)
+[89.14]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehavior(int)
+
+## v88
+- Added [`WebExtension.Download#update`][88.1] that can be used to
+ implement the WebExtension `downloads` API. This method is used to communicate
+ updates in the download status to the Web Extension
+- Added [`PanZoomController.onTouchEventForDetailResult`][88.2] and
+ [`GeckoView.onTouchEventForDetailResult`][88.3] to tell information
+ that the website doesn't expect browser apps to react the event,
+ also and deprecated [`PanZoomController.onTouchEventForResult`][88.4]
+ and [`GeckoView.onTouchEventForResult`][88.5]. With these new methods
+ browser apps can differentiate cases where the browser can do something
+ the browser's specific behavior in response to the event (e.g.
+ pull-to-refresh) and cases where the browser should not react to the event
+ because the event was consumed in the web site (e.g. in canvas like
+ web apps).
+ ([bug 1678505]({{bugzilla}}1678505)).
+- ⚠️ Deprecate the [`MediaElement`][65.11] API to be removed in v91.
+ Please use [`MediaSession`][81.6] for media events and control.
+ ([bug 1693584]({{bugzilla}}1693584)).
+- ⚠️ Deprecate [`GeckoResult.ALLOW`][89.6] and [`GeckoResult.DENY`][89.7] in
+ favor of [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9].
+ ([bug 1697270]({{bugzilla}}1697270)).
+- ⚠️ Update [`SessionState`][88.10] to handle null states/strings more gracefully.
+ ([bug 1685486]({{bugzilla}}1685486)).
+
+[88.1]: {{javadoc_uri}}/WebExtension.Download.html#update(org.mozilla.geckoview.WebExtension.Download.Info)
+[88.2]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForDetailResult
+[88.3]: {{javadoc_uri}}/GeckoView.html#onTouchEventForDetailResult
+[88.4]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForResult
+[88.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult
+[88.6]: {{javadoc_uri}}/GeckoResult.html#ALLOW
+[88.7]: {{javadoc_uri}}/GeckoResult.html#DENY
+[88.8]: {{javadoc_uri}}/GeckoResult.html#allow()
+[88.9]: {{javadoc_uri}}/GeckoResult.html#deny()
+[88.10]: {{javadoc_uri}}/GeckoSession.SessionState.html
+
+## v87
+- ⚠️ Added [`WebExtension.DownloadInitData`][87.1] class that can be used to
+ implement the WebExtension `downloads` API. This class represents initial state of a download.
+- Added [`WebExtension.Download.Info`][87.2] interface that can be used to
+ implement the WebExtension `downloads` API. This interface allows communicating
+ download's state to Web Extension.
+- [`Image#getBitmap`][87.3] now throws [`ImageProcessingException`][87.4] if
+ the image cannot be processed.
+ ([bug 1689745]({{bugzilla}}1689745))
+- Added support for HTTPS-only mode to [`GeckoRuntimeSettings`][87.5] via
+ [`setAllowInsecureConnections`][87.6].
+- Removed `JSONException` throws from [`SessionState.fromString`][87.7], fixed annotations,
+ and clarified null-handling a bit.
+
+[87.1]: {{javadoc_uri}}/WebExtension.DownloadInitData.html
+[87.2]: {{javadoc_uri}}/WebExtension.Download.Info.html
+[87.3]: {{javadoc_uri}}/Image.html#getBitmap(int)
+[87.4]: {{javadoc_uri}}/Image.ImageProcessingException.html
+[87.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html
+[87.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAllowInsecureConnections(int)
+[87.7]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String)
+
+## v86
+- Removed deprecated `ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`.
+ Use [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] instead.
+ ([bug 1665157]({{bugzilla}}1665157))
+- Added [`WebExtension.DownloadDelegate`][86.1] and that can be used to
+ implement the WebExtension `downloads` API.
+ ([bug 1656336]({{bugzilla}}1656336))
+- Added [`WebRequest.Builder#body(@Nullable String)`][86.2] which converts a string to direct byte buffer.
+- Removed deprecated `REPLACED_UNSAFE_CONTENT`.
+ ([bug 1667471]({{bugzilla}}1667471))
+- Removed deprecated [`GeckoSession#loadUri`][83.6] variants in favor of
+ [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8].
+ ([bug 1667471]({{bugzilla}}1667471))
+- Added [`GeckoResult#map`][86.3] to synchronously map a GeckoResult value.
+- Added [`PanZoomController#INPUT_RESULT_IGNORED`][86.4].
+ ([bug 1687430]({{bugzilla}}1687430))
+
+[86.1]: {{javadoc_uri}}/WebExtension.DownloadDelegate.html
+[86.2]: {{javadoc_uri}}/WebRequest.Builder#body(java.lang.String)
+[86.3]: {{javadoc_uri}}/GeckoResult.html#map(org.mozilla.geckoview.GeckoResult.OnValueMapper)
+[86.4]: {{javadoc_uri}}/PanZoomController.html#INPUT_RESULT_IGNORED
+
+## v85
+- Added [`WebExtension.BrowsingDataDelegate`][85.1] that can be used to
+ implement the WebExtension `browsingData` API.
+
+[85.1]: {{javadoc_uri}}/WebExtension.BrowsingDataDelegate.html
+
+## v84
+- ⚠️ Removed deprecated `GeckoRuntimeSettings.Builder.useMultiprocess` and
+ [`GeckoRuntimeSettings.getUseMultiprocess`]. Single-process GeckoView is no
+ longer supported. ([bug 1650118]({{bugzilla}}1650118))
+- Deprecated members now have an additional [`@DeprecationSchedule`][84.1] annotation which
+ includes the `version` that we expect to remove the member and an `id` that
+ can be used to group annotation notices in tooling.
+ ([bug 1671460]({{bugzilla}}1671460))
+- ⚠️ Removed deprecated `ContentBlockingController.ExceptionList` and
+ `ContentBlockingController.restoreExceptionList`. ([bug 1674500]({{bugzilla}}1674500))
+
+[84.1]: {{javadoc_uri}}/DeprecationSchedule.html
+
+## v83
+- Added [`WebExtension.MetaData.temporary`][83.1] which exposes whether an extension
+ has been installed temporarily, e.g. when using web-ext.
+ ([bug 1624410]({{bugzilla}}1624410))
+- ⚠️ Removing unsupported `MediaSession.Delegate.onPictureInPicture` for now.
+ Also, [`MediaSession.Delegate.onMetadata`][83.2] is no longer dispatched for
+ plain media elements.
+ ([bug 1658937]({{bugzilla}}1658937))
+- Replaced android.util.ArrayMap with java.util.TreeMap in [`WebMessage`][65.13] to enable case-insensitive handling of the HTTP headers.
+ ([bug 1666013]({{bugzilla}}1666013))
+- Added [`ContentBlocking.SafeBrowsingProvider`][83.3] to configure Safe
+ Browsing providers.
+ ([bug 1660241]({{bugzilla}}1660241))
+- Added [`GeckoRuntime.ActivityDelegate`][83.4] which allows applications to handle
+ starting external Activities on behalf of GeckoView. Currently this is used to integrate
+ FIDO support for WebAuthn.
+- Added [`GeckoWebExecutor#FETCH_FLAG_PRIVATE`][83.5]. This new flag allows for private browsing downloads using WebExecutor.
+ ([bug 1665426]({{bugzilla}}1665426))
+- ⚠️ Deprecated [`GeckoSession#loadUri`][83.6] variants in favor of
+ [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8].
+ ([bug 1667471]({{bugzilla}}1667471))
+- Added [`Loader#headerFilter`][83.9] to override the default header filtering
+ behavior.
+ ([bug 1667471]({{bugzilla}}1667471))
+
+[83.1]: {{javadoc_uri}}/WebExtension.MetaData.html#temporary
+[83.2]: {{javadoc_uri}}/MediaSession.Delegate.html#onMetadata(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.MediaSession,org.mozilla.geckoview.MediaSession.Metadata)
+[83.3]: {{javadoc_uri}}/ContentBlocking.SafeBrowsingProvider.html
+[83.4]: {{javadoc_uri}}/GeckoRuntime.ActivityDelegate.html
+[83.5]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAG_PRIVATE
+[83.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int,java.util.Map)
+[83.7]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader)
+[83.8]: {{javadoc_uri}}/GeckoSession.Loader.html
+[83.9]: {{javadoc_uri}}/GeckoSession.Loader.html#headerFilter(int)
+
+## v82
+- ⚠️ [`WebNotification.source`][79.2] is now `@Nullable` to account for
+ WebExtension notifications which don't have a `source` field.
+- ⚠️ Deprecated [`ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`][82.1] with the intention of removing
+ them in GeckoView v85.
+ ([bug 1530022]({{bugzilla}}1530022))
+- Added [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] to eliminate the need
+ to make a second request for downloads and ensure more efficient and reliable downloads in a single request. The second
+ parameter is now a [`WebResponse`][65.15]
+ ([bug 1530022]({{bugzilla}}1530022))
+- Added [`Image`][82.3] support for size-dependent bitmap retrieval from image resources.
+ ([bug 1658456]({{bugzilla}}1658456))
+- ⚠️ Use [`Image`][82.3] for [`MediaSession`][81.6] artwork and [`WebExtension`][69.5] icon support.
+ ([bug 1662508]({{bugzilla}}1662508))
+- Added [`RepostConfirmPrompt`][82.4] to prompt the user for cofirmation before
+ resending POST requests.
+ ([bug 1659073]({{bugzilla}}1659073))
+- Removed `Parcelable` support in `GeckoSession`. Use [`ProgressDelegate#onSessionStateChange`][68.29] and [`ProgressDelegate#restoreState`][82.5] instead.
+ ([bug 1650108]({{bugzilla}}1650108))
+- ⚠️ Use AndroidX instead of the Android support library. For the public API this only changes
+ the thread and nullable annotation types.
+- Added [`REPLACED_TRACKING_CONTENT`][82.6] to content blocking API to indicate when unsafe content is shimmed.
+ ([bug 1663756]({{bugzilla}}1663756))
+
+[82.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.WebResponseInfo)
+[82.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoResult)
+[82.3]: {{javadoc_uri}}/Image.html
+[82.4]: {{javadoc_uri}}/GeckoSession.PromptDelegate.RepostConfirmPrompt.html
+[82.5]: {{javadoc_uri}}/GeckoSession.html#restoreState(org.mozilla.geckoview.GeckoSession.SessionState)
+[82.6]: {{javadoc_uri}}/ContentBlockingController.Event.html#REPLACED_TRACKING_CONTENT
+
+## v81
+- Added `cookiePurging` to [`ContentBlocking.Settings.Builder`][81.1] and `getCookiePurging` and `setCookiePurging`
+ to [`ContentBlocking.Settings`][81.2].
+- Added [`GeckoSession.ContentDelegate.onPaintStatusReset()`][81.3] callback which notifies when valid content is no longer being rendered.
+- Made [`GeckoSession.ContentDelegate.onFirstContentfulPaint()`][81.4] additionally be called for the first contentful paint following a `onPaintStatusReset()` event, rather than just the first contentful paint of the session.
+- Removed deprecated `GeckoRuntime.registerWebExtension`. Use [`WebExtensionController.install`][73.1] instead.
+⚠️ - Changed [`GeckoView.onTouchEventForResult`][81.5] to return a `GeckoResult`, as it now
+makes a round-trip to Gecko. The result will be more accurate now, since how content treats
+the event is now considered.
+- Added [`MediaSession`][81.6] API for session-based media events and control.
+
+[81.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html
+[81.2]: {{javadoc_uri}}/ContentBlocking.Settings.html
+[81.3]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPaintStatusReset(org.mozilla.geckoview.GeckoSession)
+[81.4]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession)
+[81.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent)
+[81.6]: {{javadoc_uri}}/MediaSession.html
+
+## v80
+- Removed `GeckoSession.hashCode` and `GeckoSession.equals` overrides in favor
+ of the default implementations. ([bug 1647883]({{bugzilla}}1647883))
+- Added `strictSocialTrackingProtection` to [`ContentBlocking.Settings.Builder`][80.1] and `getStrictSocialTrackingProtection`
+ to [`ContentBlocking.Settings`][80.2].
+
+[80.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html
+[80.2]: {{javadoc_uri}}/ContentBlocking.Settings.html
+
+## v79
+- Added `runtime.openOptionsPage` support. For `options_ui.open_in_new_tab ==
+ false`, [`TabDelegate.onOpenOptionsPage`][79.1] is called.
+ ([bug 1618058]({{bugzilla}}1619766))
+- Added [`WebNotification.source`][79.2], which is the URL of the page
+ or Service Worker that created the notification.
+- Removed deprecated `WebExtensionController.setTabDelegate` and `WebExtensionController.getTabDelegate`
+ APIs ([bug 1618987]({{bugzilla}}1618987)).
+- ⚠️ [`RuntimeTelemetry#getSnapshots`][68.10] is removed after deprecation.
+ Use Glean to handle Gecko telemetry.
+ ([bug 1644447]({{bugzilla}}1644447))
+- Added [`ensureBuiltIn`][79.3] that ensures that a built-in extension is
+ installed without re-installing.
+ ([bug 1635564]({{bugzilla}}1635564))
+- Added [`ProfilerController`][79.4], accessible via [`GeckoRuntime.getProfilerController`][79.5]
+to allow adding gecko profiler markers.
+([bug 1624993]({{bugzilla}}1624993))
+- ⚠️ Deprecated `Parcelable` support in `GeckoSession` with the intention of removing
+ in GeckoView v82. ([bug 1649529]({{bugzilla}}1649529))
+- ⚠️ Deprecated [`GeckoRuntimeSettings.Builder.useMultiprocess`][79.6] and
+ [`GeckoRuntimeSettings.getUseMultiprocess`][79.7] with the intention of removing
+ them in GeckoView v82. ([bug 1649530]({{bugzilla}}1649530))
+
+[79.1]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onOpenOptionsPage(org.mozilla.geckoview.WebExtension)
+[79.2]: {{javadoc_uri}}/WebNotification.html#source
+[79.3]: {{javadoc_uri}}/WebExtensionController.html#ensureBuiltIn(java.lang.String,java.lang.String)
+[79.4]: {{javadoc_uri}}/ProfilerController.html
+[79.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfilerController()
+[79.6]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean)
+[79.7]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getUseMultiprocess()
+
+## v78
+- Added [`WebExtensionController.installBuiltIn`][78.1] that allows installing an
+ extension that is bundled with the APK. This method is meant as a replacement
+ for [`GeckoRuntime.registerWebExtension`][67.15], ⚠️ which is now deprecated
+ and will be removed in GeckoView 81.
+- Added [`CookieBehavior.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS`][78.2] to allow
+ enabling dynamic first party isolation; this will block tracking cookies and
+ isolate all other third party cookies by keying them based on the first party
+ from which they are accessed.
+- Added `cookieStoreId` field to [`WebExtension.CreateTabDetails`][78.3]. This adds the optional
+ ability to create a tab with a given cookie store ID for its [`contextual identity`][78.4].
+ ([bug 1622500]({{bugzilla}}1622500))
+- Added [`NavigationDelegate.onSubframeLoadRequest`][78.5] to allow intercepting
+ non-top-level navigations.
+- Added [`BeforeUnloadPrompt`][78.6] to respond to prompts from onbeforeunload.
+- ⚠️ Refactored `LoginStorage` to the [`Autocomplete`][78.7] API to support
+ login form autocomplete delegation.
+ Refactored `LoginStorage.Delegate` to [`Autocomplete.LoginStorageDelegate`][78.8].
+ Refactored `GeckoSession.PromptDelegate.onLoginStoragePrompt` to
+ [`GeckoSession.PromptDelegate.onLoginSave`][78.9].
+ Added [`GeckoSession.PromptDelegate.onLoginSelect`][78.10].
+ ([bug 1618058]({{bugzilla}}1618058))
+- Added [`GeckoRuntimeSettings#setLoginAutofillEnabled`][78.11] to control
+ whether login forms should be automatically filled in suitable situations.
+
+[78.1]: {{javadoc_uri}}/WebExtensionController.html#installBuiltIn(java.lang.String)
+[78.2]: {{javadoc_uri}}/ContentBlocking.CookieBehavior.html#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS
+[78.3]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html
+[78.4]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities
+[78.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onSubframeLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest)
+[78.6]: {{javadoc_uri}}/GeckoSession.PromptDelegate.BeforeUnloadPrompt.html
+[78.7]: {{javadoc_uri}}/Autocomplete.html
+[78.8]: {{javadoc_uri}}/Autocomplete.LoginStorageDelegate.html
+[78.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSave(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest)
+[78.10]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSelect(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest)
+[78.11]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setLoginAutofillEnabled(boolean)
+
+## v77
+- Added [`GeckoRuntime.appendAppNotesToCrashReport`][77.1] For adding app notes to the crash report.
+ ([bug 1626979]({{bugzilla}}1626979))
+- ⚠️ Remove the `DynamicToolbarAnimator` API along with accesors on `GeckoView` and `GeckoSession`.
+ ([bug 1627716]({{bugzilla}}1627716))
+
+[77.1]: {{javadoc_uri}}/GeckoRuntime.html#appendAppNotesToCrashReport(java.lang.String)
+
+## v76
+- Added [`GeckoSession.PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS`][76.1] to control EME media key access.
+- [`RuntimeTelemetry#getSnapshots`][68.10] is deprecated and will be removed
+ in 79. Use Glean to handle Gecko telemetry.
+ ([bug 1620395]({{bugzilla}}1620395))
+- Added `LoadRequest.isDirectNavigation` to know when calls to
+ [`onLoadRequest`][76.3] originate from a direct navigation made by the app
+ itself.
+ ([bug 1624675]({{bugzilla}}1624675))
+
+[76.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_MEDIA_KEY_SYSTEM_ACCESS
+[76.2]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isDirectNavigation
+[76.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest)
+
+## v75
+- ⚠️ Remove `GeckoRuntimeSettings.Builder#useContentProcessHint`. The content
+ process is now preloaded by default if
+ [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1] is enabled.
+- ⚠️ Move `GeckoSessionSettings.Builder#useMultiprocess` to
+ [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1]. Multiprocess state is
+ no longer determined per session.
+- Added [`DebuggerDelegate#onExtensionListUpdated`][75.2] to notify that a temporary
+ extension has been installed by the debugger.
+ ([bug 1614295]({{bugzilla}}1614295))
+- ⚠️ Removed [`GeckoRuntimeSettings.setAutoplayDefault`][75.3], use
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13] to
+ control autoplay.
+ ([bug 1614894]({{bugzilla}}1614894))
+- Added [`GeckoSession.reload(int flags)`][75.4] That takes a [load flag][75.5] parameter.
+- ⚠️ Moved [`ActionDelegate`][75.6] and [`MessageDelegate`][75.7] to
+ [`SessionController`][75.8].
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`SessionTabDelegate`][75.9] to [`SessionController`][75.8] and
+ [`TabDelegate`][75.10] to [`WebExtension`][69.5] which receive respectively
+ calls for the session and the runtime. `TabDelegate` is also now
+ per-`WebExtension` object instead of being global. The existing global
+ [`TabDelegate`][75.11] is now deprecated and will be removed in GeckoView 77.
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`SessionTabDelegate#onUpdateTab`][75.12] which is called whenever an
+ extension calls `tabs.update` on the corresponding `GeckoSession`.
+ [`TabDelegate#onCreateTab`][75.13] now takes a [`CreateTabDetails`][75.14]
+ object which contains additional information about the newly created tab
+ (including the `url` which used to be passed in directly).
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`GeckoRuntimeSettings.setWebManifestEnabled`][75.15],
+ [`GeckoRuntimeSettings.webManifest`][75.16], and
+ [`GeckoRuntimeSettings.getWebManifestEnabled`][75.17]
+ ([bug 1614894]({{bugzilla}}1603673)), to enable or check Web Manifest support.
+- Added [`GeckoDisplay.safeAreaInsetsChanged`][75.18] to notify the content of [safe area insets][75.19].
+ ([bug 1503656]({{bugzilla}}1503656))
+- Added [`GeckoResult#cancel()`][75.22], [`GeckoResult#setCancellationDelegate()`][75.22],
+ and [`GeckoResult.CancellationDelegate`][75.23]. This adds the optional ability to cancel
+ an operation behind a pending `GeckoResult`.
+- Added [`baseUrl`][75.24] to [`WebExtension.MetaData`][75.25] to expose the
+ base URL for all WebExtension pages for a given extension.
+ ([bug 1560048]({{bugzilla}}1560048))
+- Added [`allowedInPrivateBrowsing`][75.26] and
+ [`setAllowedInPrivateBrowsing`][75.27] to control whether an extension can
+ run in private browsing or not. Extensions installed with
+ [`registerWebExtension`][67.15] will always be allowed to run in private
+ browsing.
+ ([bug 1599139]({{bugzilla}}1599139))
+
+[75.1]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean)
+[75.2]: {{javadoc_uri}}/WebExtensionController.DebuggerDelegate.html#onExtensionListUpdated()
+[75.3]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#autoplayDefault(boolean)
+[75.4]: {{javadoc_uri}}/GeckoSession.html#reload(int)
+[75.5]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_NONE
+[75.6]: {{javadoc_uri}}/WebExtension.ActionDelegate.html
+[75.7]: {{javadoc_uri}}/WebExtension.MessageDelegate.html
+[75.8]: {{javadoc_uri}}/WebExtension.SessionController.html
+[75.9]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html
+[75.10]: {{javadoc_uri}}/WebExtension.TabDelegate.html
+[75.11]: {{javadoc_uri}}/WebExtensionRuntime.TabDelegate.html
+[75.12]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html#onUpdateTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.WebExtension.UpdateTabDetails)
+[75.13]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onNewTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.CreateTabDetails)
+[75.14]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html
+[75.15]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#setWebManifestEnabled(boolean)
+[75.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#webManifest(boolean)
+[75.17]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#getWebManifestEnabled()
+[75.18]: {{javadoc_uri}}/GeckoDisplay.html#safeAreaInsetsChanged(int,int,int,int)
+[75.19]: https://developer.mozilla.org/en-US/docs/Web/CSS/env
+[75.20]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_POSTPONED
+[75.21]: {{javadoc_uri}}/GeckoResult.html#cancel()
+[75.22]: {{javadoc_uri}}/GeckoResult.html#setCancellationDelegate(CancellationDelegate)
+[75.23]: {{javadoc_uri}}/GeckoResult.CancellationDelegate.html
+[75.24]: {{javadoc_uri}}/WebExtension.MetaData.html#baseUrl
+[75.25]: {{javadoc_uri}}/WebExtension.MetaData.html
+[75.26]: {{javadoc_uri}}/WebExtension.MetaData.html#allowedInPrivateBrowsing
+[75.27]: {{javadoc_uri}}/WebExtensionController.html#setAllowedInPrivateBrowsing(org.mozilla.geckoview.WebExtension,boolean)
+
+## v74
+- Added [`WebExtensionController.enable`][74.1] and [`disable`][74.2] to
+ enable and disable extensions.
+ ([bug 1599585]({{bugzilla}}1599585))
+- ⚠️ Added [`GeckoSession.ProgressDelegate.SecurityInformation#certificate`][74.3], which is the
+ full server certificate in use, if any. The other certificate-related fields were removed.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebResponse#isSecure`][74.4], which indicates whether or not the response was
+ delivered over a secure connection.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebResponse#certificate`][74.5], which is the server certificate used for the
+ response, if any.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebRequestError#certificate`][74.6], which is the server certificate used in the
+ failed request, if any.
+ ([bug 1508730]({{bugzilla}}1508730))
+- ⚠️ Updated [`ContentBlockingController`][74.7] to use new representation for content blocking
+ exceptions and to add better support for removing exceptions. This deprecates [`ExceptionList`][74.8]
+ and [`restoreExceptionList`][74.9] with the intent to remove them in 76.
+ ([bug 1587552]({{bugzilla}}1587552))
+- Added [`GeckoSession.ContentDelegate.onMetaViewportFitChange`][74.10]. This exposes `viewport-fit` value that is CSS Round Display Level 1. ([bug 1574307]({{bugzilla}}1574307))
+- Extended [`LoginStorage.Delegate`][74.11] with [`onLoginUsed`][74.12] to
+ report when existing login entries are used for autofill.
+ ([bug 1610353]({{bugzilla}}1610353))
+- Added [`WebExtensionController#setTabActive`][74.13], which is used to notify extensions about
+ tab changes
+ ([bug 1597793]({{bugzilla}}1597793))
+- Added [`WebExtension.metaData.optionsUrl`][74.14] and [`WebExtension.metaData.openOptionsPageInTab`][74.15],
+ which is the addon metadata necessary to show their option pages.
+ ([bug 1598792]({{bugzilla}}1598792))
+- Added [`WebExtensionController.update`][74.16] to update extensions. ([bug 1599581]({{bugzilla}}1599581))
+- ⚠️ Replaced `subscription` argument in [`WebPushDelegate.onSubscriptionChanged`][74.17] from a [`WebPushSubscription`][74.18] to the [`String`][74.19] `scope`.
+
+[74.1]: {{javadoc_uri}}/WebExtensionController.html#enable(org.mozilla.geckoview.WebExtension,int)
+[74.2]: {{javadoc_uri}}/WebExtensionController.html#disable(org.mozilla.geckoview.WebExtension,int)
+[74.3]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html#certificate
+[74.4]: {{javadoc_uri}}/WebResponse.html#isSecure
+[74.5]: {{javadoc_uri}}/WebResponse.html#certificate
+[74.6]: {{javadoc_uri}}/WebRequestError.html#certificate
+[74.7]: {{javadoc_uri}}/ContentBlockingController.html
+[74.8]: {{javadoc_uri}}/ContentBlockingController.ExceptionList.html
+[74.9]: {{javadoc_uri}}/ContentBlockingController.html#restoreExceptionList(org.mozilla.geckoview.ContentBlockingController.ExceptionList)
+[74.10]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onMetaViewportFitChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[74.11]: {{javadoc_uri}}/LoginStorage.Delegate.html
+[74.12]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginUsed(org.mozilla.geckoview.LoginStorage.LoginEntry,int)
+[74.13]: {{javadoc_uri}}/WebExtensionController.html#setTabActive
+[74.14]: {{javadoc_uri}}/WebExtension.MetaData.html#optionsUrl
+[74.15]: {{javadoc_uri}}/WebExtension.MetaData.html#openOptionsPageInTab
+[74.16]: {{javadoc_uri}}/WebExtensionController.html#update(org.mozilla.geckoview.WebExtension,int)
+[74.17]: {{javadoc_uri}}/WebPushController.html#onSubscriptionChange(org.mozilla.geckoview.WebPushSubscription,byte[])
+[74.18]: {{javadoc_uri}}/WebPushSubscription.html
+[74.19]: https://developer.android.com/reference/java/lang/String
+
+## v73
+- Added [`WebExtensionController.install`][73.1] and [`uninstall`][73.2] to
+ manage installed extensions
+- ⚠️ Renamed `ScreenLength.VIEWPORT_WIDTH`, `ScreenLength.VIEWPORT_HEIGHT`,
+ `ScreenLength.fromViewportWidth` and `ScreenLength.fromViewportHeight` to
+ [`ScreenLength.VISUAL_VIEWPORT_WIDTH`][73.3],
+ [`ScreenLength.VISUAL_VIEWPORT_HEIGHT`][73.4],
+ [`ScreenLength.fromVisualViewportWidth`][73.5] and
+ [`ScreenLength.fromVisualViewportHeight`][73.6] respectively.
+- Added the [`LoginStorage`][73.7] API. Apps may handle login fetch requests now by
+ attaching a [`LoginStorage.Delegate`][73.8] via
+ [`GeckoRuntime#setLoginStorageDelegate`][73.9]
+ ([bug 1602881]({{bugzilla}}1602881))
+- ⚠️ [`WebExtension`][69.5]'s constructor now requires a `WebExtensionController`
+ instance.
+- Added [`GeckoResult.allOf`][73.10] for consuming a list of results.
+- Added [`WebExtensionController.list`][73.11] to list all installed extensions.
+- Added [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13]. These control
+ autoplay permissions for audible and inaudible videos.
+ ([bug 1577596]({{bugzilla}}1577596))
+- Added [`LoginStorage.Delegate.onLoginSave`][73.14] for login storage save
+ requests and [`GeckoSession.PromptDelegate.onLoginStoragePrompt`][73.15] for
+ login storage prompts.
+ ([bug 1599873]({{bugzilla}}1599873))
+
+[73.1]: {{javadoc_uri}}/WebExtensionController.html#install(java.lang.String)
+[73.2]: {{javadoc_uri}}/WebExtensionController.html#uninstall(org.mozilla.geckoview.WebExtension)
+[73.3]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_WIDTH
+[73.4]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_HEIGHT
+[73.5]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportWidth(double)
+[73.6]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportHeight(double)
+[73.7]: {{javadoc_uri}}/LoginStorage.html
+[73.8]: {{javadoc_uri}}/LoginStorage.Delegate.html
+[73.9]: {{javadoc_uri}}/GeckoRuntime.html#setLoginStorageDelegate(org.mozilla.geckoview.LoginStorage.Delegate)
+[73.10]: {{javadoc_uri}}/GeckoResult.html#allOf(java.util.List)
+[73.11]: {{javadoc_uri}}/WebExtensionController.html#list()
+[73.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_AUDIBLE
+[73.13]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_INAUDIBLE
+[73.14]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginSave(org.mozilla.geckoview.LoginStorage.LoginEntry)
+[73.15]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginStoragePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.LoginStoragePrompt)
+
+## v72
+- Added [`GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture`][72.1]. This indicates
+ if a load was requested while a user gesture was active (e.g., a tap).
+ ([bug 1555337]({{bugzilla}}1555337))
+- ⚠️ Refactored `AutofillElement` and `AutofillSupport` into the
+ [`Autofill`][72.2] API.
+ ([bug 1591462]({{bugzilla}}1591462))
+- Make `read()` in the `InputStream` returned from [`WebResponse#body`][72.3] timeout according
+ to [`WebResponse#setReadTimeoutMillis()`][72.4]. The default timeout value is reflected in
+ [`WebResponse#DEFAULT_READ_TIMEOUT_MS`][72.5], currently 30s.
+ ([bug 1595145]({{bugzilla}}1595145))
+- ⚠️ Removed `GeckoResponse`
+ ([bug 1581161]({{bugzilla}}1581161))
+- ⚠️ Removed `actions` and `response` arguments from [`SelectionActionDelegate.onShowActionRequest`][72.6]
+ and [`BasicSelectionActionDelegate.onShowActionRequest`][72.7]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added text selection action methods to [`SelectionActionDelegate.Selection`][72.8]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added [`BasicSelectionActionDelegate.getSelection`][72.9]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Changed [`BasicSelectionActionDelegate.clearSelection`][72.10] to public.
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added `Autofill` commit support.
+ ([bug 1577005]({{bugzilla}}1577005))
+- Added [`GeckoView.setViewBackend`][72.11] to set whether GeckoView should be
+ backed by a [`TextureView`][72.12] or a [`SurfaceView`][72.13].
+ ([bug 1530402]({{bugzilla}}1530402))
+- Added support for Browser and Page Action from the WebExtension API.
+ See [`WebExtension.Action`][72.14].
+ ([bug 1530402]({{bugzilla}}1530402))
+- ⚠️ Split [`ContentBlockingController.Event.LOADED_TRACKING_CONTENT`][72.15] into
+ [`ContentBlockingController.Event.LOADED_LEVEL_1_TRACKING_CONTENT`][72.16] and
+ [`ContentBlockingController.Event.LOADED_LEVEL_2_TRACKING_CONTENT`][72.17].
+- Replaced `subscription` argument in [`WebPushDelegate.onPushEvent`][72.18] from a [`WebPushSubscription`][72.19] to the [`String`][72.20] `scope`.
+- ⚠️ Renamed `WebExtension.ActionIcon` to [`Icon`][72.21].
+- Added [`GeckoWebExecutor#FETCH_FLAGS_STREAM_FAILURE_TEST`][72.22], which is a new
+ flag used to immediately fail when reading a `WebResponse` body.
+ ([bug 1594905]({{bugzilla}}1594905))
+- Changed [`CrashReporter#sendCrashReport(Context, File, JSONObject)`][72.23] to
+ accept a JSON object instead of a Map. Said object also includes the
+ application name that was previously passed as the fourth argument to the
+ method, which was thus removed.
+- Added WebXR device access permission support, [`PERMISSION_PERSISTENT_XR`][72.24].
+ ([bug 1599927]({{bugzilla}}1599927))
+
+[72.1]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture
+[72.2]: {{javadoc_uri}}/Autofill.html
+[72.3]: {{javadoc_uri}}/WebResponse.html#body
+[72.4]: {{javadoc_uri}}/WebResponse.html#setReadTimeoutMillis(long)
+[72.5]: {{javadoc_uri}}/WebResponse.html#DEFAULT_READ_TIMEOUT_MS
+[72.6]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection)
+[72.7]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection)
+[72.8]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html
+[72.9]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#getSelection
+[72.10]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#clearSelection
+[72.11]: {{javadoc_uri}}/GeckoView.html#setViewBackend(int)
+[72.12]: https://developer.android.com/reference/android/view/TextureView
+[72.13]: https://developer.android.com/reference/android/view/SurfaceView
+[72.14]: {{javadoc_uri}}/WebExtension.Action.html
+[72.15]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_TRACKING_CONTENT
+[72.16]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_1_TRACKING_CONTENT
+[72.17]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_2_TRACKING_CONTENT
+[72.18]: {{javadoc_uri}}/WebPushController.html#onPushEvent(org.mozilla.geckoview.WebPushSubscription,byte[])
+[72.19]: {{javadoc_uri}}/WebPushSubscription.html
+[72.20]: https://developer.android.com/reference/java/lang/String
+[72.21]: {{javadoc_uri}}/WebExtension.Icon.html
+[72.22]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_STREAM_FAILURE_TEST
+[72.23]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,org.json.JSONObject)
+[72.24]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_XR
+
+## v71
+- Added a content blocking flag for blocked social cookies to [`ContentBlocking`][70.17].
+ ([bug 1584479]({{bugzilla}}1584479))
+- Added [`onBooleanScalar`][71.1], [`onLongScalar`][71.2],
+ [`onStringScalar`][71.3] to [`RuntimeTelemetry.Delegate`][70.12] to support
+ scalars in streaming telemetry. ⚠️ As part of this change,
+ `onTelemetryReceived` has been renamed to [`onHistogram`][71.4], and
+ [`Metric`][71.5] now takes a type parameter.
+ ([bug 1576730]({{bugzilla}}1576730))
+- Added overloads of [`GeckoSession.loadUri`][71.6] that accept a map of
+ additional HTTP request headers.
+ ([bug 1567549]({{bugzilla}}1567549))
+- Added support for exposing the content blocking log in [`ContentBlockingController`][71.7].
+ ([bug 1580201]({{bugzilla}}1580201))
+- ⚠️ Added `nativeApp` to [`WebExtension.MessageDelegate.onMessage`][71.8] which
+ exposes the native application identifier that was used to send the message.
+ ([bug 1546445]({{bugzilla}}1546445))
+- Added [`GeckoRuntime.ServiceWorkerDelegate`][71.9] set via
+ [`setServiceWorkerDelegate`][71.10] to support [`ServiceWorkerClients.openWindow`][71.11]
+ ([bug 1511033]({{bugzilla}}1511033))
+- Added [`GeckoRuntimeSettings.Builder#aboutConfigEnabled`][71.12] to control whether or
+ not `about:config` should be available.
+ ([bug 1540065]({{bugzilla}}1540065))
+- Added [`GeckoSession.ContentDelegate.onFirstContentfulPaint`][71.13]
+ ([bug 1578947]({{bugzilla}}1578947))
+- Added `setEnhancedTrackingProtectionLevel` to [`ContentBlocking.Settings`][71.14].
+ ([bug 1580854]({{bugzilla}}1580854))
+- ⚠️ Added [`GeckoView.onTouchEventForResult`][71.15] and modified
+ [`PanZoomController.onTouchEvent`][71.16] to return how the touch event was handled. This
+ allows apps to know if an event is handled by touch event listeners in web content. The methods in `PanZoomController` now return `int` instead of `boolean`.
+- Added [`GeckoSession.purgeHistory`][71.17] allowing apps to clear a session's history.
+ ([bug 1583265]({{bugzilla}}1583265))
+- Added [`GeckoRuntimeSettings.Builder#forceUserScalableEnabled`][71.18] to control whether or
+ not to force user scalable zooming.
+ ([bug 1540615]({{bugzilla}}1540615))
+- ⚠️ Moved Autofill related methods from `SessionTextInput` and `GeckoSession.TextInputDelegate`
+ into `GeckoSession` and `AutofillDelegate`.
+- Added [`GeckoSession.getAutofillElements()`][71.19], which is a new method for getting
+ an autofill virtual structure without using `ViewStructure`. It relies on a new class,
+ [`AutofillElement`][71.20], for representing the virtual tree.
+- Added [`GeckoView.setAutofillEnabled`][71.21] for controlling whether or not the `GeckoView`
+ instance participates in Android autofill. When enabled, this connects an `AutofillDelegate`
+ to the session it holds.
+- Changed [`AutofillElement.children`][71.20] interface to `Collection` to provide
+ an efficient way to pre-allocate memory when filling `ViewStructure`.
+- Added [`GeckoSession.PromptDelegate.onSharePrompt`][71.22] to support the WebShare API.
+ ([bug 1402369]({{bugzilla}}1402369))
+- Added [`GeckoDisplay.screenshot`][71.23] allowing apps finer grain control over screenshots.
+ ([bug 1577192]({{bugzilla}}1577192))
+- Added `GeckoView.setDynamicToolbarMaxHeight` to make ICB size static, ICB doesn't include the dynamic toolbar region.
+ ([bug 1586144]({{bugzilla}}1586144))
+
+[71.1]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onBooleanScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.2]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onLongScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.3]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onStringScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.4]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onHistogram(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.5]: {{javadoc_uri}}/RuntimeTelemetry.Metric.html
+[71.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,java.io.File,java.util.Map)
+[71.7]: {{javadoc_uri}}/ContentBlockingController.html
+[71.8]: {{javadoc_uri}}/WebExtension.MessageDelegate.html#onMessage(java.lang.String,java.lang.Object,org.mozilla.geckoview.WebExtension.MessageSender)
+[71.9]: {{javadoc_uri}}/GeckoRuntime.ServiceWorkerDelegate.html
+[71.10]: {{javadoc_uri}}/GeckoRuntime#setServiceWorkerDelegate(org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate)
+[71.11]: https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow
+[71.12]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#aboutConfigEnabled(boolean)
+[71.13]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession)
+[71.15]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent)
+[71.16]: {{javadoc_uri}}/PanZoomController.html#onTouchEvent(android.view.MotionEvent)
+[71.17]: {{javadoc_uri}}/GeckoSession.html#purgeHistory()
+[71.18]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#forceUserScalableEnabled(boolean)
+[71.19]: {{javadoc_uri}}/GeckoSession.html#getAutofillElements()
+[71.20]: {{javadoc_uri}}/AutofillElement.html
+[71.21]: {{javadoc_uri}}/GeckoView.html#setAutofillEnabled(boolean)
+[71.22]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSharePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt)
+[71.23]: {{javadoc_uri}}/GeckoDisplay.html#screenshot()
+
+## v70
+- Added API for session context assignment
+ [`GeckoSessionSettings.Builder.contextId`][70.1] and deletion of data related
+ to a session context [`StorageController.clearDataForSessionContext`][70.2].
+ ([bug 1501108]({{bugzilla}}1501108))
+- Removed `setSession(session, runtime)` from [`GeckoView`][70.5]. With this
+ change, `GeckoView` will no longer manage opening/closing of the
+ [`GeckoSession`][70.6] and instead leave that up to the app. It's also now
+ allowed to call [`setSession`][70.10] with a closed `GeckoSession`.
+ ([bug 1510314]({{bugzilla}}1510314))
+- Added an overload of [`GeckoSession.loadUri()`][70.8] that accepts a
+ referring [`GeckoSession`][70.6]. This should be used when the URI we're
+ loading originates from another page. A common example of this would be long
+ pressing a link and then opening that in a new `GeckoSession`.
+ ([bug 1561079]({{bugzilla}}1561079))
+- Added capture parameter to [`onFilePrompt`][70.9] and corresponding
+ [`CAPTURE_TYPE_*`][70.7] constants.
+ ([bug 1553603]({{bugzilla}}1553603))
+- Removed the obsolete `success` parameter from
+ [`CrashReporter#sendCrashReport(Context, File, File, String)`][70.3] and
+ [`CrashReporter#sendCrashReport(Context, File, Map, String)`][70.4].
+ ([bug 1570789]({{bugzilla}}1570789))
+- Add `GeckoSession.LOAD_FLAGS_REPLACE_HISTORY`.
+ ([bug 1571088]({{bugzilla}}1571088))
+- Complete rewrite of [`PromptDelegate`][70.11].
+ ([bug 1499394]({{bugzilla}}1499394))
+- Added [`RuntimeTelemetry.Delegate`][70.12] that receives streaming telemetry
+ data from GeckoView.
+ ([bug 1566367]({{bugzilla}}1566367))
+- Updated [`ContentBlocking`][70.13] to better report blocked and allowed ETP events.
+ ([bug 1567268]({{bugzilla}}1567268))
+- Added API for controlling Gecko logging [`GeckoRuntimeSettings.debugLogging`][70.14]
+ ([bug 1573304]({{bugzilla}}1573304))
+- Added [`WebNotification`][70.15] and [`WebNotificationDelegate`][70.16] for handling Web Notifications.
+ ([bug 1533057]({{bugzilla}}1533057))
+- Added Social Tracking Protection support to [`ContentBlocking`][70.17].
+ ([bug 1568295]({{bugzilla}}1568295))
+- Added [`WebExtensionController`][70.18] and [`WebExtensionController.TabDelegate`][70.19] to handle
+ [`browser.tabs.create`][70.20] calls by WebExtensions.
+ ([bug 1539144]({{bugzilla}}1539144))
+- Added [`onCloseTab`][70.21] to [`WebExtensionController.TabDelegate`][70.19] to handle
+ [`browser.tabs.remove`][70.22] calls by WebExtensions.
+ ([bug 1565782]({{bugzilla}}1565782))
+- Added onSlowScript to [`ContentDelegate`][70.23] which allows handling of slow and hung scripts.
+ ([bug 1621094]({{bugzilla}}1621094))
+- Added support for Web Push via [`WebPushController`][70.24], [`WebPushDelegate`][70.25], and
+ [`WebPushSubscription`][70.26].
+- Added [`ContentBlockingController`][70.27], accessible via [`GeckoRuntime.getContentBlockingController`][70.28]
+ to allow modification and inspection of a content blocking exception list.
+
+[70.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#contextId(java.lang.String)
+[70.2]: {{javadoc_uri}}/StorageController.html#clearDataForSessionContext(java.lang.String)
+[70.3]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.io.File,java.lang.String)
+[70.4]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.util.Map,java.lang.String)
+[70.5]: {{javadoc_uri}}/GeckoView.html
+[70.6]: {{javadoc_uri}}/GeckoSession.html
+[70.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#CAPTURE_TYPE_NONE
+[70.8]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int)
+[70.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onFilePrompt(org.mozilla.geckoview.GeckoSession,java.lang.String,int,java.lang.String[],int,org.mozilla.geckoview.GeckoSession.PromptDelegate.FileCallback)
+[70.10]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession)
+[70.11]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html
+[70.12]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html
+[70.13]: {{javadoc_uri}}/ContentBlocking.html
+[70.14]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#debugLogging(boolean)
+[70.15]: {{javadoc_uri}}/WebNotification.html
+[70.16]: {{javadoc_uri}}/WebNotificationDelegate.html
+[70.17]: {{javadoc_uri}}/ContentBlocking.html
+[70.18]: {{javadoc_uri}}/WebExtensionController.html
+[70.19]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html
+[70.20]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create
+[70.21]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html#onCloseTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession)
+[70.22]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove
+[70.23]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html
+[70.24]: {{javadoc_uri}}/WebPushController.html
+[70.25]: {{javadoc_uri}}/WebPushDelegate.html
+[70.26]: {{javadoc_uri}}/WebPushSubscription.html
+[70.27]: {{javadoc_uri}}/ContentBlockingController.html
+[70.28]: {{javadoc_uri}}/GeckoRuntime.html#getContentBlockingController()
+
+## v69
+- Modified behavior of [`setAutomaticFontSizeAdjustment`][69.1] so that it no
+ longer has any effect on [`setFontInflationEnabled`][69.2]
+- Add [GeckoSession.LOAD_FLAGS_FORCE_ALLOW_DATA_URI][69.14]
+- Added [`GeckoResult.accept`][69.3] for consuming a result without
+ transforming it.
+- [`GeckoSession.setMessageDelegate`][69.13] callers must now specify the
+ [`WebExtension`][69.5] that the [`MessageDelegate`][69.4] will receive
+ messages from.
+- Created [`onKill`][69.7] to [`ContentDelegate`][69.11] to differentiate from crashes.
+
+[69.1]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean)
+[69.2]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontInflationEnabled(boolean)
+[69.3]: {{javadoc_uri}}/GeckoResult.html#accept(org.mozilla.geckoview.GeckoResult.Consumer)
+[69.4]: {{javadoc_uri}}/WebExtension.MessageDelegate.html
+[69.5]: {{javadoc_uri}}/WebExtension.html
+[69.7]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onKill(org.mozilla.geckoview.GeckoSession)
+[69.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html
+[69.13]: {{javadoc_uri}}/GeckoSession.html#setMessageDelegate(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String)
+[69.14]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_FORCE_ALLOW_DATA_URI
+
+## v68
+- Added [`GeckoRuntime#configurationChanged`][68.1] to notify the device
+ configuration has changed.
+- Added [`onSessionStateChange`][68.29] to [`ProgressDelegate`][68.2] and removed `saveState`.
+- Added [`ContentBlocking#AT_CRYPTOMINING`][68.3] for cryptocurrency miner blocking.
+- Added [`ContentBlocking#AT_DEFAULT`][68.4], [`ContentBlocking#AT_STRICT`][68.5],
+ [`ContentBlocking#CB_DEFAULT`][68.6] and [`ContentBlocking#CB_STRICT`][68.7]
+ for clearer app default selections.
+- Added [`GeckoSession.SessionState.fromString`][68.8]. This can be used to
+ deserialize a `GeckoSession.SessionState` instance previously serialized to
+ a `String` via `GeckoSession.SessionState.toString`.
+- Added [`GeckoRuntimeSettings#setPreferredColorScheme`][68.9] to override
+ the default color theme for web content ("light" or "dark").
+- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all fields.
+- [`RuntimeTelemetry#getSnapshots`][68.10] returns a [`JSONObject`][68.30] now.
+- Removed all `org.mozilla.gecko` references in the API.
+- Added [`ContentBlocking#AT_FINGERPRINTING`][68.11] to block fingerprinting trackers.
+- Added [`HistoryItem`][68.31] and [`HistoryList`][68.32] interfaces and [`onHistoryStateChange`][68.34] to
+ [`HistoryDelegate`][68.12] and added [`gotoHistoryIndex`][68.33] to [`GeckoSession`][68.13].
+- [`GeckoView`][70.5] will not create a [`GeckoSession`][65.9] anymore when
+ attached to a window without a session.
+- Added [`GeckoRuntimeSettings.Builder#configFilePath`][68.16] to set
+ a path to a configuration file from which GeckoView will read
+ configuration options such as Gecko process arguments, environment
+ variables, and preferences.
+- Added [`unregisterWebExtension`][68.17] to unregister a web extension.
+- Added messaging support for WebExtension. [`setMessageDelegate`][68.18]
+ allows embedders to listen to messages coming from a WebExtension.
+ [`Port`][68.19] allows bidirectional communication between the embedder and
+ the WebExtension.
+- Expose the following prefs in [`GeckoRuntimeSettings`][67.3]:
+ [`setAutoZoomEnabled`][68.20], [`setDoubleTapZoomingEnabled`][68.21],
+ [`setGlMsaaLevel`][68.22].
+- Added new constant for requesting external storage Android permissions, [`PERMISSION_PERSISTENT_STORAGE`][68.35]
+- Added `setVerticalClipping` to [`GeckoDisplay`][68.24] and
+ [`GeckoView`][68.23] to tell Gecko how much of its vertical space is clipped.
+- Added [`StorageController`][68.25] API for clearing data.
+- Added [`onRecordingStatusChanged`][68.26] to [`MediaDelegate`][68.27] to handle events related to the status of recording devices.
+- Removed redundant constants in [`MediaSource`][68.28]
+
+[68.1]: {{javadoc_uri}}/GeckoRuntime.html#configurationChanged(android.content.res.Configuration)
+[68.2]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html
+[68.3]: {{javadoc_uri}}/ContentBlocking.html#AT_CRYPTOMINING
+[68.4]: {{javadoc_uri}}/ContentBlocking.html#AT_DEFAULT
+[68.5]: {{javadoc_uri}}/ContentBlocking.html#AT_STRICT
+[68.6]: {{javadoc_uri}}/ContentBlocking.html#CB_DEFAULT
+[68.7]: {{javadoc_uri}}/ContentBlocking.html#CB_STRICT
+[68.8]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String)
+[68.9]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setPreferredColorScheme(int)
+[68.10]: {{javadoc_uri}}/RuntimeTelemetry.html#getSnapshots(boolean)
+[68.11]: {{javadoc_uri}}/ContentBlocking.html#AT_FINGERPRINTING
+[68.12]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html
+[68.13]: {{javadoc_uri}}/GeckoSession.html
+[68.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#configFilePath(java.lang.String)
+[68.17]: {{javadoc_uri}}/GeckoRuntime.html#unregisterWebExtension(org.mozilla.geckoview.WebExtension)
+[68.18]: {{javadoc_uri}}/WebExtension.html#setMessageDelegate(org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String)
+[68.19]: {{javadoc_uri}}/WebExtension.Port.html
+[68.20]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoZoomEnabled(boolean)
+[68.21]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setDoubleTapZoomingEnabled(boolean)
+[68.22]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setGlMsaaLevel(int)
+[68.23]: {{javadoc_uri}}/GeckoView.html#setVerticalClipping(int)
+[68.24]: {{javadoc_uri}}/GeckoDisplay.html#setVerticalClipping(int)
+[68.25]: {{javadoc_uri}}/StorageController.html
+[68.26]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html#onRecordingStatusChanged(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice[])
+[68.27]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html
+[68.28]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html
+[68.29]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html#onSessionStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SessionState)
+[68.30]: https://developer.android.com/reference/org/json/JSONObject
+[68.31]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryItem.html
+[68.32]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryList.html
+[68.33]: {{javadoc_uri}}/GeckoSession.html#gotoHistoryIndex(int)
+[68.34]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html#onHistoryStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.HistoryDelegate.HistoryList)
+[68.35]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_STORAGE
+
+## v67
+- Added [`setAutomaticFontSizeAdjustment`][67.23] to
+ [`GeckoRuntimeSettings`][67.3] for automatically adjusting font size settings
+ depending on the OS-level font size setting.
+- Added [`setFontSizeFactor`][67.4] to [`GeckoRuntimeSettings`][67.3] for
+ setting a font size scaling factor, and for enabling font inflation for
+ non-mobile-friendly pages.
+- Updated video autoplay API to reflect changes in Gecko. Instead of being a
+ per-video permission in the [`PermissionDelegate`][67.5], it is a [runtime
+ setting][67.6] that either allows or blocks autoplay videos.
+- Change [`ContentBlocking.AT_AD`][67.7] and [`ContentBlocking.SB_ALL`][67.8]
+ values to mirror the actual constants they encompass.
+- Added nested [`ContentBlocking`][67.9] runtime settings.
+- Added [`RuntimeSettings`][67.10] base class to support nested settings.
+- Added [`baseUri`][67.11] to [`ContentDelegate.ContextElement`][65.21] and
+ changed [`linkUri`][67.12] to absolute form.
+- Added [`scrollBy`][67.13] and [`scrollTo`][67.14] to [`PanZoomController`][65.4].
+- Added [`GeckoSession.getDefaultUserAgent`][67.1] to expose the build-time
+ default user agent synchronously.
+- Changed [`WebResponse.body`][67.24] from a [`ByteBuffer`][67.25] to an [`InputStream`][67.26]. Apps that want access
+ to the entire response body will now need to read the stream themselves.
+- Added [`GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS`][67.27], which will cause [`GeckoWebExecutor.fetch()`][67.28] to not
+ automatically follow [HTTP redirects][67.29] (e.g., 302).
+- Moved [`GeckoVRManager`][67.2] into the org.mozilla.geckoview package.
+- Initial WebExtension support. [`GeckoRuntime#registerWebExtension`][67.15]
+ allows embedders to register a local web extension.
+- Added API to [`GeckoView`][70.5] to take screenshot of the visible page. Calling [`capturePixels`][67.16] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.17] of the current [`Surface`][67.18] contents, or an [`IllegalStateException`][67.19] if the [`GeckoSession`][65.9] is not ready to render content.
+- Added API to capture a screenshot to [`GeckoDisplay`][67.20]. [`capturePixels`][67.21] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.16] of the current [`Surface`][67.17] contents, or an [`IllegalStateException`][67.18] if the [`GeckoSession`][65.9] is not ready to render content.
+- Add missing [`@Nullable`][66.2] annotation to return value for
+ [`GeckoSession.PromptDelegate.ChoiceCallback.onPopupResult()`][67.30]
+- Added `default` implementations for all non-functional `interface`s.
+- Added [`ContentDelegate.onWebAppManifest`][67.22], which will deliver the contents of a parsed
+ and validated Web App Manifest on pages that contain one.
+
+[67.1]: {{javadoc_uri}}/GeckoSession.html#getDefaultUserAgent()
+[67.2]: {{javadoc_uri}}/GeckoVRManager.html
+[67.3]: {{javadoc_uri}}/GeckoRuntimeSettings.html
+[67.4]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontSizeFactor(float)
+[67.5]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html
+[67.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoplayDefault(int)
+[67.7]: {{javadoc_uri}}/ContentBlocking.html#AT_AD
+[67.8]: {{javadoc_uri}}/ContentBlocking.html#SB_ALL
+[67.9]: {{javadoc_uri}}/ContentBlocking.html
+[67.10]: {{javadoc_uri}}/RuntimeSettings.html
+[67.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#baseUri
+[67.12]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#linkUri
+[67.13]: {{javadoc_uri}}/PanZoomController.html#scrollBy(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength)
+[67.14]: {{javadoc_uri}}/PanZoomController.html#scrollTo(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength)
+[67.15]: {{javadoc_uri}}/GeckoRuntime.html#registerWebExtension(org.mozilla.geckoview.WebExtension)
+[67.16]: {{javadoc_uri}}/GeckoView.html#capturePixels()
+[67.17]: https://developer.android.com/reference/android/graphics/Bitmap
+[67.18]: https://developer.android.com/reference/android/view/Surface
+[67.19]: https://developer.android.com/reference/java/lang/IllegalStateException
+[67.20]: {{javadoc_uri}}/GeckoDisplay.html
+[67.21]: {{javadoc_uri}}/GeckoDisplay.html#capturePixels()
+[67.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onWebAppManifest(org.mozilla.geckoview.GeckoSession,org.json.JSONObject)
+[67.23]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean)
+[67.24]: {{javadoc_uri}}/WebResponse.html#body
+[67.25]: https://developer.android.com/reference/java/nio/ByteBuffer
+[67.26]: https://developer.android.com/reference/java/io/InputStream
+[67.27]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_NO_REDIRECTS
+[67.28]: {{javadoc_uri}}/GeckoWebExecutor.html#fetch(org.mozilla.geckoview.WebRequest,int)
+[67.29]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections
+[67.30]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ChoiceCallback.html
+
+## v66
+- Removed redundant field `trackingMode` from [`SecurityInformation`][66.6].
+ Use `TrackingProtectionDelegate.onTrackerBlocked` for notification of blocked
+ elements during page load.
+- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all APIs.
+- Added methods for each setting in [`GeckoSessionSettings`][66.3]
+- Added [`GeckoSessionSettings`][66.4] for enabling desktop viewport. Desktop
+ viewport is no longer set by [`USER_AGENT_MODE_DESKTOP`][66.5] and must be set
+ separately.
+- Added [`@UiThread`][65.6] to [`GeckoSession.releaseSession`][66.7] and
+ [`GeckoSession.setSession`][66.8]
+
+[66.1]: https://developer.android.com/reference/android/support/annotation/NonNull
+[66.2]: https://developer.android.com/reference/android/support/annotation/Nullable
+[66.3]: {{javadoc_uri}}/GeckoSessionSettings.html
+[66.4]: {{javadoc_uri}}/GeckoSessionSettings.html
+[66.5]: {{javadoc_uri}}/GeckoSessionSettings.html#USER_AGENT_MODE_DESKTOP
+[66.6]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html
+[66.7]: {{javadoc_uri}}/GeckoView.html#releaseSession()
+[66.8]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession)
+
+## v65
+- Added experimental ad-blocking category to `GeckoSession.TrackingProtectionDelegate`.
+- Moved [`CompositorController`][65.1], [`DynamicToolbarAnimator`][65.2],
+ [`OverscrollEdgeEffect`][65.3], [`PanZoomController`][65.4] from
+ `org.mozilla.gecko.gfx` to [`org.mozilla.geckoview`][65.5]
+- Added [`@UiThread`][65.6], [`@AnyThread`][65.7] annotations to all APIs
+- Changed `GeckoRuntimeSettings#getLocale` to [`getLocales`][65.8] and related
+ APIs.
+- Merged `org.mozilla.gecko.gfx.LayerSession` into [`GeckoSession`][65.9]
+- Added [`GeckoSession.MediaDelegate`][65.10] and [`MediaElement`][65.11]. This
+ allow monitoring and control of web media elements (play, pause, seek, etc).
+- Removed unused `access` parameter from
+ [`GeckoSession.PermissionDelegate#onContentPermissionRequest`][65.12]
+- Added [`WebMessage`][65.13], [`WebRequest`][65.14], [`WebResponse`][65.15],
+ and [`GeckoWebExecutor`][65.16]. This exposes Gecko networking to apps. It
+ includes speculative connections, name resolution, and a Fetch-like HTTP API.
+- Added [`GeckoSession.HistoryDelegate`][65.17]. This allows apps to implement
+ their own history storage system and provide visited link status.
+- Added [`ContentDelegate#onFirstComposite`][65.18] to get first composite
+ callback after a compositor start.
+- Changed `LoadRequest.isUserTriggered` to [`isRedirect`][65.19].
+- Added [`GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER`][65.20] to bypass the URI
+ classifier.
+- Added a `protected` empty constructor to all field-only classes so that apps
+ can mock these classes in tests.
+- Added [`ContentDelegate.ContextElement`][65.21] to extend the information
+ passed to [`ContentDelegate#onContextMenu`][65.22]. Extended information
+ includes the element's title and alt attributes.
+- Changed [`ContentDelegate.ContextElement`][65.21] `TYPE_` constants to public
+ access.
+- Changed [`ContentDelegate.ContextElement`][65.21],
+ [`GeckoSession.FinderResult`][65.23] to non-final class.
+- Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a
+ [`GeckoResult<String>`][65.25].
+
+[65.1]: {{javadoc_uri}}/CompositorController.html
+[65.2]: {{javadoc_uri}}/DynamicToolbarAnimator.html
+[65.3]: {{javadoc_uri}}/OverscrollEdgeEffect.html
+[65.4]: {{javadoc_uri}}/PanZoomController.html
+[65.5]: {{javadoc_uri}}/package-summary.html
+[65.6]: https://developer.android.com/reference/android/support/annotation/UiThread
+[65.7]: https://developer.android.com/reference/android/support/annotation/AnyThread
+[65.8]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getLocales()
+[65.9]: {{javadoc_uri}}/GeckoSession.html
+[65.10]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html
+[65.11]: {{javadoc_uri}}/MediaElement.html
+[65.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,java.lang.String,int,org.mozilla.geckoview.GeckoSession.PermissionDelegate.Callback)
+[65.13]: {{javadoc_uri}}/WebMessage.html
+[65.14]: {{javadoc_uri}}/WebRequest.html
+[65.15]: {{javadoc_uri}}/WebResponse.html
+[65.16]: {{javadoc_uri}}/GeckoWebExecutor.html
+[65.17]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html
+[65.18]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstComposite(org.mozilla.geckoview.GeckoSession)
+[65.19]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isRedirect
+[65.20]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_BYPASS_CLASSIFIER
+[65.21]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html
+[65.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onContextMenu(org.mozilla.geckoview.GeckoSession,int,int,org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement)
+[65.23]: {{javadoc_uri}}/GeckoSession.FinderResult.html
+[65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,android.os.Bundle,java.lang.String)
+[65.25]: {{javadoc_uri}}/GeckoResult.html
+
+[api-version]: ff5a513251f19534bbf4ebe0084909665d00a227
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java
new file mode 100644
index 0000000000..4394d27f72
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java
@@ -0,0 +1,40 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This package contains the public interfaces for the library.
+ *
+ * <ul>
+ * <li>{@link org.mozilla.geckoview.GeckoRuntime} is the entry point for starting and initializing
+ * Gecko. You can use this to preload Gecko before you need to load a page or to configure
+ * features such as crash reporting.
+ * <li>{@link org.mozilla.geckoview.GeckoSession} is where most interesting work happens, such as
+ * loading pages. It relies on {@link org.mozilla.geckoview.GeckoRuntime} to talk to Gecko.
+ * <li>{@link org.mozilla.geckoview.GeckoView} is the embeddable {@link android.view.View}. This
+ * is the most common way of getting a {@link org.mozilla.geckoview.GeckoSession} onto the
+ * screen.
+ * </ul>
+ *
+ * <p><strong>Permissions</strong>
+ *
+ * <p>This library does not request any dangerous permissions in the manifest, though it's possible
+ * that some web features may require them. For instance, WebRTC video calls would need the {@link
+ * android.Manifest.permission#CAMERA} and {@link android.Manifest.permission#RECORD_AUDIO}
+ * permissions. Declaring these are at the application's discretion. If you want full web
+ * functionality, the following permissions should be declared:
+ *
+ * <ul>
+ * <li>{@link android.Manifest.permission#ACCESS_COARSE_LOCATION}
+ * <li>{@link android.Manifest.permission#ACCESS_FINE_LOCATION}
+ * <li>{@link android.Manifest.permission#READ_EXTERNAL_STORAGE}
+ * <li>{@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE}
+ * <li>{@link android.Manifest.permission#CAMERA}
+ * <li>{@link android.Manifest.permission#RECORD_AUDIO}
+ * </ul>
+ *
+ * For a detailed change log of the API see: <a href="./doc-files/CHANGELOG"
+ * target="_blank">CHANGELOG</a>.
+ */
+package org.mozilla.geckoview;