diff options
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/geckoview')
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> + * <form> + * <input type="text" placeholder="username"> + * <input type="password" placeholder="password"> + * <input type="submit" value="submit"> + * </form> + * </code></pre> + * + * <p>With the document parsed and the login input fields identified, GeckoView dispatches a <code> + * StorageDelegate.onLoginFetch("example.com")</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("example.com")</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<Integer> divide(final int dividend, final int divisor) { + * final GeckoResult<Integer> result = new GeckoResult<>(); + * (new Thread(() -> { + * 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<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) { + * // value == 21 + * } + * }, new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * // Not called + * } + * });</pre> + * + * <p>And to retrieve a completed exception, + * + * <pre> + * divide(42, 0).then(new GeckoResult.OnValueListener<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) { + * // Not called + * } + * }, new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> 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<Integer, String>() { + * @Override + * public GeckoResult<String> onValue(final Integer value) { + * return GeckoResult.fromValue(value.toString()); + * } + * }).then(new GeckoResult.OnValueListener<String, String>() { + * @Override + * public GeckoResult<String> onValue(final String value) { + * return GeckoResult.fromValue("42 / 2 = " + value); + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> 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<String>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * return "foo"; + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> 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<Integer, String>() { + * @Override + * public GeckoResult<String> onValue(final Integer value) { + * // Not called + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> 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<String>() { + * @Override + * public GeckoResult<String> onException(final Throwable exception) { + * // Not called + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> 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<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) throws FooException { + * throw new FooException(); + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) throws Exception { + * // exception instanceof FooException + * throw new BarException(); + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> 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 < 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} > 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 <meta> + * 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} > 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 <meta> + * 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 <option> elements are updated when using <select> + * 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 <datalist> 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 <meta> 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 <meta> 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<WebResponse> result = executor.fetch( + * new WebRequest.Builder("https://example.org/json") + * .header("Accept", "application/json") + * .build()); + * + * result.then(response -> { + * // 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> + * @Override + * public Handler getHandler() { + * if (Build.VERSION.SDK_INT >= 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 => { + * 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 + * & DisabledFlags.USER > 0; + * </code></pre> + */ + public final @EnabledFlags int disabledFlags; + + /** + * Root URL for this extension's pages. Can be used to determine if a given URL belongs to this + * extension. + */ + public final @NonNull String baseUrl; + + /** + * Whether this extension is allowed to run in private browsing or not. To modify this value use + * {@link WebExtensionController#setAllowedInPrivateBrowsing}. + */ + public final boolean allowedInPrivateBrowsing; + + /** Whether this extension is enabled or not. */ + public final boolean enabled; + + /** + * Whether this extension is temporary or not. Temporary extensions are not retained and will be + * uninstalled when the browser exits. + */ + public final boolean temporary; + + /** 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; |