summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill/FormAutofillUtils.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/formautofill/FormAutofillUtils.jsm1335
1 files changed, 1335 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/FormAutofillUtils.jsm b/toolkit/components/formautofill/FormAutofillUtils.jsm
new file mode 100644
index 0000000000..a06746944b
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillUtils.jsm
@@ -0,0 +1,1335 @@
+/* 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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "FormAutofillUtils",
+ "AddressDataLoader",
+ "LabelUtils",
+];
+let FormAutofillUtils;
+
+const ADDRESS_METADATA_PATH = "resource://autofill/addressmetadata/";
+const ADDRESS_REFERENCES = "addressReferences.js";
+const ADDRESS_REFERENCES_EXT = "addressReferencesExt.js";
+
+const ADDRESSES_COLLECTION_NAME = "addresses";
+const CREDITCARDS_COLLECTION_NAME = "creditCards";
+const MANAGE_ADDRESSES_L10N_IDS = [
+ "autofill-add-new-address-title",
+ "autofill-manage-addresses-title",
+];
+const EDIT_ADDRESS_L10N_IDS = [
+ "autofill-address-given-name",
+ "autofill-address-additional-name",
+ "autofill-address-family-name",
+ "autofill-address-organization",
+ "autofill-address-street",
+ "autofill-address-state",
+ "autofill-address-province",
+ "autofill-address-city",
+ "autofill-address-country",
+ "autofill-address-zip",
+ "autofill-address-postal-code",
+ "autofill-address-email",
+ "autofill-address-tel",
+];
+const MANAGE_CREDITCARDS_L10N_IDS = [
+ "autofill-add-new-card-title",
+ "autofill-manage-credit-cards-title",
+];
+const EDIT_CREDITCARD_L10N_IDS = [
+ "autofill-card-number",
+ "autofill-card-name-on-card",
+ "autofill-card-expires-month",
+ "autofill-card-expires-year",
+ "autofill-card-network",
+];
+const FIELD_STATES = {
+ NORMAL: "NORMAL",
+ AUTO_FILLED: "AUTO_FILLED",
+ PREVIEW: "PREVIEW",
+};
+const SECTION_TYPES = {
+ ADDRESS: "address",
+ CREDIT_CARD: "creditCard",
+};
+
+const ELIGIBLE_INPUT_TYPES = ["text", "email", "tel", "number", "month"];
+
+// The maximum length of data to be saved in a single field for preventing DoS
+// attacks that fill the user's hard drive(s).
+const MAX_FIELD_VALUE_LENGTH = 200;
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { FormAutofill } = ChromeUtils.import(
+ "resource://autofill/FormAutofill.jsm"
+);
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+let AddressDataLoader = {
+ // Status of address data loading. We'll load all the countries with basic level 1
+ // information while requesting conutry information, and set country to true.
+ // Level 1 Set is for recording which country's level 1/level 2 data is loaded,
+ // since we only load this when getCountryAddressData called with level 1 parameter.
+ _dataLoaded: {
+ country: false,
+ level1: new Set(),
+ },
+
+ /**
+ * Load address data and extension script into a sandbox from different paths.
+ *
+ * @param {string} path
+ * The path for address data and extension script. It could be root of the address
+ * metadata folder(addressmetadata/) or under specific country(addressmetadata/TW/).
+ * @returns {object}
+ * A sandbox that contains address data object with properties from extension.
+ */
+ _loadScripts(path) {
+ let sandbox = {};
+ let extSandbox = {};
+
+ try {
+ sandbox = FormAutofillUtils.loadDataFromScript(path + ADDRESS_REFERENCES);
+ extSandbox = FormAutofillUtils.loadDataFromScript(
+ path + ADDRESS_REFERENCES_EXT
+ );
+ } catch (e) {
+ // Will return only address references if extension loading failed or empty sandbox if
+ // address references loading failed.
+ return sandbox;
+ }
+
+ if (extSandbox.addressDataExt) {
+ for (let key in extSandbox.addressDataExt) {
+ let addressDataForKey = sandbox.addressData[key];
+ if (!addressDataForKey) {
+ addressDataForKey = sandbox.addressData[key] = {};
+ }
+
+ Object.assign(addressDataForKey, extSandbox.addressDataExt[key]);
+ }
+ }
+ return sandbox;
+ },
+
+ /**
+ * Convert certain properties' string value into array. We should make sure
+ * the cached data is parsed.
+ *
+ * @param {object} data Original metadata from addressReferences.
+ * @returns {object} parsed metadata with property value that converts to array.
+ */
+ _parse(data) {
+ if (!data) {
+ return null;
+ }
+
+ const properties = [
+ "languages",
+ "sub_keys",
+ "sub_isoids",
+ "sub_names",
+ "sub_lnames",
+ ];
+ for (let key of properties) {
+ if (!data[key]) {
+ continue;
+ }
+ // No need to normalize data if the value is array already.
+ if (Array.isArray(data[key])) {
+ return data;
+ }
+
+ data[key] = data[key].split("~");
+ }
+ return data;
+ },
+
+ /**
+ * We'll cache addressData in the loader once the data loaded from scripts.
+ * It'll become the example below after loading addressReferences with extension:
+ * addressData: {
+ * "data/US": {"lang": ["en"], ...// Data defined in libaddressinput metadata
+ * "alternative_names": ... // Data defined in extension }
+ * "data/CA": {} // Other supported country metadata
+ * "data/TW": {} // Other supported country metadata
+ * "data/TW/台北市": {} // Other supported country level 1 metadata
+ * }
+ *
+ * @param {string} country
+ * @param {string?} level1
+ * @returns {object} Default locale metadata
+ */
+ _loadData(country, level1 = null) {
+ // Load the addressData if needed
+ if (!this._dataLoaded.country) {
+ this._addressData = this._loadScripts(ADDRESS_METADATA_PATH).addressData;
+ this._dataLoaded.country = true;
+ }
+ if (!level1) {
+ return this._parse(this._addressData[`data/${country}`]);
+ }
+ // If level1 is set, load addressReferences under country folder with specific
+ // country/level 1 for level 2 information.
+ if (!this._dataLoaded.level1.has(country)) {
+ Object.assign(
+ this._addressData,
+ this._loadScripts(`${ADDRESS_METADATA_PATH}${country}/`).addressData
+ );
+ this._dataLoaded.level1.add(country);
+ }
+ return this._parse(this._addressData[`data/${country}/${level1}`]);
+ },
+
+ /**
+ * Return the region metadata with default locale and other locales (if exists).
+ *
+ * @param {string} country
+ * @param {string?} level1
+ * @returns {object} Return default locale and other locales metadata.
+ */
+ getData(country, level1 = null) {
+ let defaultLocale = this._loadData(country, level1);
+ if (!defaultLocale) {
+ return null;
+ }
+
+ let countryData = this._parse(this._addressData[`data/${country}`]);
+ let locales = [];
+ // TODO: Should be able to support multi-locale level 1/ level 2 metadata query
+ // in Bug 1421886
+ if (countryData.languages) {
+ let list = countryData.languages.filter(key => key !== countryData.lang);
+ locales = list.map(key =>
+ this._parse(this._addressData[`${defaultLocale.id}--${key}`])
+ );
+ }
+ return { defaultLocale, locales };
+ },
+};
+
+FormAutofillUtils = {
+ get AUTOFILL_FIELDS_THRESHOLD() {
+ return 3;
+ },
+
+ ADDRESSES_COLLECTION_NAME,
+ CREDITCARDS_COLLECTION_NAME,
+ MANAGE_ADDRESSES_L10N_IDS,
+ EDIT_ADDRESS_L10N_IDS,
+ MANAGE_CREDITCARDS_L10N_IDS,
+ EDIT_CREDITCARD_L10N_IDS,
+ MAX_FIELD_VALUE_LENGTH,
+ FIELD_STATES,
+ SECTION_TYPES,
+
+ _fieldNameInfo: {
+ name: "name",
+ "given-name": "name",
+ "additional-name": "name",
+ "family-name": "name",
+ organization: "organization",
+ "street-address": "address",
+ "address-line1": "address",
+ "address-line2": "address",
+ "address-line3": "address",
+ "address-level1": "address",
+ "address-level2": "address",
+ "postal-code": "address",
+ country: "address",
+ "country-name": "address",
+ tel: "tel",
+ "tel-country-code": "tel",
+ "tel-national": "tel",
+ "tel-area-code": "tel",
+ "tel-local": "tel",
+ "tel-local-prefix": "tel",
+ "tel-local-suffix": "tel",
+ "tel-extension": "tel",
+ email: "email",
+ "cc-name": "creditCard",
+ "cc-given-name": "creditCard",
+ "cc-additional-name": "creditCard",
+ "cc-family-name": "creditCard",
+ "cc-number": "creditCard",
+ "cc-exp-month": "creditCard",
+ "cc-exp-year": "creditCard",
+ "cc-exp": "creditCard",
+ "cc-type": "creditCard",
+ },
+
+ _collators: {},
+ _reAlternativeCountryNames: {},
+
+ isAddressField(fieldName) {
+ return (
+ !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName)
+ );
+ },
+
+ isCreditCardField(fieldName) {
+ return this._fieldNameInfo[fieldName] == "creditCard";
+ },
+
+ isCCNumber(ccNumber) {
+ return lazy.CreditCard.isValidNumber(ccNumber);
+ },
+
+ ensureLoggedIn(promptMessage) {
+ return lazy.OSKeyStore.ensureLoggedIn(
+ this._reauthEnabledByUser && promptMessage ? promptMessage : false
+ );
+ },
+
+ /**
+ * Get the array of credit card network ids ("types") we expect and offer as valid choices
+ *
+ * @returns {Array}
+ */
+ getCreditCardNetworks() {
+ return lazy.CreditCard.getSupportedNetworks();
+ },
+
+ getCategoryFromFieldName(fieldName) {
+ return this._fieldNameInfo[fieldName];
+ },
+
+ getCategoriesFromFieldNames(fieldNames) {
+ let categories = new Set();
+ for (let fieldName of fieldNames) {
+ let info = this.getCategoryFromFieldName(fieldName);
+ if (info) {
+ categories.add(info);
+ }
+ }
+ return Array.from(categories);
+ },
+
+ getAddressSeparator() {
+ // The separator should be based on the L10N address format, and using a
+ // white space is a temporary solution.
+ return " ";
+ },
+
+ /**
+ * Get address display label. It should display information separated
+ * by a comma.
+ *
+ * @param {object} address
+ * @param {string?} addressFields Override the fields which can be displayed, but not the order.
+ * @returns {string}
+ */
+ getAddressLabel(address, addressFields = null) {
+ // TODO: Implement a smarter way for deciding what to display
+ // as option text. Possibly improve the algorithm in
+ // ProfileAutoCompleteResult.jsm and reuse it here.
+ let fieldOrder = [
+ "name",
+ "-moz-street-address-one-line", // Street address
+ "address-level3", // Townland / Neighborhood / Village
+ "address-level2", // City/Town
+ "organization", // Company or organization name
+ "address-level1", // Province/State (Standardized code if possible)
+ "country-name", // Country name
+ "postal-code", // Postal code
+ "tel", // Phone number
+ "email", // Email address
+ ];
+
+ address = { ...address };
+ let parts = [];
+ if (addressFields) {
+ let requiredFields = addressFields.trim().split(/\s+/);
+ fieldOrder = fieldOrder.filter(name => requiredFields.includes(name));
+ }
+ if (address["street-address"]) {
+ address["-moz-street-address-one-line"] = this.toOneLineAddress(
+ address["street-address"]
+ );
+ }
+ for (const fieldName of fieldOrder) {
+ let string = address[fieldName];
+ if (string) {
+ parts.push(string);
+ }
+ if (parts.length == 2 && !addressFields) {
+ break;
+ }
+ }
+ return parts.join(", ");
+ },
+
+ /**
+ * Internal method to split an address to multiple parts per the provided delimiter,
+ * removing blank parts.
+ *
+ * @param {string} address The address the split
+ * @param {string} [delimiter] The separator that is used between lines in the address
+ * @returns {string[]}
+ */
+ _toStreetAddressParts(address, delimiter = "\n") {
+ let array = typeof address == "string" ? address.split(delimiter) : address;
+
+ if (!Array.isArray(array)) {
+ return [];
+ }
+ return array.map(s => (s ? s.trim() : "")).filter(s => s);
+ },
+
+ /**
+ * Converts a street address to a single line, removing linebreaks marked by the delimiter
+ *
+ * @param {string} address The address the convert
+ * @param {string} [delimiter] The separator that is used between lines in the address
+ * @returns {string}
+ */
+ toOneLineAddress(address, delimiter = "\n") {
+ let addressParts = this._toStreetAddressParts(address, delimiter);
+ return addressParts.join(this.getAddressSeparator());
+ },
+
+ /**
+ * Compares two addresses, removing internal whitespace
+ *
+ * @param {string} a The first address to compare
+ * @param {string} b The second address to compare
+ * @param {Array} collators Search collators that will be used for comparison
+ * @param {string} [delimiter="\n"] The separator that is used between lines in the address
+ * @returns {boolean} True if the addresses are equal, false otherwise
+ */
+ compareStreetAddress(a, b, collators, delimiter = "\n") {
+ let oneLineA = this._toStreetAddressParts(a, delimiter)
+ .map(p => p.replace(/\s/g, ""))
+ .join("");
+ let oneLineB = this._toStreetAddressParts(b, delimiter)
+ .map(p => p.replace(/\s/g, ""))
+ .join("");
+ return this.strCompare(oneLineA, oneLineB, collators);
+ },
+
+ /**
+ * In-place concatenate tel-related components into a single "tel" field and
+ * delete unnecessary fields.
+ *
+ * @param {object} address An address record.
+ */
+ compressTel(address) {
+ let telCountryCode = address["tel-country-code"] || "";
+ let telAreaCode = address["tel-area-code"] || "";
+
+ if (!address.tel) {
+ if (address["tel-national"]) {
+ address.tel = telCountryCode + address["tel-national"];
+ } else if (address["tel-local"]) {
+ address.tel = telCountryCode + telAreaCode + address["tel-local"];
+ } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) {
+ address.tel =
+ telCountryCode +
+ telAreaCode +
+ address["tel-local-prefix"] +
+ address["tel-local-suffix"];
+ }
+ }
+
+ for (let field in address) {
+ if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") {
+ delete address[field];
+ }
+ }
+ },
+
+ autofillFieldSelector(doc) {
+ return doc.querySelectorAll("input, select");
+ },
+
+ /**
+ * Determines if an element can be autofilled or not.
+ *
+ * @param {HTMLElement} element
+ * @returns {boolean} true if the element can be autofilled
+ */
+ isFieldAutofillable(element) {
+ return element && !element.readOnly && !element.disabled;
+ },
+
+ /**
+ * Determines if an element is visually hidden or not.
+ *
+ * NOTE: this does not encompass every possible way of hiding an element.
+ * Instead, we check some of the more common methods of hiding for performance reasons.
+ * See Bug 1727832 for follow up.
+ *
+ * @param {HTMLElement} element
+ * @returns {boolean} true if the element is visible
+ */
+ isFieldVisible(element) {
+ if (element.hidden) {
+ return false;
+ }
+ if (element.style.display == "none") {
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Determines if an element is eligible to be used by credit card or address autofill.
+ *
+ * @param {HTMLElement} element
+ * @returns {boolean} true if element can be used by credit card or address autofill
+ */
+ isCreditCardOrAddressFieldType(element) {
+ if (!element) {
+ return false;
+ }
+ if (HTMLInputElement.isInstance(element)) {
+ // `element.type` can be recognized as `text`, if it's missing or invalid.
+ if (!ELIGIBLE_INPUT_TYPES.includes(element.type)) {
+ return false;
+ }
+ // If the field is visually invisible, we do not want to autofill into it.
+ if (!this.isFieldVisible(element)) {
+ return false;
+ }
+ } else if (!HTMLSelectElement.isInstance(element)) {
+ return false;
+ }
+
+ return true;
+ },
+
+ loadDataFromScript(url, sandbox = {}) {
+ Services.scriptloader.loadSubScript(url, sandbox);
+ return sandbox;
+ },
+
+ /**
+ * Get country address data and fallback to US if not found.
+ * See AddressDataLoader._loadData for more details of addressData structure.
+ *
+ * @param {string} [country=FormAutofill.DEFAULT_REGION]
+ * The country code for requesting specific country's metadata. It'll be
+ * default region if parameter is not set.
+ * @param {string} [level1=null]
+ * Return address level 1/level 2 metadata if parameter is set.
+ * @returns {object|null}
+ * Return metadata of specific region with default locale and other supported
+ * locales. We need to return a default country metadata for layout format
+ * and collator, but for sub-region metadata we'll just return null if not found.
+ */
+ getCountryAddressRawData(
+ country = FormAutofill.DEFAULT_REGION,
+ level1 = null
+ ) {
+ let metadata = AddressDataLoader.getData(country, level1);
+ if (!metadata) {
+ if (level1) {
+ return null;
+ }
+ // Fallback to default region if we couldn't get data from given country.
+ if (country != FormAutofill.DEFAULT_REGION) {
+ metadata = AddressDataLoader.getData(FormAutofill.DEFAULT_REGION);
+ }
+ }
+
+ // TODO: Now we fallback to US if we couldn't get data from default region,
+ // but it could be removed in bug 1423464 if it's not necessary.
+ if (!metadata) {
+ metadata = AddressDataLoader.getData("US");
+ }
+ return metadata;
+ },
+
+ /**
+ * Get country address data with default locale.
+ *
+ * @param {string} country
+ * @param {string} level1
+ * @returns {object|null} Return metadata of specific region with default locale.
+ * NOTE: The returned data may be for a default region if the
+ * specified one cannot be found. Callers who only want the specific
+ * region should check the returned country code.
+ */
+ getCountryAddressData(country, level1) {
+ let metadata = this.getCountryAddressRawData(country, level1);
+ return metadata && metadata.defaultLocale;
+ },
+
+ /**
+ * Get country address data with all locales.
+ *
+ * @param {string} country
+ * @param {string} level1
+ * @returns {Array<object> | null}
+ * Return metadata of specific region with all the locales.
+ * NOTE: The returned data may be for a default region if the
+ * specified one cannot be found. Callers who only want the specific
+ * region should check the returned country code.
+ */
+ getCountryAddressDataWithLocales(country, level1) {
+ let metadata = this.getCountryAddressRawData(country, level1);
+ return metadata && [metadata.defaultLocale, ...metadata.locales];
+ },
+
+ /**
+ * Get the collators based on the specified country.
+ *
+ * @param {string} country The specified country.
+ * @returns {Array} An array containing several collator objects.
+ */
+ getSearchCollators(country) {
+ // TODO: Only one language should be used at a time per country. The locale
+ // of the page should be taken into account to do this properly.
+ // We are going to support more countries in bug 1370193 and this
+ // should be addressed when we start to implement that bug.
+
+ if (!this._collators[country]) {
+ let dataset = this.getCountryAddressData(country);
+ let languages = dataset.languages || [dataset.lang];
+ let options = {
+ ignorePunctuation: true,
+ sensitivity: "base",
+ usage: "search",
+ };
+ this._collators[country] = languages.map(
+ lang => new Intl.Collator(lang, options)
+ );
+ }
+ return this._collators[country];
+ },
+
+ // Based on the list of fields abbreviations in
+ // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata
+ FIELDS_LOOKUP: {
+ N: "name",
+ O: "organization",
+ A: "street-address",
+ S: "address-level1",
+ C: "address-level2",
+ D: "address-level3",
+ Z: "postal-code",
+ n: "newLine",
+ },
+
+ /**
+ * Parse a country address format string and outputs an array of fields.
+ * Spaces, commas, and other literals are ignored in this implementation.
+ * For example, format string "%A%n%C, %S" should return:
+ * [
+ * {fieldId: "street-address", newLine: true},
+ * {fieldId: "address-level2"},
+ * {fieldId: "address-level1"},
+ * ]
+ *
+ * @param {string} fmt Country address format string
+ * @returns {Array<object>} List of fields
+ */
+ parseAddressFormat(fmt) {
+ if (!fmt) {
+ throw new Error("fmt string is missing.");
+ }
+
+ return fmt.match(/%[^%]/g).reduce((parsed, part) => {
+ // Take the first letter of each segment and try to identify it
+ let fieldId = this.FIELDS_LOOKUP[part[1]];
+ // Early return if cannot identify part.
+ if (!fieldId) {
+ return parsed;
+ }
+ // If a new line is detected, add an attribute to the previous field.
+ if (fieldId == "newLine") {
+ let size = parsed.length;
+ if (size) {
+ parsed[size - 1].newLine = true;
+ }
+ return parsed;
+ }
+ return parsed.concat({ fieldId });
+ }, []);
+ },
+
+ /**
+ * Used to populate dropdowns in the UI (e.g. FormAutofill preferences).
+ * Use findAddressSelectOption for matching a value to a region.
+ *
+ * @param {string[]} subKeys An array of regionCode strings
+ * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key
+ * @param {string[]} subNames An array of regionName strings
+ * @param {string[]} subLnames An array of latinised regionName strings
+ * @returns {Map?} Returns null if subKeys or subNames are not truthy.
+ * Otherwise, a Map will be returned mapping keys -> names.
+ */
+ buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) {
+ // Not all regions have sub_keys. e.g. DE
+ if (
+ !subKeys ||
+ !subKeys.length ||
+ (!subNames && !subLnames) ||
+ (subNames && subKeys.length != subNames.length) ||
+ (subLnames && subKeys.length != subLnames.length)
+ ) {
+ return null;
+ }
+
+ // Overwrite subKeys with subIsoids, when available
+ if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) {
+ for (let i = 0; i < subIsoids.length; i++) {
+ if (subIsoids[i]) {
+ subKeys[i] = subIsoids[i];
+ }
+ }
+ }
+
+ // Apply sub_lnames if sub_names does not exist
+ let names = subNames || subLnames;
+ return new Map(subKeys.map((key, index) => [key, names[index]]));
+ },
+
+ /**
+ * Parse a require string and outputs an array of fields.
+ * Spaces, commas, and other literals are ignored in this implementation.
+ * For example, a require string "ACS" should return:
+ * ["street-address", "address-level2", "address-level1"]
+ *
+ * @param {string} requireString Country address require string
+ * @returns {Array<string>} List of fields
+ */
+ parseRequireString(requireString) {
+ if (!requireString) {
+ throw new Error("requireString string is missing.");
+ }
+
+ return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]);
+ },
+
+ /**
+ * Use alternative country name list to identify a country code from a
+ * specified country name.
+ *
+ * @param {string} countryName A country name to be identified
+ * @param {string} [countrySpecified] A country code indicating that we only
+ * search its alternative names if specified.
+ * @returns {string} The matching country code.
+ */
+ identifyCountryCode(countryName, countrySpecified) {
+ let countries = countrySpecified
+ ? [countrySpecified]
+ : [...FormAutofill.countries.keys()];
+
+ for (let country of countries) {
+ let collators = this.getSearchCollators(country);
+ let metadata = this.getCountryAddressData(country);
+ if (country != metadata.key) {
+ // We hit the fallback logic in getCountryAddressRawData so ignore it as
+ // it's not related to `country` and use the name from l10n instead.
+ metadata = {
+ id: `data/${country}`,
+ key: country,
+ name: FormAutofill.countries.get(country),
+ };
+ }
+ let alternativeCountryNames = metadata.alternative_names || [
+ metadata.name,
+ ];
+ let reAlternativeCountryNames = this._reAlternativeCountryNames[country];
+ if (!reAlternativeCountryNames) {
+ reAlternativeCountryNames = this._reAlternativeCountryNames[
+ country
+ ] = [];
+ }
+
+ for (let i = 0; i < alternativeCountryNames.length; i++) {
+ let name = alternativeCountryNames[i];
+ let reName = reAlternativeCountryNames[i];
+ if (!reName) {
+ reName = reAlternativeCountryNames[i] = new RegExp(
+ "\\b" + this.escapeRegExp(name) + "\\b",
+ "i"
+ );
+ }
+
+ if (
+ this.strCompare(name, countryName, collators) ||
+ reName.test(countryName)
+ ) {
+ return country;
+ }
+ }
+ }
+
+ return null;
+ },
+
+ findSelectOption(selectEl, record, fieldName) {
+ if (this.isAddressField(fieldName)) {
+ return this.findAddressSelectOption(selectEl, record, fieldName);
+ }
+ if (this.isCreditCardField(fieldName)) {
+ return this.findCreditCardSelectOption(selectEl, record, fieldName);
+ }
+ return null;
+ },
+
+ /**
+ * Try to find the abbreviation of the given sub-region name
+ *
+ * @param {string[]} subregionValues A list of inferable sub-region values.
+ * @param {string} [country] A country name to be identified.
+ * @returns {string} The matching sub-region abbreviation.
+ */
+ getAbbreviatedSubregionName(subregionValues, country) {
+ let values = Array.isArray(subregionValues)
+ ? subregionValues
+ : [subregionValues];
+
+ let collators = this.getSearchCollators(country);
+ for (let metadata of this.getCountryAddressDataWithLocales(country)) {
+ let {
+ sub_keys: subKeys,
+ sub_names: subNames,
+ sub_lnames: subLnames,
+ } = metadata;
+ if (!subKeys) {
+ // Not all regions have sub_keys. e.g. DE
+ continue;
+ }
+ // Apply sub_lnames if sub_names does not exist
+ subNames = subNames || subLnames;
+
+ let speculatedSubIndexes = [];
+ for (const val of values) {
+ let identifiedValue = this.identifyValue(
+ subKeys,
+ subNames,
+ val,
+ collators
+ );
+ if (identifiedValue) {
+ return identifiedValue;
+ }
+
+ // Predict the possible state by partial-matching if no exact match.
+ [subKeys, subNames].forEach(sub => {
+ speculatedSubIndexes.push(
+ sub.findIndex(token => {
+ let pattern = new RegExp(
+ "\\b" + this.escapeRegExp(token) + "\\b"
+ );
+
+ return pattern.test(val);
+ })
+ );
+ });
+ }
+ let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)];
+ if (subKey) {
+ return subKey;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Find the option element from select element.
+ * 1. Try to find the locale using the country from address.
+ * 2. First pass try to find exact match.
+ * 3. Second pass try to identify values from address value and options,
+ * and look for a match.
+ *
+ * @param {DOMElement} selectEl
+ * @param {object} address
+ * @param {string} fieldName
+ * @returns {DOMElement}
+ */
+ findAddressSelectOption(selectEl, address, fieldName) {
+ if (selectEl.options.length > 512) {
+ // Allow enough space for all countries (roughly 300 distinct values) and all
+ // timezones (roughly 400 distinct values), plus some extra wiggle room.
+ return null;
+ }
+ let value = address[fieldName];
+ if (!value) {
+ return null;
+ }
+
+ let collators = this.getSearchCollators(address.country);
+
+ for (let option of selectEl.options) {
+ if (
+ this.strCompare(value, option.value, collators) ||
+ this.strCompare(value, option.text, collators)
+ ) {
+ return option;
+ }
+ }
+
+ switch (fieldName) {
+ case "address-level1": {
+ let { country } = address;
+ let identifiedValue = this.getAbbreviatedSubregionName(
+ [value],
+ country
+ );
+ // No point going any further if we cannot identify value from address level 1
+ if (!identifiedValue) {
+ return null;
+ }
+ for (let dataset of this.getCountryAddressDataWithLocales(country)) {
+ let keys = dataset.sub_keys;
+ if (!keys) {
+ // Not all regions have sub_keys. e.g. DE
+ continue;
+ }
+ // Apply sub_lnames if sub_names does not exist
+ let names = dataset.sub_names || dataset.sub_lnames;
+
+ // Go through options one by one to find a match.
+ // Also check if any option contain the address-level1 key.
+ let pattern = new RegExp(
+ "\\b" + this.escapeRegExp(identifiedValue) + "\\b",
+ "i"
+ );
+ for (let option of selectEl.options) {
+ let optionValue = this.identifyValue(
+ keys,
+ names,
+ option.value,
+ collators
+ );
+ let optionText = this.identifyValue(
+ keys,
+ names,
+ option.text,
+ collators
+ );
+ if (
+ identifiedValue === optionValue ||
+ identifiedValue === optionText ||
+ pattern.test(option.value)
+ ) {
+ return option;
+ }
+ }
+ }
+ break;
+ }
+ case "country": {
+ if (this.getCountryAddressData(value).alternative_names) {
+ for (let option of selectEl.options) {
+ if (
+ this.identifyCountryCode(option.text, value) ||
+ this.identifyCountryCode(option.value, value)
+ ) {
+ return option;
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ return null;
+ },
+
+ findCreditCardSelectOption(selectEl, creditCard, fieldName) {
+ let oneDigitMonth = creditCard["cc-exp-month"]
+ ? creditCard["cc-exp-month"].toString()
+ : null;
+ let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null;
+ let fourDigitsYear = creditCard["cc-exp-year"]
+ ? creditCard["cc-exp-year"].toString()
+ : null;
+ let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null;
+ let options = Array.from(selectEl.options);
+
+ switch (fieldName) {
+ case "cc-exp-month": {
+ if (!oneDigitMonth) {
+ return null;
+ }
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(s => {
+ let result = /[1-9]\d*/.exec(s);
+ return result && result[0] == oneDigitMonth;
+ })
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-exp-year": {
+ if (!fourDigitsYear) {
+ return null;
+ }
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(
+ s => s == twoDigitsYear || s == fourDigitsYear
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-exp": {
+ if (!oneDigitMonth || !fourDigitsYear) {
+ return null;
+ }
+ let patterns = [
+ oneDigitMonth + "/" + twoDigitsYear, // 8/22
+ oneDigitMonth + "/" + fourDigitsYear, // 8/2022
+ twoDigitsMonth + "/" + twoDigitsYear, // 08/22
+ twoDigitsMonth + "/" + fourDigitsYear, // 08/2022
+ oneDigitMonth + "-" + twoDigitsYear, // 8-22
+ oneDigitMonth + "-" + fourDigitsYear, // 8-2022
+ twoDigitsMonth + "-" + twoDigitsYear, // 08-22
+ twoDigitsMonth + "-" + fourDigitsYear, // 08-2022
+ twoDigitsYear + "-" + twoDigitsMonth, // 22-08
+ fourDigitsYear + "-" + twoDigitsMonth, // 2022-08
+ fourDigitsYear + "/" + oneDigitMonth, // 2022/8
+ twoDigitsMonth + twoDigitsYear, // 0822
+ twoDigitsYear + twoDigitsMonth, // 2208
+ ];
+
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(str =>
+ patterns.some(pattern => str.includes(pattern))
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-type": {
+ let network = creditCard["cc-type"] || "";
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(
+ s => lazy.CreditCard.getNetworkFromName(s) == network
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Try to match value with keys and names, but always return the key.
+ *
+ * @param {Array<string>} keys
+ * @param {Array<string>} names
+ * @param {string} value
+ * @param {Array} collators
+ * @returns {string}
+ */
+ identifyValue(keys, names, value, collators) {
+ let resultKey = keys.find(key => this.strCompare(value, key, collators));
+ if (resultKey) {
+ return resultKey;
+ }
+
+ let index = names.findIndex(name =>
+ this.strCompare(value, name, collators)
+ );
+ if (index !== -1) {
+ return keys[index];
+ }
+
+ return null;
+ },
+
+ /**
+ * Compare if two strings are the same.
+ *
+ * @param {string} a
+ * @param {string} b
+ * @param {Array} collators
+ * @returns {boolean}
+ */
+ strCompare(a = "", b = "", collators) {
+ return collators.some(collator => !collator.compare(a, b));
+ },
+
+ /**
+ * Escaping user input to be treated as a literal string within a regular
+ * expression.
+ *
+ * @param {string} string
+ * @returns {string}
+ */
+ escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ },
+
+ /**
+ * Get formatting information of a given country
+ *
+ * @param {string} country
+ * @returns {object}
+ * {
+ * {string} addressLevel3L10nId
+ * {string} addressLevel2L10nId
+ * {string} addressLevel1L10nId
+ * {string} postalCodeL10nId
+ * {object} fieldsOrder
+ * {string} postalCodePattern
+ * }
+ */
+ getFormFormat(country) {
+ let dataset = this.getCountryAddressData(country);
+ // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here.
+ if (country != dataset.key) {
+ // Use a sparse object so the below default values take effect.
+ dataset = {
+ /**
+ * Even though data/ZZ only has address-level2, include the other levels
+ * in case they are needed for unknown countries. Users can leave the
+ * unnecessary fields blank which is better than forcing users to enter
+ * the data in incorrect fields.
+ */
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ };
+ }
+ return {
+ // When particular values are missing for a country, the
+ // data/ZZ value should be used instead:
+ // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ
+ addressLevel3L10nId: this.getAddressFieldL10nId(
+ dataset.sublocality_name_type || "suburb"
+ ),
+ addressLevel2L10nId: this.getAddressFieldL10nId(
+ dataset.locality_name_type || "city"
+ ),
+ addressLevel1L10nId: this.getAddressFieldL10nId(
+ dataset.state_name_type || "province"
+ ),
+ addressLevel1Options: this.buildRegionMapIfAvailable(
+ dataset.sub_keys,
+ dataset.sub_isoids,
+ dataset.sub_names,
+ dataset.sub_lnames
+ ),
+ countryRequiredFields: this.parseRequireString(dataset.require || "AC"),
+ fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"),
+ postalCodeL10nId: this.getAddressFieldL10nId(
+ dataset.zip_name_type || "postal-code"
+ ),
+ postalCodePattern: dataset.zip,
+ };
+ },
+
+ getAddressFieldL10nId(type) {
+ return "autofill-address-" + type.replace(/_/g, "-");
+ },
+
+ CC_FATHOM_NONE: 0,
+ CC_FATHOM_JS: 1,
+ CC_FATHOM_NATIVE: 2,
+ isFathomCreditCardsEnabled() {
+ return this.ccHeuristicsMode != this.CC_FATHOM_NONE;
+ },
+
+ /**
+ * Transform the key in FormAutofillConfidences (defined in ChromeUtils.webidl)
+ * to fathom recognized field type.
+ *
+ * @param {string} key key from FormAutofillConfidences dictionary
+ * @returns {string} fathom field type
+ */
+ formAutofillConfidencesKeyToCCFieldType(key) {
+ const MAP = {
+ ccNumber: "cc-number",
+ ccName: "cc-name",
+ ccType: "cc-type",
+ ccExp: "cc-exp",
+ ccExpMonth: "cc-exp-month",
+ ccExpYear: "cc-exp-year",
+ };
+ return MAP[key];
+ },
+};
+
+const LabelUtils = {
+ // The tag name list is from Chromium except for "STYLE":
+ // eslint-disable-next-line max-len
+ // https://cs.chromium.org/chromium/src/components/autofill/content/renderer/form_autofill_util.cc?l=216&rcl=d33a171b7c308a64dc3372fac3da2179c63b419e
+ EXCLUDED_TAGS: ["SCRIPT", "NOSCRIPT", "OPTION", "STYLE"],
+
+ // A map object, whose keys are the id's of form fields and each value is an
+ // array consisting of label elements correponding to the id.
+ // @type {Map<string, array>}
+ _mappedLabels: null,
+
+ // An array consisting of label elements whose correponding form field doesn't
+ // have an id attribute.
+ // @type {Array<[HTMLLabelElement, HTMLElement]>}
+ _unmappedLabelControls: null,
+
+ // A weak map consisting of label element and extracted strings pairs.
+ // @type {WeakMap<HTMLLabelElement, array>}
+ _labelStrings: null,
+
+ /**
+ * Extract all strings of an element's children to an array.
+ * "element.textContent" is a string which is merged of all children nodes,
+ * and this function provides an array of the strings contains in an element.
+ *
+ * @param {object} element
+ * A DOM element to be extracted.
+ * @returns {Array}
+ * All strings in an element.
+ */
+ extractLabelStrings(element) {
+ if (this._labelStrings.has(element)) {
+ return this._labelStrings.get(element);
+ }
+ let strings = [];
+ let _extractLabelStrings = el => {
+ if (this.EXCLUDED_TAGS.includes(el.tagName)) {
+ return;
+ }
+
+ if (el.nodeType == el.TEXT_NODE || !el.childNodes.length) {
+ let trimmedText = el.textContent.trim();
+ if (trimmedText) {
+ strings.push(trimmedText);
+ }
+ return;
+ }
+
+ for (let node of el.childNodes) {
+ let nodeType = node.nodeType;
+ if (nodeType != node.ELEMENT_NODE && nodeType != node.TEXT_NODE) {
+ continue;
+ }
+ _extractLabelStrings(node);
+ }
+ };
+ _extractLabelStrings(element);
+ this._labelStrings.set(element, strings);
+ return strings;
+ },
+
+ generateLabelMap(doc) {
+ this._mappedLabels = new Map();
+ this._unmappedLabelControls = [];
+ this._labelStrings = new WeakMap();
+
+ for (let label of doc.querySelectorAll("label")) {
+ let id = label.htmlFor;
+ let control;
+ if (!id) {
+ control = label.control;
+ if (!control) {
+ continue;
+ }
+ id = control.id;
+ }
+ if (id) {
+ let labels = this._mappedLabels.get(id);
+ if (labels) {
+ labels.push(label);
+ } else {
+ this._mappedLabels.set(id, [label]);
+ }
+ } else {
+ // control must be non-empty here
+ this._unmappedLabelControls.push({ label, control });
+ }
+ }
+ },
+
+ clearLabelMap() {
+ this._mappedLabels = null;
+ this._unmappedLabelControls = null;
+ this._labelStrings = null;
+ },
+
+ findLabelElements(element) {
+ if (!this._mappedLabels) {
+ this.generateLabelMap(element.ownerDocument);
+ }
+
+ let id = element.id;
+ if (!id) {
+ return this._unmappedLabelControls
+ .filter(lc => lc.control == element)
+ .map(lc => lc.label);
+ }
+ return this._mappedLabels.get(id) || [];
+ },
+};
+
+XPCOMUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function() {
+ return Services.strings.createBundle(
+ "chrome://formautofill/locale/formautofill.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function() {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "_reauthEnabledByUser",
+ "extensions.formautofill.reauth.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccHeuristicsMode",
+ "extensions.formautofill.creditCards.heuristics.mode",
+ 0
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomConfidenceThreshold",
+ "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold",
+ null,
+ null,
+ pref => parseFloat(pref)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomHighConfidenceThreshold",
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ null,
+ null,
+ pref => parseFloat(pref)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomTestConfidence",
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ null,
+ null,
+ pref => parseFloat(pref)
+);