diff options
Diffstat (limited to 'browser/extensions/formautofill/ProfileAutoCompleteResult.jsm')
-rw-r--r-- | browser/extensions/formautofill/ProfileAutoCompleteResult.jsm | 516 |
1 files changed, 516 insertions, 0 deletions
diff --git a/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm b/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm new file mode 100644 index 0000000000..f3a8f4b61d --- /dev/null +++ b/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm @@ -0,0 +1,516 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["AddressResult", "CreditCardResult"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "CreditCard", + "resource://gre/modules/CreditCard.jsm" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "insecureWarningEnabled", + "security.insecure_field_warning.contextual.enabled" +); + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +class ProfileAutoCompleteResult { + constructor( + searchString, + focusedFieldName, + allFieldNames, + matchingProfiles, + { resultCode = null, isSecure = true, isInputAutofilled = false } + ) { + log.debug("Constructing new ProfileAutoCompleteResult:", [...arguments]); + + // nsISupports + this.QueryInterface = ChromeUtils.generateQI(["nsIAutoCompleteResult"]); + + // The user's query string + this.searchString = searchString; + // The field name of the focused input. + this._focusedFieldName = focusedFieldName; + // The matching profiles contains the information for filling forms. + this._matchingProfiles = matchingProfiles; + // The default item that should be entered if none is selected + this.defaultIndex = 0; + // The reason the search failed + this.errorDescription = ""; + // The value used to determine whether the form is secure or not. + this._isSecure = isSecure; + // The value to indicate whether the focused input has been autofilled or not. + this._isInputAutofilled = isInputAutofilled; + // All fillable field names in the form including the field name of the currently-focused input. + this._allFieldNames = [ + ...this._matchingProfiles.reduce((fieldSet, curProfile) => { + for (let field of Object.keys(curProfile)) { + fieldSet.add(field); + } + + return fieldSet; + }, new Set()), + ].filter(field => allFieldNames.includes(field)); + + // Force return success code if the focused field is auto-filled in order + // to show clear form button popup. + if (isInputAutofilled) { + resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + } + // The result code of this result object. + if (resultCode) { + this.searchResult = resultCode; + } else if (matchingProfiles.length) { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + } else { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH; + } + + // An array of primary and secondary labels for each profile. + this._popupLabels = this._generateLabels( + this._focusedFieldName, + this._allFieldNames, + this._matchingProfiles + ); + } + + /** + * @returns {number} The number of results + */ + get matchCount() { + return this._popupLabels.length; + } + + _checkIndexBounds(index) { + if (index < 0 || index >= this._popupLabels.length) { + throw Components.Exception( + "Index out of range.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + } + + /** + * Get the secondary label based on the focused field name and related field names + * in the same form. + * @param {string} focusedFieldName The field name of the focused input + * @param {Array<Object>} allFieldNames The field names in the same section + * @param {object} profile The profile providing the labels to show. + * @returns {string} The secondary label + */ + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + return ""; + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) {} + + /** + * Get the value of the result at the given index. + * + * Always return empty string for form autofill feature to suppress + * AutoCompleteController from autofilling, as we'll populate the + * fields on our own. + * + * @param {number} index The index of the result requested + * @returns {string} The result at the specified index + */ + getValueAt(index) { + this._checkIndexBounds(index); + return ""; + } + + getLabelAt(index) { + this._checkIndexBounds(index); + + let label = this._popupLabels[index]; + if (typeof label == "string") { + return label; + } + return JSON.stringify(label); + } + + /** + * Retrieves a comment (metadata instance) + * @param {number} index The index of the comment requested + * @returns {string} The comment at the specified index + */ + getCommentAt(index) { + this._checkIndexBounds(index); + return JSON.stringify(this._matchingProfiles[index]); + } + + /** + * Retrieves a style hint specific to a particular index. + * @param {number} index The index of the style hint requested + * @returns {string} The style hint at the specified index + */ + getStyleAt(index) { + this._checkIndexBounds(index); + if (index == this.matchCount - 1) { + return "autofill-footer"; + } + if (this._isInputAutofilled) { + return "autofill-clear-button"; + } + + return "autofill-profile"; + } + + /** + * Retrieves an image url. + * @param {number} index The index of the image url requested + * @returns {string} The image url at the specified index + */ + getImageAt(index) { + this._checkIndexBounds(index); + return ""; + } + + /** + * Retrieves a result + * @param {number} index The index of the result requested + * @returns {string} The result at the specified index + */ + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + } + + /** + * Removes a result from the resultset + * @param {number} index The index of the result to remove + */ + removeValueAt(index) { + // There is no plan to support removing profiles via autocomplete. + } +} + +class AddressResult extends ProfileAutoCompleteResult { + constructor(...args) { + super(...args); + } + + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + // We group similar fields into the same field name so we won't pick another + // field in the same group as the secondary label. + const GROUP_FIELDS = { + name: ["name", "given-name", "additional-name", "family-name"], + "street-address": [ + "street-address", + "address-line1", + "address-line2", + "address-line3", + ], + "country-name": ["country", "country-name"], + tel: [ + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", + ], + }; + + const secondaryLabelOrder = [ + "street-address", // Street address + "name", // Full name + "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 + ]; + + for (let field in GROUP_FIELDS) { + if (GROUP_FIELDS[field].includes(focusedFieldName)) { + focusedFieldName = field; + break; + } + } + + for (const currentFieldName of secondaryLabelOrder) { + if (focusedFieldName == currentFieldName || !profile[currentFieldName]) { + continue; + } + + let matching = GROUP_FIELDS[currentFieldName] + ? allFieldNames.some(fieldName => + GROUP_FIELDS[currentFieldName].includes(fieldName) + ) + : allFieldNames.includes(currentFieldName); + + if (matching) { + if ( + currentFieldName == "street-address" && + profile["-moz-street-address-one-line"] + ) { + return profile["-moz-street-address-one-line"]; + } + return profile[currentFieldName]; + } + } + + return ""; // Nothing matched. + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) { + if (this._isInputAutofilled) { + return [ + { primary: "", secondary: "" }, // Clear button + { primary: "", secondary: "" }, // Footer + ]; + } + + // Skip results without a primary label. + let labels = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => { + let primaryLabel = profile[focusedFieldName]; + if ( + focusedFieldName == "street-address" && + profile["-moz-street-address-one-line"] + ) { + primaryLabel = profile["-moz-street-address-one-line"]; + } + return { + primary: primaryLabel, + secondary: this._getSecondaryLabel( + focusedFieldName, + allFieldNames, + profile + ), + }; + }); + // Add an empty result entry for footer. Its content will come from + // the footer binding, so don't assign any value to it. + // The additional properties: categories and focusedCategory are required of + // the popup to generate autofill hint on the footer. + labels.push({ + primary: "", + secondary: "", + categories: FormAutofillUtils.getCategoriesFromFieldNames( + this._allFieldNames + ), + focusedCategory: FormAutofillUtils.getCategoryFromFieldName( + this._focusedFieldName + ), + }); + + return labels; + } +} + +class CreditCardResult extends ProfileAutoCompleteResult { + constructor(...args) { + super(...args); + this._cardTypes = this._generateCardTypes( + this._focusedFieldName, + this._allFieldNames, + this._matchingProfiles + ); + } + + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + const GROUP_FIELDS = { + "cc-name": [ + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + ], + "cc-exp": ["cc-exp", "cc-exp-month", "cc-exp-year"], + }; + + const secondaryLabelOrder = [ + "cc-number", // Credit card number + "cc-name", // Full name + "cc-exp", // Expiration date + ]; + + for (let field in GROUP_FIELDS) { + if (GROUP_FIELDS[field].includes(focusedFieldName)) { + focusedFieldName = field; + break; + } + } + + for (const currentFieldName of secondaryLabelOrder) { + if (focusedFieldName == currentFieldName || !profile[currentFieldName]) { + continue; + } + + let matching = GROUP_FIELDS[currentFieldName] + ? allFieldNames.some(fieldName => + GROUP_FIELDS[currentFieldName].includes(fieldName) + ) + : allFieldNames.includes(currentFieldName); + + if (matching) { + if (currentFieldName == "cc-number") { + let { affix, label } = CreditCard.formatMaskedNumber( + profile[currentFieldName] + ); + return affix + label; + } + return profile[currentFieldName]; + } + } + + return ""; // Nothing matched. + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) { + if (!this._isSecure) { + if (!insecureWarningEnabled) { + return []; + } + let brandName = FormAutofillUtils.brandBundle.GetStringFromName( + "brandShortName" + ); + + return [ + FormAutofillUtils.stringBundle.formatStringFromName( + "insecureFieldWarningDescription", + [brandName] + ), + ]; + } + + if (this._isInputAutofilled) { + return [ + { primary: "", secondary: "" }, // Clear button + { primary: "", secondary: "" }, // Footer + ]; + } + + // Skip results without a primary label. + let labels = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => { + let primaryAffix; + let primary = profile[focusedFieldName]; + + if (focusedFieldName == "cc-number") { + let { affix, label } = CreditCard.formatMaskedNumber(primary); + primaryAffix = affix; + primary = label; + } + const secondary = this._getSecondaryLabel( + focusedFieldName, + allFieldNames, + profile + ); + // The card type is displayed visually using an image. For a11y, we need + // to expose it as text. We do this using aria-label. However, + // aria-label overrides the text content, so we must include that also. + let ccTypeName; + try { + ccTypeName = FormAutofillUtils.stringBundle.GetStringFromName( + `cardNetwork.${profile["cc-type"]}` + ); + } catch (e) { + ccTypeName = null; // Unknown. + } + const ariaLabel = [ccTypeName, primaryAffix, primary, secondary] + .filter(chunk => !!chunk) // Exclude empty chunks. + .join(" "); + return { + primaryAffix, + primary, + secondary, + ariaLabel, + }; + }); + // Add an empty result entry for footer. + labels.push({ primary: "", secondary: "" }); + + return labels; + } + + // This method needs to return an array that parallels the + // array returned by _generateLabels, above. As a consequence, + // its logic follows very closely. + _generateCardTypes(focusedFieldName, allFieldNames, profiles) { + if (this._isInputAutofilled) { + return [ + "", // Clear button + "", // Footer + ]; + } + + // Skip results without a primary label. + let cardTypes = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => profile["cc-type"]); + + // Add an empty result entry for footer. + cardTypes.push(""); + return cardTypes; + } + + getStyleAt(index) { + this._checkIndexBounds(index); + if (!this._isSecure && insecureWarningEnabled) { + return "autofill-insecureWarning"; + } + + return super.getStyleAt(index); + } + + getImageAt(index) { + const PATH = "chrome://formautofill/content/"; + const THIRD_PARTY_PATH = PATH + "third-party/"; + + this._checkIndexBounds(index); + switch (this._cardTypes[index]) { + case "amex": + return THIRD_PARTY_PATH + "cc-logo-amex.png"; + case "cartebancaire": + return THIRD_PARTY_PATH + "cc-logo-cartebancaire.png"; + case "diners": + return THIRD_PARTY_PATH + "cc-logo-diners.svg"; + case "discover": + return THIRD_PARTY_PATH + "cc-logo-discover.png"; + case "jcb": + return THIRD_PARTY_PATH + "cc-logo-jcb.svg"; + case "mastercard": + return THIRD_PARTY_PATH + "cc-logo-mastercard.svg"; + case "mir": + return THIRD_PARTY_PATH + "cc-logo-mir.svg"; + case "unionpay": + return THIRD_PARTY_PATH + "cc-logo-unionpay.svg"; + case "visa": + return THIRD_PARTY_PATH + "cc-logo-visa.svg"; + default: + return PATH + "icon-credit-card-generic.svg"; + } + } +} |