Source code
Revision control
Copy as Markdown
Other Tools
/* 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
import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs",
CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
FormAutofillNameUtils:
"resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
AddressMetaDataLoader:
"resource://gre/modules/shared/AddressMetaDataLoader.sys.mjs",
});
ChromeUtils.defineLazyGetter(
lazy,
"l10n",
() =>
new Localization(
["toolkit/formautofill/formAutofill.ftl", "branding/brand.ftl"],
true
)
);
XPCOMUtils.defineLazyServiceGetter(
lazy,
"Crypto",
"@mozilla.org/login-manager/crypto/SDR;1",
"nsILoginManagerCrypto"
);
export let FormAutofillUtils;
const ADDRESSES_COLLECTION_NAME = "addresses";
const CREDITCARDS_COLLECTION_NAME = "creditCards";
const AUTOFILL_CREDITCARDS_REAUTH_PREF =
FormAutofill.AUTOFILL_CREDITCARDS_REAUTH_PREF;
const MANAGE_ADDRESSES_L10N_IDS = [
"autofill-add-address-title",
"autofill-manage-addresses-title",
];
const EDIT_ADDRESS_L10N_IDS = [
"autofill-address-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",
"autofill-edit-address-title",
"autofill-address-neighborhood",
"autofill-address-village-township",
"autofill-address-island",
"autofill-address-townland",
"autofill-address-district",
"autofill-address-county",
"autofill-address-post-town",
"autofill-address-suburb",
"autofill-address-parish",
"autofill-address-prefecture",
"autofill-address-area",
"autofill-address-do-si",
"autofill-address-department",
"autofill-address-emirate",
"autofill-address-oblast",
"autofill-address-pin",
"autofill-address-eircode",
"autofill-address-country-only",
"autofill-cancel-button",
"autofill-save-button",
];
const MANAGE_CREDITCARDS_L10N_IDS = [
"autofill-add-card-title",
"autofill-manage-payment-methods-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: "",
AUTO_FILLED: "autofill",
PREVIEW: "preview",
};
const FORM_SUBMISSION_REASON = {
FORM_SUBMIT_EVENT: "form-submit-event",
FORM_REMOVAL_AFTER_FETCH: "form-removal-after-fetch",
IFRAME_PAGEHIDE: "iframe-pagehide",
PAGE_NAVIGATION: "page-navigation",
};
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;
FormAutofillUtils = {
get AUTOFILL_FIELDS_THRESHOLD() {
return 3;
},
ADDRESSES_COLLECTION_NAME,
CREDITCARDS_COLLECTION_NAME,
AUTOFILL_CREDITCARDS_REAUTH_PREF,
MANAGE_ADDRESSES_L10N_IDS,
EDIT_ADDRESS_L10N_IDS,
MANAGE_CREDITCARDS_L10N_IDS,
EDIT_CREDITCARD_L10N_IDS,
MAX_FIELD_VALUE_LENGTH,
FIELD_STATES,
FORM_SUBMISSION_REASON,
_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",
// DE addresses are often split into street name and house number;
// combined they form address-line1
"address-streetname": "address",
"address-housenumber": "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",
"cc-csc": "creditCard",
},
_collators: {},
_reAlternativeCountryNames: {},
isAddressField(fieldName) {
return (
!!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName)
);
},
isCreditCardField(fieldName) {
return this._fieldNameInfo?.[fieldName] == "creditCard";
},
isCCNumber(ccNumber) {
return ccNumber && lazy.CreditCard.isValidNumber(ccNumber);
},
/**
* Get the decrypted value for a string pref.
*
* @param {string} prefName -> The pref whose value is needed.
* @param {string} safeDefaultValue -> Value to be returned incase the pref is not yet set.
* @returns {string}
*/
getSecurePref(prefName, safeDefaultValue) {
if (Services.prefs.getBoolPref("security.nocertdb", false)) {
return false;
}
try {
const encryptedValue = Services.prefs.getStringPref(prefName, "");
return encryptedValue === ""
? safeDefaultValue
: lazy.Crypto.decrypt(encryptedValue);
} catch {
return safeDefaultValue;
}
},
/**
* Set the pref to the encrypted form of the value.
*
* @param {string} prefName -> The pref whose value is to be set.
* @param {string} value -> The value to be set in its encrypted form.
*/
setSecurePref(prefName, value) {
if (Services.prefs.getBoolPref("security.nocertdb", false)) {
return;
}
if (value) {
const encryptedValue = lazy.Crypto.encrypt(value);
Services.prefs.setStringPref(prefName, encryptedValue);
} else {
Services.prefs.clearUserPref(prefName);
}
},
/**
* Get whether the OSAuth is enabled or not.
*
* @param {string} prefName -> The name of the pref (creditcards or addresses)
* @returns {boolean}
*/
getOSAuthEnabled(prefName) {
return (
lazy.OSKeyStore.canReauth() &&
this.getSecurePref(prefName, "") !== "opt out"
);
},
/**
* Set whether the OSAuth is enabled or not.
*
* @param {string} prefName -> The pref to encrypt.
* @param {boolean} enable -> Whether the pref is to be enabled.
*/
setOSAuthEnabled(prefName, enable) {
this.setSecurePref(prefName, enable ? null : "opt out");
},
async verifyUserOSAuth(
prefName,
promptMessage,
captionDialog = "",
parentWindow = null,
generateKeyIfNotAvailable = true
) {
if (!this.getOSAuthEnabled(prefName)) {
promptMessage = false;
}
try {
return (
await lazy.OSKeyStore.ensureLoggedIn(
promptMessage,
captionDialog,
parentWindow,
generateKeyIfNotAvailable
)
).authenticated;
} catch (ex) {
// Since Win throws an exception whereas Mac resolves to false upon cancelling.
if (ex.result !== Cr.NS_ERROR_FAILURE) {
throw ex;
}
}
return 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);
},
getCollectionNameFromFieldName(fieldName) {
return this.isCreditCardField(fieldName)
? CREDITCARDS_COLLECTION_NAME
: ADDRESSES_COLLECTION_NAME;
},
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
* @returns {string}
*/
getAddressLabel(address) {
// TODO: Implement a smarter way for deciding what to display
// as option text. Possibly improve the algorithm in
// ProfileAutoCompleteResult.sys.mjs 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", // Country name
"postal-code", // Postal code
"tel", // Phone number
"email", // Email address
];
address = { ...address };
let parts = [];
if (address["street-address"]) {
address["-moz-street-address-one-line"] = this.toOneLineAddress(
address["street-address"]
);
}
if (!("name" in address)) {
address.name = lazy.FormAutofillNameUtils.joinNameParts({
given: address["given-name"],
middle: address["additional-name"],
family: address["family-name"],
});
}
for (const fieldName of fieldOrder) {
let string = address[fieldName];
if (string) {
parts.push(string);
}
}
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());
},
/**
* Returns false if an address is written <number> <street>
* and true if an address is written <street> <number>. In the future, this
* can be expanded to format an address
*/
getAddressReversed(region) {
return this.getCountryAddressData(region).address_reversed;
},
/**
* 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];
}
}
},
/**
* 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.
*
* @param {HTMLElement} element
* @param {boolean} visibilityCheck true to run visiblity check against
* element.checkVisibility API. Otherwise, test by only checking
* `hidden` and `display` attributes
* @returns {boolean} true if the element is visible
*/
isFieldVisible(element, visibilityCheck = true) {
if (
visibilityCheck &&
element.checkVisibility &&
!FormAutofillUtils.ignoreVisibilityCheck
) {
if (
!element.checkVisibility({
checkOpacity: true,
checkVisibilityCSS: true,
})
) {
return false;
}
} else if (element.hidden || element.style.display == "none") {
return false;
}
return element.getAttribute("aria-hidden") != "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.
return ELIGIBLE_INPUT_TYPES.includes(element.type);
}
return HTMLSelectElement.isInstance(element);
},
loadDataFromScript(url, sandbox = {}) {
Services.scriptloader.loadSubScript(url, sandbox);
return sandbox;
},
/**
* Get country address data and fallback to US if not found.
* See AddressMetaDataLoader.#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 = lazy.AddressMetaDataLoader.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 = lazy.AddressMetaDataLoader.getData(
FormAutofill.DEFAULT_REGION
);
}
}
// TODO: Now we fallback to US if we couldn't get data from default region,
if (!metadata) {
metadata = lazy.AddressMetaDataLoader.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.
* @param {object} [options = {}] a list of options for this method
* @param {boolean} [options.ignorePunctuation = true] Whether punctuation should be ignored.
* @param {string} [options.sensitivity = 'base'] Which differences in the strings should lead to non-zero result values
* @param {string} [options.usage = 'search'] Whether the comparison is for sorting or for searching for matching strings
* @returns {Array} An array containing several collator objects.
*/
getSearchCollators(
country,
{ ignorePunctuation = true, sensitivity = "base", usage = "search" } = {}
) {
// 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.
// 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,
sensitivity,
usage,
};
this._collators[country] = languages.map(
lang => new Intl.Collator(lang, options)
);
}
return this._collators[country];
},
// Based on the list of fields abbreviations in
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 address data and 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) {
if (!countryName) {
return null;
}
if (lazy.AddressMetaDataLoader.getData(countryName)) {
return countryName;
}
const countries = countrySpecified
? [countrySpecified]
: [...FormAutofill.countries.keys()];
for (const 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] =
[];
}
if (countryName.length == 3) {
if (this.strCompare(metadata.alpha_3_code, countryName, collators)) {
return 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.options, 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 {Array<{text: string, value: string}>} options
* @param {object} address
* @param {string} fieldName
* @returns {DOMElement}
*/
findAddressSelectOption(options, address, fieldName) {
if (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 (const option of 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 (const option of options) {
let optionValue = this.identifyValue(
keys,
names,
option.value,
collators
);
let optionText = this.identifyValue(
keys,
names,
option.text,
collators,
true
);
if (
identifiedValue === optionValue ||
identifiedValue === optionText ||
pattern.test(option.value)
) {
return option;
}
}
}
break;
}
case "country": {
if (this.getCountryAddressData(value)) {
for (const option of options) {
if (
this.identifyCountryCode(option.text, value) ||
this.identifyCountryCode(option.value, value)
) {
return option;
}
}
}
break;
}
}
return null;
},
/**
* Find the option element from xul menu popups, as used in address capture
* doorhanger.
*
* This is a proxy to `findAddressSelectOption`, which expects HTML select
* DOM nodes and operates on options instead of xul menuitems.
*
* method will then be removed `findAddressSelectOption` will be used
* directly.
*
* @param {XULPopupElement} menupopup
* @param {object} address
* @param {string} fieldName
* @returns {XULElement}
*/
findAddressSelectOptionWithMenuPopup(menupopup, address, fieldName) {
const options = Array.from(menupopup.childNodes).map(menuitem => ({
text: menuitem.label,
value: menuitem.value,
menuitem,
}));
return this.findAddressSelectOption(options, address, fieldName)?.menuitem;
},
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.
* If inexactMatch is true, then a substring match is performed, otherwise
* the string must match exactly.
*
* @param {Array<string>} keys
* @param {Array<string>} names
* @param {string} value
* @param {Array} collators
* @param {bool} inexactMatch
* @returns {string}
*/
identifyValue(keys, names, value, collators, inexactMatch = false) {
let resultKey = keys.find(key => this.strCompare(value, key, collators));
if (resultKey) {
return resultKey;
}
let index = names.findIndex(name =>
inexactMatch
? this.strInclude(value, name, collators)
: 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));
},
/**
* Determine whether one string(b) may be found within another string(a)
*
* @param {string} a
* @param {string} b
* @param {Array} collators
* @returns {boolean} True if the string is found
*/
strInclude(a = "", b = "", collators) {
const len = a.length - b.length;
for (let i = 0; i <= len; i++) {
if (this.strCompare(a.substring(i, i + b.length), b, collators)) {
return true;
}
}
return false;
},
/**
* 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:
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,
};
},
/**
* Converts a Map to an array of objects with `value` and `text` properties ( option like).
*
* @param {Map} optionsMap
* @returns {Array<{ value: string, text: string }>|null}
*/
optionsMapToArray(optionsMap) {
return optionsMap?.size
? [...optionsMap].map(([value, text]) => ({ value, text }))
: null;
},
/**
* Get flattened form layout information of a given country
*
* @param {object} record - An object containing at least the 'country' property.
* @returns {Array} Flattened array with the address fiels in order.
*/
getFormLayout(record) {
const formFormat = this.getFormFormat(record.country);
let fieldsInOrder = formFormat.fieldsOrder;
// Add missing fields that are always present but not in the .fmt of addresses
// TODO: extend libaddress later to support this if possible
fieldsInOrder = [
...fieldsInOrder,
{
fieldId: "country",
options: this.optionsMapToArray(FormAutofill.countries),
required: true,
},
{ fieldId: "tel", type: "tel" },
{ fieldId: "email", type: "email" },
];
const addressLevel1Options = this.optionsMapToArray(
formFormat.addressLevel1Options
);
const addressLevel1SelectedValue = addressLevel1Options
? this.findAddressSelectOption(
addressLevel1Options,
record,
"address-level1"
)?.value
: record["address-level1"];
for (const field of fieldsInOrder) {
const flattenedObject = {
fieldId: field.fieldId,
newLine: field.newLine,
l10nId: this.getAddressFieldL10nId(field.fieldId),
required: formFormat.countryRequiredFields.includes(field.fieldId),
value: record[field.fieldId] ?? "",
...(field.fieldId === "street-address" && {
l10nId: "autofill-address-street",
multiline: true,
}),
...(field.fieldId === "address-level1" && {
l10nId: formFormat.addressLevel1L10nId,
options: addressLevel1Options,
value: addressLevel1SelectedValue,
}),
...(field.fieldId === "address-level2" && {
l10nId: formFormat.addressLevel2L10nId,
}),
...(field.fieldId === "address-level3" && {
l10nId: formFormat.addressLevel3L10nId,
}),
...(field.fieldId === "postal-code" && {
pattern: formFormat.postalCodePattern,
l10nId: formFormat.postalCodeL10nId,
}),
};
Object.assign(field, flattenedObject);
}
return fieldsInOrder;
},
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];
},
/**
* Generates the localized os dialog message that
* prompts the user to reauthenticate
*
* @param {string} msgMac fluent message id for macos clients
* @param {string} msgWin fluent message id for windows clients
* @param {string} msgOther fluent message id for other clients
* @param {string} msgLin (optional) fluent message id for linux clients
* @returns {string} localized os prompt message
*/
reauthOSPromptMessage(msgMac, msgWin, msgOther, msgLin = null) {
const platform = AppConstants.platform;
let messageID;
switch (platform) {
case "win":
messageID = msgWin;
break;
case "macosx":
messageID = msgMac;
break;
case "linux":
messageID = msgLin ?? msgOther;
break;
default:
messageID = msgOther;
}
return lazy.l10n.formatValueSync(messageID);
},
/**
* Retrieves a unique identifier for a given DOM element.
* Note that the identifier generated by ContentDOMReference is an object but
* this API serializes it to string to make lookup easier.
*
* @param {Element} element The DOM element from which to generate an identifier.
* @returns {string} A unique identifier for the element.
*/
getElementIdentifier(element) {
let id;
try {
id = JSON.stringify(lazy.ContentDOMReference.get(element));
} catch {
// This is needed because when running in xpc-shell test, we don't have
const entry = Object.entries(this._elementByElementId).find(
e => e[1] == element
);
if (entry) {
id = entry[0];
} else {
id = Services.uuid.generateUUID().toString();
this._elementByElementId[id] = element;
}
}
return id;
},
/**
* Maps element identifiers to their corresponding DOM elements.
* Only used when we can't get the identifier via ContentDOMReference,
* for example, xpcshell test.
*/
_elementByElementId: {},
/**
* Retrieves the DOM element associated with the specific identifier.
* The identifier should be generated with the `getElementIdentifier` API
*
* @param {string} elementId The identifier of the element.
* @returns {Element} The DOM element associated with the given identifier.
*/
getElementByIdentifier(elementId) {
let element;
try {
element = lazy.ContentDOMReference.resolve(JSON.parse(elementId));
} catch {
element = this._elementByElementId[elementId];
}
return element;
},
/**
* This function is used to determine the frames that can also be autofilled
* when users trigger autofill on the focusd frame.
*
* Currently we also autofill when for frames that
* 1. is top-level.
* 2. is same origin with the top-level.
* 3. is same origin with the frame that triggers autofill.
*
* @param {BrowsingContext} browsingContext
* frame to be checked whether we can also autofill
*/
isBCSameOriginWithTop(browsingContext) {
return (
browsingContext.top == browsingContext ||
browsingContext.currentWindowGlobal.documentPrincipal.equals(
browsingContext.top.currentWindowGlobal.documentPrincipal
)
);
},
};
ChromeUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function () {
return Services.strings.createBundle(
"chrome://formautofill/locale/formautofill.properties"
);
});
ChromeUtils.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)
);
// This is only used in iOS
XPCOMUtils.defineLazyPreferenceGetter(
FormAutofillUtils,
"focusOnAutofill",
"extensions.formautofill.focusOnAutofill",
true
);
// This is only used for testing
XPCOMUtils.defineLazyPreferenceGetter(
FormAutofillUtils,
"ignoreVisibilityCheck",
"extensions.formautofill.test.ignoreVisibilityCheck",
false
);