Bug 1941046 - Part 4: Send a callback request for impression and clicks of MARS Top...
[gecko.git] / toolkit / components / formautofill / shared / FormAutofillUtils.sys.mjs
blobc5f1dc86de54acef219910076b54918f62525471
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
9 const lazy = {};
10 ChromeUtils.defineESModuleGetters(lazy, {
11   ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs",
12   CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
13   FormAutofillNameUtils:
14     "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
15   OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
16   AddressMetaDataLoader:
17     "resource://gre/modules/shared/AddressMetaDataLoader.sys.mjs",
18 });
20 ChromeUtils.defineLazyGetter(
21   lazy,
22   "l10n",
23   () =>
24     new Localization(
25       ["toolkit/formautofill/formAutofill.ftl", "branding/brand.ftl"],
26       true
27     )
30 XPCOMUtils.defineLazyServiceGetter(
31   lazy,
32   "Crypto",
33   "@mozilla.org/login-manager/crypto/SDR;1",
34   "nsILoginManagerCrypto"
37 export let FormAutofillUtils;
39 const ADDRESSES_COLLECTION_NAME = "addresses";
40 const CREDITCARDS_COLLECTION_NAME = "creditCards";
41 const AUTOFILL_CREDITCARDS_REAUTH_PREF =
42   FormAutofill.AUTOFILL_CREDITCARDS_REAUTH_PREF;
43 const MANAGE_ADDRESSES_L10N_IDS = [
44   "autofill-add-address-title",
45   "autofill-manage-addresses-title",
47 const EDIT_ADDRESS_L10N_IDS = [
48   "autofill-address-name",
49   "autofill-address-organization",
50   "autofill-address-street",
51   "autofill-address-state",
52   "autofill-address-province",
53   "autofill-address-city",
54   "autofill-address-country",
55   "autofill-address-zip",
56   "autofill-address-postal-code",
57   "autofill-address-email",
58   "autofill-address-tel",
59   "autofill-edit-address-title",
60   "autofill-address-neighborhood",
61   "autofill-address-village-township",
62   "autofill-address-island",
63   "autofill-address-townland",
64   "autofill-address-district",
65   "autofill-address-county",
66   "autofill-address-post-town",
67   "autofill-address-suburb",
68   "autofill-address-parish",
69   "autofill-address-prefecture",
70   "autofill-address-area",
71   "autofill-address-do-si",
72   "autofill-address-department",
73   "autofill-address-emirate",
74   "autofill-address-oblast",
75   "autofill-address-pin",
76   "autofill-address-eircode",
77   "autofill-address-country-only",
78   "autofill-cancel-button",
79   "autofill-save-button",
81 const MANAGE_CREDITCARDS_L10N_IDS = [
82   "autofill-add-card-title",
83   "autofill-manage-payment-methods-title",
85 const EDIT_CREDITCARD_L10N_IDS = [
86   "autofill-card-number",
87   "autofill-card-name-on-card",
88   "autofill-card-expires-month",
89   "autofill-card-expires-year",
90   "autofill-card-network",
92 const FIELD_STATES = {
93   NORMAL: "",
94   AUTO_FILLED: "autofill",
95   PREVIEW: "preview",
97 const FORM_SUBMISSION_REASON = {
98   FORM_SUBMIT_EVENT: "form-submit-event",
99   FORM_REMOVAL_AFTER_FETCH: "form-removal-after-fetch",
100   IFRAME_PAGEHIDE: "iframe-pagehide",
101   PAGE_NAVIGATION: "page-navigation",
104 const ELIGIBLE_INPUT_TYPES = ["text", "email", "tel", "number", "month"];
106 // The maximum length of data to be saved in a single field for preventing DoS
107 // attacks that fill the user's hard drive(s).
108 const MAX_FIELD_VALUE_LENGTH = 200;
110 FormAutofillUtils = {
111   get AUTOFILL_FIELDS_THRESHOLD() {
112     return 3;
113   },
115   ADDRESSES_COLLECTION_NAME,
116   CREDITCARDS_COLLECTION_NAME,
117   AUTOFILL_CREDITCARDS_REAUTH_PREF,
118   MANAGE_ADDRESSES_L10N_IDS,
119   EDIT_ADDRESS_L10N_IDS,
120   MANAGE_CREDITCARDS_L10N_IDS,
121   EDIT_CREDITCARD_L10N_IDS,
122   MAX_FIELD_VALUE_LENGTH,
123   FIELD_STATES,
124   FORM_SUBMISSION_REASON,
126   _fieldNameInfo: {
127     name: "name",
128     "given-name": "name",
129     "additional-name": "name",
130     "family-name": "name",
131     organization: "organization",
132     "street-address": "address",
133     "address-line1": "address",
134     "address-line2": "address",
135     "address-line3": "address",
136     "address-level1": "address",
137     "address-level2": "address",
138     // DE addresses are often split into street name and house number;
139     // combined they form address-line1
140     "address-streetname": "address",
141     "address-housenumber": "address",
142     "postal-code": "address",
143     country: "address",
144     "country-name": "address",
145     tel: "tel",
146     "tel-country-code": "tel",
147     "tel-national": "tel",
148     "tel-area-code": "tel",
149     "tel-local": "tel",
150     "tel-local-prefix": "tel",
151     "tel-local-suffix": "tel",
152     "tel-extension": "tel",
153     email: "email",
154     "cc-name": "creditCard",
155     "cc-given-name": "creditCard",
156     "cc-additional-name": "creditCard",
157     "cc-family-name": "creditCard",
158     "cc-number": "creditCard",
159     "cc-exp-month": "creditCard",
160     "cc-exp-year": "creditCard",
161     "cc-exp": "creditCard",
162     "cc-type": "creditCard",
163     "cc-csc": "creditCard",
164   },
166   _collators: {},
167   _reAlternativeCountryNames: {},
169   isAddressField(fieldName) {
170     return (
171       !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName)
172     );
173   },
175   isCreditCardField(fieldName) {
176     return this._fieldNameInfo?.[fieldName] == "creditCard";
177   },
179   isCCNumber(ccNumber) {
180     return ccNumber && lazy.CreditCard.isValidNumber(ccNumber);
181   },
183   /**
184    * Get the decrypted value for a string pref.
185    *
186    * @param {string} prefName -> The pref whose value is needed.
187    * @param {string} safeDefaultValue -> Value to be returned incase the pref is not yet set.
188    * @returns {string}
189    */
190   getSecurePref(prefName, safeDefaultValue) {
191     if (Services.prefs.getBoolPref("security.nocertdb", false)) {
192       return false;
193     }
194     try {
195       const encryptedValue = Services.prefs.getStringPref(prefName, "");
196       return encryptedValue === ""
197         ? safeDefaultValue
198         : lazy.Crypto.decrypt(encryptedValue);
199     } catch {
200       return safeDefaultValue;
201     }
202   },
204   /**
205    * Set the pref to the encrypted form of the value.
206    *
207    * @param {string} prefName -> The pref whose value is to be set.
208    * @param {string} value -> The value to be set in its encrypted form.
209    */
210   setSecurePref(prefName, value) {
211     if (Services.prefs.getBoolPref("security.nocertdb", false)) {
212       return;
213     }
214     if (value) {
215       const encryptedValue = lazy.Crypto.encrypt(value);
216       Services.prefs.setStringPref(prefName, encryptedValue);
217     } else {
218       Services.prefs.clearUserPref(prefName);
219     }
220   },
222   /**
223    * Get whether the OSAuth is enabled or not.
224    *
225    * @param {string} prefName -> The name of the pref (creditcards or addresses)
226    * @returns {boolean}
227    */
228   getOSAuthEnabled(prefName) {
229     return (
230       lazy.OSKeyStore.canReauth() &&
231       this.getSecurePref(prefName, "") !== "opt out"
232     );
233   },
235   /**
236    * Set whether the OSAuth is enabled or not.
237    *
238    * @param {string} prefName -> The pref to encrypt.
239    * @param {boolean} enable -> Whether the pref is to be enabled.
240    */
241   setOSAuthEnabled(prefName, enable) {
242     this.setSecurePref(prefName, enable ? null : "opt out");
243   },
245   async verifyUserOSAuth(
246     prefName,
247     promptMessage,
248     captionDialog = "",
249     parentWindow = null,
250     generateKeyIfNotAvailable = true
251   ) {
252     if (!this.getOSAuthEnabled(prefName)) {
253       promptMessage = false;
254     }
255     try {
256       return (
257         await lazy.OSKeyStore.ensureLoggedIn(
258           promptMessage,
259           captionDialog,
260           parentWindow,
261           generateKeyIfNotAvailable
262         )
263       ).authenticated;
264     } catch (ex) {
265       // Since Win throws an exception whereas Mac resolves to false upon cancelling.
266       if (ex.result !== Cr.NS_ERROR_FAILURE) {
267         throw ex;
268       }
269     }
270     return false;
271   },
273   /**
274    * Get the array of credit card network ids ("types") we expect and offer as valid choices
275    *
276    * @returns {Array}
277    */
278   getCreditCardNetworks() {
279     return lazy.CreditCard.getSupportedNetworks();
280   },
282   getCategoryFromFieldName(fieldName) {
283     return this._fieldNameInfo[fieldName];
284   },
286   getCategoriesFromFieldNames(fieldNames) {
287     let categories = new Set();
288     for (let fieldName of fieldNames) {
289       let info = this.getCategoryFromFieldName(fieldName);
290       if (info) {
291         categories.add(info);
292       }
293     }
294     return Array.from(categories);
295   },
297   getCollectionNameFromFieldName(fieldName) {
298     return this.isCreditCardField(fieldName)
299       ? CREDITCARDS_COLLECTION_NAME
300       : ADDRESSES_COLLECTION_NAME;
301   },
303   getAddressSeparator() {
304     // The separator should be based on the L10N address format, and using a
305     // white space is a temporary solution.
306     return " ";
307   },
309   /**
310    * Get address display label. It should display information separated
311    * by a comma.
312    *
313    * @param  {object} address
314    * @returns {string}
315    */
316   getAddressLabel(address) {
317     // TODO: Implement a smarter way for deciding what to display
318     //       as option text. Possibly improve the algorithm in
319     //       ProfileAutoCompleteResult.sys.mjs and reuse it here.
320     let fieldOrder = [
321       "name",
322       "-moz-street-address-one-line", // Street address
323       "address-level3", // Townland / Neighborhood / Village
324       "address-level2", // City/Town
325       "organization", // Company or organization name
326       "address-level1", // Province/State (Standardized code if possible)
327       "country", // Country name
328       "postal-code", // Postal code
329       "tel", // Phone number
330       "email", // Email address
331     ];
333     address = { ...address };
334     let parts = [];
335     if (address["street-address"]) {
336       address["-moz-street-address-one-line"] = this.toOneLineAddress(
337         address["street-address"]
338       );
339     }
341     if (!("name" in address)) {
342       address.name = lazy.FormAutofillNameUtils.joinNameParts({
343         given: address["given-name"],
344         middle: address["additional-name"],
345         family: address["family-name"],
346       });
347     }
349     for (const fieldName of fieldOrder) {
350       let string = address[fieldName];
351       if (string) {
352         parts.push(string);
353       }
354     }
355     return parts.join(", ");
356   },
358   /**
359    * Internal method to split an address to multiple parts per the provided delimiter,
360    * removing blank parts.
361    *
362    * @param {string} address The address the split
363    * @param {string} [delimiter] The separator that is used between lines in the address
364    * @returns {string[]}
365    */
366   _toStreetAddressParts(address, delimiter = "\n") {
367     let array = typeof address == "string" ? address.split(delimiter) : address;
369     if (!Array.isArray(array)) {
370       return [];
371     }
372     return array.map(s => (s ? s.trim() : "")).filter(s => s);
373   },
375   /**
376    * Converts a street address to a single line, removing linebreaks marked by the delimiter
377    *
378    * @param {string} address The address the convert
379    * @param {string} [delimiter] The separator that is used between lines in the address
380    * @returns {string}
381    */
382   toOneLineAddress(address, delimiter = "\n") {
383     let addressParts = this._toStreetAddressParts(address, delimiter);
384     return addressParts.join(this.getAddressSeparator());
385   },
387   /**
388    * Returns false if an address is written <number> <street>
389    * and true if an address is written <street> <number>. In the future, this
390    * can be expanded to format an address
391    */
392   getAddressReversed(region) {
393     return this.getCountryAddressData(region).address_reversed;
394   },
396   /**
397    * In-place concatenate tel-related components into a single "tel" field and
398    * delete unnecessary fields.
399    *
400    * @param {object} address An address record.
401    */
402   compressTel(address) {
403     let telCountryCode = address["tel-country-code"] || "";
404     let telAreaCode = address["tel-area-code"] || "";
406     if (!address.tel) {
407       if (address["tel-national"]) {
408         address.tel = telCountryCode + address["tel-national"];
409       } else if (address["tel-local"]) {
410         address.tel = telCountryCode + telAreaCode + address["tel-local"];
411       } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) {
412         address.tel =
413           telCountryCode +
414           telAreaCode +
415           address["tel-local-prefix"] +
416           address["tel-local-suffix"];
417       }
418     }
420     for (let field in address) {
421       if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") {
422         delete address[field];
423       }
424     }
425   },
427   /**
428    * Determines if an element can be autofilled or not.
429    *
430    * @param {HTMLElement} element
431    * @returns {boolean} true if the element can be autofilled
432    */
433   isFieldAutofillable(element) {
434     return element && !element.readOnly && !element.disabled;
435   },
437   /**
438    * Determines if an element is visually hidden or not.
439    *
440    * @param {HTMLElement} element
441    * @param {boolean} visibilityCheck true to run visiblity check against
442    *                  element.checkVisibility API. Otherwise, test by only checking
443    *                  `hidden` and `display` attributes
444    * @returns {boolean} true if the element is visible
445    */
446   isFieldVisible(element, visibilityCheck = true) {
447     if (
448       visibilityCheck &&
449       element.checkVisibility &&
450       !FormAutofillUtils.ignoreVisibilityCheck
451     ) {
452       if (
453         !element.checkVisibility({
454           checkOpacity: true,
455           checkVisibilityCSS: true,
456         })
457       ) {
458         return false;
459       }
460     } else if (element.hidden || element.style.display == "none") {
461       return false;
462     }
464     return element.getAttribute("aria-hidden") != "true";
465   },
467   /**
468    * Determines if an element is eligible to be used by credit card or address autofill.
469    *
470    * @param {HTMLElement} element
471    * @returns {boolean} true if element can be used by credit card or address autofill
472    */
473   isCreditCardOrAddressFieldType(element) {
474     if (!element) {
475       return false;
476     }
478     if (HTMLInputElement.isInstance(element)) {
479       // `element.type` can be recognized as `text`, if it's missing or invalid.
480       return ELIGIBLE_INPUT_TYPES.includes(element.type);
481     }
483     return HTMLSelectElement.isInstance(element);
484   },
486   loadDataFromScript(url, sandbox = {}) {
487     Services.scriptloader.loadSubScript(url, sandbox);
488     return sandbox;
489   },
491   /**
492    * Get country address data and fallback to US if not found.
493    * See AddressMetaDataLoader.#loadData for more details of addressData structure.
494    *
495    * @param {string} [country=FormAutofill.DEFAULT_REGION]
496    *        The country code for requesting specific country's metadata. It'll be
497    *        default region if parameter is not set.
498    * @param {string} [level1=null]
499    *        Return address level 1/level 2 metadata if parameter is set.
500    * @returns {object|null}
501    *          Return metadata of specific region with default locale and other supported
502    *          locales. We need to return a default country metadata for layout format
503    *          and collator, but for sub-region metadata we'll just return null if not found.
504    */
505   getCountryAddressRawData(
506     country = FormAutofill.DEFAULT_REGION,
507     level1 = null
508   ) {
509     let metadata = lazy.AddressMetaDataLoader.getData(country, level1);
510     if (!metadata) {
511       if (level1) {
512         return null;
513       }
514       // Fallback to default region if we couldn't get data from given country.
515       if (country != FormAutofill.DEFAULT_REGION) {
516         metadata = lazy.AddressMetaDataLoader.getData(
517           FormAutofill.DEFAULT_REGION
518         );
519       }
520     }
522     // TODO: Now we fallback to US if we couldn't get data from default region,
523     //       but it could be removed in bug 1423464 if it's not necessary.
524     if (!metadata) {
525       metadata = lazy.AddressMetaDataLoader.getData("US");
526     }
527     return metadata;
528   },
530   /**
531    * Get country address data with default locale.
532    *
533    * @param {string} country
534    * @param {string} level1
535    * @returns {object|null} Return metadata of specific region with default locale.
536    *          NOTE: The returned data may be for a default region if the
537    *          specified one cannot be found. Callers who only want the specific
538    *          region should check the returned country code.
539    */
540   getCountryAddressData(country, level1) {
541     let metadata = this.getCountryAddressRawData(country, level1);
542     return metadata && metadata.defaultLocale;
543   },
545   /**
546    * Get country address data with all locales.
547    *
548    * @param {string} country
549    * @param {string} level1
550    * @returns {Array<object> | null}
551    *          Return metadata of specific region with all the locales.
552    *          NOTE: The returned data may be for a default region if the
553    *          specified one cannot be found. Callers who only want the specific
554    *          region should check the returned country code.
555    */
556   getCountryAddressDataWithLocales(country, level1) {
557     let metadata = this.getCountryAddressRawData(country, level1);
558     return metadata && [metadata.defaultLocale, ...metadata.locales];
559   },
561   /**
562    * Get the collators based on the specified country.
563    *
564    * @param {string}  country The specified country.
565    * @param {object}  [options = {}] a list of options for this method
566    * @param {boolean} [options.ignorePunctuation = true] Whether punctuation should be ignored.
567    * @param {string}  [options.sensitivity = 'base'] Which differences in the strings should lead to non-zero result values
568    * @param {string}  [options.usage = 'search'] Whether the comparison is for sorting or for searching for matching strings
569    * @returns {Array} An array containing several collator objects.
570    */
571   getSearchCollators(
572     country,
573     { ignorePunctuation = true, sensitivity = "base", usage = "search" } = {}
574   ) {
575     // TODO: Only one language should be used at a time per country. The locale
576     //       of the page should be taken into account to do this properly.
577     //       We are going to support more countries in bug 1370193 and this
578     //       should be addressed when we start to implement that bug.
580     if (!this._collators[country]) {
581       let dataset = this.getCountryAddressData(country);
582       let languages = dataset.languages || [dataset.lang];
583       let options = {
584         ignorePunctuation,
585         sensitivity,
586         usage,
587       };
588       this._collators[country] = languages.map(
589         lang => new Intl.Collator(lang, options)
590       );
591     }
592     return this._collators[country];
593   },
595   // Based on the list of fields abbreviations in
596   // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata
597   FIELDS_LOOKUP: {
598     N: "name",
599     O: "organization",
600     A: "street-address",
601     S: "address-level1",
602     C: "address-level2",
603     D: "address-level3",
604     Z: "postal-code",
605     n: "newLine",
606   },
608   /**
609    * Parse a country address format string and outputs an array of fields.
610    * Spaces, commas, and other literals are ignored in this implementation.
611    * For example, format string "%A%n%C, %S" should return:
612    * [
613    *   {fieldId: "street-address", newLine: true},
614    *   {fieldId: "address-level2"},
615    *   {fieldId: "address-level1"},
616    * ]
617    *
618    * @param   {string} fmt Country address format string
619    * @returns {Array<object>} List of fields
620    */
621   parseAddressFormat(fmt) {
622     if (!fmt) {
623       throw new Error("fmt string is missing.");
624     }
626     return fmt.match(/%[^%]/g).reduce((parsed, part) => {
627       // Take the first letter of each segment and try to identify it
628       let fieldId = this.FIELDS_LOOKUP[part[1]];
629       // Early return if cannot identify part.
630       if (!fieldId) {
631         return parsed;
632       }
633       // If a new line is detected, add an attribute to the previous field.
634       if (fieldId == "newLine") {
635         let size = parsed.length;
636         if (size) {
637           parsed[size - 1].newLine = true;
638         }
639         return parsed;
640       }
641       return parsed.concat({ fieldId });
642     }, []);
643   },
645   /**
646    * Used to populate dropdowns in the UI (e.g. FormAutofill preferences).
647    * Use findAddressSelectOption for matching a value to a region.
648    *
649    * @param {string[]} subKeys An array of regionCode strings
650    * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key
651    * @param {string[]} subNames An array of regionName strings
652    * @param {string[]} subLnames An array of latinised regionName strings
653    * @returns {Map?} Returns null if subKeys or subNames are not truthy.
654    *                   Otherwise, a Map will be returned mapping keys -> names.
655    */
656   buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) {
657     // Not all regions have sub_keys. e.g. DE
658     if (
659       !subKeys ||
660       !subKeys.length ||
661       (!subNames && !subLnames) ||
662       (subNames && subKeys.length != subNames.length) ||
663       (subLnames && subKeys.length != subLnames.length)
664     ) {
665       return null;
666     }
668     // Overwrite subKeys with subIsoids, when available
669     if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) {
670       for (let i = 0; i < subIsoids.length; i++) {
671         if (subIsoids[i]) {
672           subKeys[i] = subIsoids[i];
673         }
674       }
675     }
677     // Apply sub_lnames if sub_names does not exist
678     let names = subNames || subLnames;
679     return new Map(subKeys.map((key, index) => [key, names[index]]));
680   },
682   /**
683    * Parse a require string and outputs an array of fields.
684    * Spaces, commas, and other literals are ignored in this implementation.
685    * For example, a require string "ACS" should return:
686    * ["street-address", "address-level2", "address-level1"]
687    *
688    * @param   {string} requireString Country address require string
689    * @returns {Array<string>} List of fields
690    */
691   parseRequireString(requireString) {
692     if (!requireString) {
693       throw new Error("requireString string is missing.");
694     }
696     return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]);
697   },
699   /**
700    * Use address data and alternative country name list to identify a country code from a
701    * specified country name.
702    *
703    * @param   {string} countryName A country name to be identified
704    * @param   {string} [countrySpecified] A country code indicating that we only
705    *                                      search its alternative names if specified.
706    * @returns {string} The matching country code.
707    */
708   identifyCountryCode(countryName, countrySpecified) {
709     if (!countryName) {
710       return null;
711     }
713     if (lazy.AddressMetaDataLoader.getData(countryName)) {
714       return countryName;
715     }
717     const countries = countrySpecified
718       ? [countrySpecified]
719       : [...FormAutofill.countries.keys()];
721     for (const country of countries) {
722       let collators = this.getSearchCollators(country);
723       let metadata = this.getCountryAddressData(country);
724       if (country != metadata.key) {
725         // We hit the fallback logic in getCountryAddressRawData so ignore it as
726         // it's not related to `country` and use the name from l10n instead.
727         metadata = {
728           id: `data/${country}`,
729           key: country,
730           name: FormAutofill.countries.get(country),
731         };
732       }
733       let alternativeCountryNames = metadata.alternative_names || [
734         metadata.name,
735       ];
736       let reAlternativeCountryNames = this._reAlternativeCountryNames[country];
737       if (!reAlternativeCountryNames) {
738         reAlternativeCountryNames = this._reAlternativeCountryNames[country] =
739           [];
740       }
742       if (countryName.length == 3) {
743         if (this.strCompare(metadata.alpha_3_code, countryName, collators)) {
744           return country;
745         }
746       }
748       for (let i = 0; i < alternativeCountryNames.length; i++) {
749         let name = alternativeCountryNames[i];
750         let reName = reAlternativeCountryNames[i];
751         if (!reName) {
752           reName = reAlternativeCountryNames[i] = new RegExp(
753             "\\b" + this.escapeRegExp(name) + "\\b",
754             "i"
755           );
756         }
758         if (
759           this.strCompare(name, countryName, collators) ||
760           reName.test(countryName)
761         ) {
762           return country;
763         }
764       }
765     }
767     return null;
768   },
770   findSelectOption(selectEl, record, fieldName) {
771     if (this.isAddressField(fieldName)) {
772       return this.findAddressSelectOption(selectEl.options, record, fieldName);
773     }
774     if (this.isCreditCardField(fieldName)) {
775       return this.findCreditCardSelectOption(selectEl, record, fieldName);
776     }
777     return null;
778   },
780   /**
781    * Try to find the abbreviation of the given sub-region name
782    *
783    * @param   {string[]} subregionValues A list of inferable sub-region values.
784    * @param   {string} [country] A country name to be identified.
785    * @returns {string} The matching sub-region abbreviation.
786    */
787   getAbbreviatedSubregionName(subregionValues, country) {
788     let values = Array.isArray(subregionValues)
789       ? subregionValues
790       : [subregionValues];
792     let collators = this.getSearchCollators(country);
793     for (let metadata of this.getCountryAddressDataWithLocales(country)) {
794       let {
795         sub_keys: subKeys,
796         sub_names: subNames,
797         sub_lnames: subLnames,
798       } = metadata;
799       if (!subKeys) {
800         // Not all regions have sub_keys. e.g. DE
801         continue;
802       }
803       // Apply sub_lnames if sub_names does not exist
804       subNames = subNames || subLnames;
806       let speculatedSubIndexes = [];
807       for (const val of values) {
808         let identifiedValue = this.identifyValue(
809           subKeys,
810           subNames,
811           val,
812           collators
813         );
814         if (identifiedValue) {
815           return identifiedValue;
816         }
818         // Predict the possible state by partial-matching if no exact match.
819         [subKeys, subNames].forEach(sub => {
820           speculatedSubIndexes.push(
821             sub.findIndex(token => {
822               let pattern = new RegExp(
823                 "\\b" + this.escapeRegExp(token) + "\\b"
824               );
826               return pattern.test(val);
827             })
828           );
829         });
830       }
831       let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)];
832       if (subKey) {
833         return subKey;
834       }
835     }
836     return null;
837   },
839   /**
840    * Find the option element from select element.
841    * 1. Try to find the locale using the country from address.
842    * 2. First pass try to find exact match.
843    * 3. Second pass try to identify values from address value and options,
844    *    and look for a match.
845    *
846    * @param   {Array<{text: string, value: string}>} options
847    * @param   {object} address
848    * @param   {string} fieldName
849    * @returns {DOMElement}
850    */
851   findAddressSelectOption(options, address, fieldName) {
852     if (options.length > 512) {
853       // Allow enough space for all countries (roughly 300 distinct values) and all
854       // timezones (roughly 400 distinct values), plus some extra wiggle room.
855       return null;
856     }
857     let value = address[fieldName];
858     if (!value) {
859       return null;
860     }
862     let collators = this.getSearchCollators(address.country);
864     for (const option of options) {
865       if (
866         this.strCompare(value, option.value, collators) ||
867         this.strCompare(value, option.text, collators)
868       ) {
869         return option;
870       }
871     }
873     switch (fieldName) {
874       case "address-level1": {
875         let { country } = address;
876         let identifiedValue = this.getAbbreviatedSubregionName(
877           [value],
878           country
879         );
880         // No point going any further if we cannot identify value from address level 1
881         if (!identifiedValue) {
882           return null;
883         }
884         for (let dataset of this.getCountryAddressDataWithLocales(country)) {
885           let keys = dataset.sub_keys;
886           if (!keys) {
887             // Not all regions have sub_keys. e.g. DE
888             continue;
889           }
890           // Apply sub_lnames if sub_names does not exist
891           let names = dataset.sub_names || dataset.sub_lnames;
893           // Go through options one by one to find a match.
894           // Also check if any option contain the address-level1 key.
895           let pattern = new RegExp(
896             "\\b" + this.escapeRegExp(identifiedValue) + "\\b",
897             "i"
898           );
899           for (const option of options) {
900             let optionValue = this.identifyValue(
901               keys,
902               names,
903               option.value,
904               collators
905             );
906             let optionText = this.identifyValue(
907               keys,
908               names,
909               option.text,
910               collators,
911               true
912             );
913             if (
914               identifiedValue === optionValue ||
915               identifiedValue === optionText ||
916               pattern.test(option.value)
917             ) {
918               return option;
919             }
920           }
921         }
922         break;
923       }
924       case "country": {
925         if (this.getCountryAddressData(value)) {
926           for (const option of options) {
927             if (
928               this.identifyCountryCode(option.text, value) ||
929               this.identifyCountryCode(option.value, value)
930             ) {
931               return option;
932             }
933           }
934         }
935         break;
936       }
937     }
939     return null;
940   },
942   /**
943    * Find the option element from xul menu popups, as used in address capture
944    * doorhanger.
945    *
946    * This is a proxy to `findAddressSelectOption`, which expects HTML select
947    * DOM nodes and operates on options instead of xul menuitems.
948    *
949    * NOTE: This is a temporary solution until Bug 1886949 is landed. This
950    * method will then be removed `findAddressSelectOption` will be used
951    * directly.
952    *
953    * @param   {XULPopupElement} menupopup
954    * @param   {object} address
955    * @param   {string} fieldName
956    * @returns {XULElement}
957    */
958   findAddressSelectOptionWithMenuPopup(menupopup, address, fieldName) {
959     const options = Array.from(menupopup.childNodes).map(menuitem => ({
960       text: menuitem.label,
961       value: menuitem.value,
962       menuitem,
963     }));
965     return this.findAddressSelectOption(options, address, fieldName)?.menuitem;
966   },
968   findCreditCardSelectOption(selectEl, creditCard, fieldName) {
969     let oneDigitMonth = creditCard["cc-exp-month"]
970       ? creditCard["cc-exp-month"].toString()
971       : null;
972     let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null;
973     let fourDigitsYear = creditCard["cc-exp-year"]
974       ? creditCard["cc-exp-year"].toString()
975       : null;
976     let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null;
977     let options = Array.from(selectEl.options);
979     switch (fieldName) {
980       case "cc-exp-month": {
981         if (!oneDigitMonth) {
982           return null;
983         }
984         for (let option of options) {
985           if (
986             [option.text, option.label, option.value].some(s => {
987               let result = /[1-9]\d*/.exec(s);
988               return result && result[0] == oneDigitMonth;
989             })
990           ) {
991             return option;
992           }
993         }
994         break;
995       }
996       case "cc-exp-year": {
997         if (!fourDigitsYear) {
998           return null;
999         }
1000         for (let option of options) {
1001           if (
1002             [option.text, option.label, option.value].some(
1003               s => s == twoDigitsYear || s == fourDigitsYear
1004             )
1005           ) {
1006             return option;
1007           }
1008         }
1009         break;
1010       }
1011       case "cc-exp": {
1012         if (!oneDigitMonth || !fourDigitsYear) {
1013           return null;
1014         }
1015         let patterns = [
1016           oneDigitMonth + "/" + twoDigitsYear, // 8/22
1017           oneDigitMonth + "/" + fourDigitsYear, // 8/2022
1018           twoDigitsMonth + "/" + twoDigitsYear, // 08/22
1019           twoDigitsMonth + "/" + fourDigitsYear, // 08/2022
1020           oneDigitMonth + "-" + twoDigitsYear, // 8-22
1021           oneDigitMonth + "-" + fourDigitsYear, // 8-2022
1022           twoDigitsMonth + "-" + twoDigitsYear, // 08-22
1023           twoDigitsMonth + "-" + fourDigitsYear, // 08-2022
1024           twoDigitsYear + "-" + twoDigitsMonth, // 22-08
1025           fourDigitsYear + "-" + twoDigitsMonth, // 2022-08
1026           fourDigitsYear + "/" + oneDigitMonth, // 2022/8
1027           twoDigitsMonth + twoDigitsYear, // 0822
1028           twoDigitsYear + twoDigitsMonth, // 2208
1029         ];
1031         for (let option of options) {
1032           if (
1033             [option.text, option.label, option.value].some(str =>
1034               patterns.some(pattern => str.includes(pattern))
1035             )
1036           ) {
1037             return option;
1038           }
1039         }
1040         break;
1041       }
1042       case "cc-type": {
1043         let network = creditCard["cc-type"] || "";
1044         for (let option of options) {
1045           if (
1046             [option.text, option.label, option.value].some(
1047               s => lazy.CreditCard.getNetworkFromName(s) == network
1048             )
1049           ) {
1050             return option;
1051           }
1052         }
1053         break;
1054       }
1055     }
1057     return null;
1058   },
1060   /**
1061    * Try to match value with keys and names, but always return the key.
1062    * If inexactMatch is true, then a substring match is performed, otherwise
1063    * the string must match exactly.
1064    *
1065    * @param   {Array<string>} keys
1066    * @param   {Array<string>} names
1067    * @param   {string} value
1068    * @param   {Array} collators
1069    * @param   {bool} inexactMatch
1070    * @returns {string}
1071    */
1072   identifyValue(keys, names, value, collators, inexactMatch = false) {
1073     let resultKey = keys.find(key => this.strCompare(value, key, collators));
1074     if (resultKey) {
1075       return resultKey;
1076     }
1078     let index = names.findIndex(name =>
1079       inexactMatch
1080         ? this.strInclude(value, name, collators)
1081         : this.strCompare(value, name, collators)
1082     );
1083     if (index !== -1) {
1084       return keys[index];
1085     }
1087     return null;
1088   },
1090   /**
1091    * Compare if two strings are the same.
1092    *
1093    * @param   {string} a
1094    * @param   {string} b
1095    * @param   {Array} collators
1096    * @returns {boolean}
1097    */
1098   strCompare(a = "", b = "", collators) {
1099     return collators.some(collator => !collator.compare(a, b));
1100   },
1102   /**
1103    * Determine whether one string(b) may be found within another string(a)
1104    *
1105    * @param   {string} a
1106    * @param   {string} b
1107    * @param   {Array} collators
1108    * @returns {boolean} True if the string is found
1109    */
1110   strInclude(a = "", b = "", collators) {
1111     const len = a.length - b.length;
1112     for (let i = 0; i <= len; i++) {
1113       if (this.strCompare(a.substring(i, i + b.length), b, collators)) {
1114         return true;
1115       }
1116     }
1117     return false;
1118   },
1120   /**
1121    * Escaping user input to be treated as a literal string within a regular
1122    * expression.
1123    *
1124    * @param   {string} string
1125    * @returns {string}
1126    */
1127   escapeRegExp(string) {
1128     return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1129   },
1131   /**
1132    * Get formatting information of a given country
1133    *
1134    * @param   {string} country
1135    * @returns {object}
1136    *         {
1137    *           {string} addressLevel3L10nId
1138    *           {string} addressLevel2L10nId
1139    *           {string} addressLevel1L10nId
1140    *           {string} postalCodeL10nId
1141    *           {object} fieldsOrder
1142    *           {string} postalCodePattern
1143    *         }
1144    */
1145   getFormFormat(country) {
1146     let dataset = this.getCountryAddressData(country);
1147     // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here.
1148     if (country != dataset.key) {
1149       // Use a sparse object so the below default values take effect.
1150       dataset = {
1151         /**
1152          * Even though data/ZZ only has address-level2, include the other levels
1153          * in case they are needed for unknown countries. Users can leave the
1154          * unnecessary fields blank which is better than forcing users to enter
1155          * the data in incorrect fields.
1156          */
1157         fmt: "%N%n%O%n%A%n%C %S %Z",
1158       };
1159     }
1160     return {
1161       // When particular values are missing for a country, the
1162       // data/ZZ value should be used instead:
1163       // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ
1164       addressLevel3L10nId: this.getAddressFieldL10nId(
1165         dataset.sublocality_name_type || "suburb"
1166       ),
1167       addressLevel2L10nId: this.getAddressFieldL10nId(
1168         dataset.locality_name_type || "city"
1169       ),
1170       addressLevel1L10nId: this.getAddressFieldL10nId(
1171         dataset.state_name_type || "province"
1172       ),
1173       addressLevel1Options: this.buildRegionMapIfAvailable(
1174         dataset.sub_keys,
1175         dataset.sub_isoids,
1176         dataset.sub_names,
1177         dataset.sub_lnames
1178       ),
1179       countryRequiredFields: this.parseRequireString(dataset.require || "AC"),
1180       fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"),
1181       postalCodeL10nId: this.getAddressFieldL10nId(
1182         dataset.zip_name_type || "postal-code"
1183       ),
1184       postalCodePattern: dataset.zip,
1185     };
1186   },
1187   /**
1188    * Converts a Map to an array of objects with `value` and `text` properties ( option like).
1189    *
1190    * @param {Map} optionsMap
1191    * @returns {Array<{ value: string, text: string }>|null}
1192    */
1193   optionsMapToArray(optionsMap) {
1194     return optionsMap?.size
1195       ? [...optionsMap].map(([value, text]) => ({ value, text }))
1196       : null;
1197   },
1199   /**
1200    * Get flattened form layout information of a given country
1201    * TODO(Bug 1891730): Remove getFormFormat and use this instead.
1202    *
1203    * @param {object} record - An object containing at least the 'country' property.
1204    * @returns {Array} Flattened array with the address fiels in order.
1205    */
1206   getFormLayout(record) {
1207     const formFormat = this.getFormFormat(record.country);
1208     let fieldsInOrder = formFormat.fieldsOrder;
1210     // Add missing fields that are always present but not in the .fmt of addresses
1211     // TODO: extend libaddress later to support this if possible
1212     fieldsInOrder = [
1213       ...fieldsInOrder,
1214       {
1215         fieldId: "country",
1216         options: this.optionsMapToArray(FormAutofill.countries),
1217         required: true,
1218       },
1219       { fieldId: "tel", type: "tel" },
1220       { fieldId: "email", type: "email" },
1221     ];
1223     const addressLevel1Options = this.optionsMapToArray(
1224       formFormat.addressLevel1Options
1225     );
1227     const addressLevel1SelectedValue = addressLevel1Options
1228       ? this.findAddressSelectOption(
1229           addressLevel1Options,
1230           record,
1231           "address-level1"
1232         )?.value
1233       : record["address-level1"];
1235     for (const field of fieldsInOrder) {
1236       const flattenedObject = {
1237         fieldId: field.fieldId,
1238         newLine: field.newLine,
1239         l10nId: this.getAddressFieldL10nId(field.fieldId),
1240         required: formFormat.countryRequiredFields.includes(field.fieldId),
1241         value: record[field.fieldId] ?? "",
1242         ...(field.fieldId === "street-address" && {
1243           l10nId: "autofill-address-street",
1244           multiline: true,
1245         }),
1246         ...(field.fieldId === "address-level1" && {
1247           l10nId: formFormat.addressLevel1L10nId,
1248           options: addressLevel1Options,
1249           value: addressLevel1SelectedValue,
1250         }),
1251         ...(field.fieldId === "address-level2" && {
1252           l10nId: formFormat.addressLevel2L10nId,
1253         }),
1254         ...(field.fieldId === "address-level3" && {
1255           l10nId: formFormat.addressLevel3L10nId,
1256         }),
1257         ...(field.fieldId === "postal-code" && {
1258           pattern: formFormat.postalCodePattern,
1259           l10nId: formFormat.postalCodeL10nId,
1260         }),
1261       };
1262       Object.assign(field, flattenedObject);
1263     }
1265     return fieldsInOrder;
1266   },
1268   getAddressFieldL10nId(type) {
1269     return "autofill-address-" + type.replace(/_/g, "-");
1270   },
1272   CC_FATHOM_NONE: 0,
1273   CC_FATHOM_JS: 1,
1274   CC_FATHOM_NATIVE: 2,
1275   isFathomCreditCardsEnabled() {
1276     return this.ccHeuristicsMode != this.CC_FATHOM_NONE;
1277   },
1279   /**
1280    * Transform the key in FormAutofillConfidences (defined in ChromeUtils.webidl)
1281    * to fathom recognized field type.
1282    *
1283    * @param {string} key key from FormAutofillConfidences dictionary
1284    * @returns {string} fathom field type
1285    */
1286   formAutofillConfidencesKeyToCCFieldType(key) {
1287     const MAP = {
1288       ccNumber: "cc-number",
1289       ccName: "cc-name",
1290       ccType: "cc-type",
1291       ccExp: "cc-exp",
1292       ccExpMonth: "cc-exp-month",
1293       ccExpYear: "cc-exp-year",
1294     };
1295     return MAP[key];
1296   },
1297   /**
1298    * Generates the localized os dialog message that
1299    * prompts the user to reauthenticate
1300    *
1301    * @param {string} msgMac fluent message id for macos clients
1302    * @param {string} msgWin fluent message id for windows clients
1303    * @param {string} msgOther fluent message id for other clients
1304    * @param {string} msgLin (optional) fluent message id for linux clients
1305    * @returns {string} localized os prompt message
1306    */
1307   reauthOSPromptMessage(msgMac, msgWin, msgOther, msgLin = null) {
1308     const platform = AppConstants.platform;
1309     let messageID;
1311     switch (platform) {
1312       case "win":
1313         messageID = msgWin;
1314         break;
1315       case "macosx":
1316         messageID = msgMac;
1317         break;
1318       case "linux":
1319         messageID = msgLin ?? msgOther;
1320         break;
1321       default:
1322         messageID = msgOther;
1323     }
1324     return lazy.l10n.formatValueSync(messageID);
1325   },
1327   /**
1328    * Retrieves a unique identifier for a given DOM element.
1329    * Note that the identifier generated by ContentDOMReference is an object but
1330    * this API serializes it to string to make lookup easier.
1331    *
1332    * @param {Element} element The DOM element from which to generate an identifier.
1333    * @returns {string} A unique identifier for the element.
1334    */
1335   getElementIdentifier(element) {
1336     let id;
1337     try {
1338       id = JSON.stringify(lazy.ContentDOMReference.get(element));
1339     } catch {
1340       // This is needed because when running in xpc-shell test, we don't have
1341       const entry = Object.entries(this._elementByElementId).find(
1342         e => e[1] == element
1343       );
1344       if (entry) {
1345         id = entry[0];
1346       } else {
1347         id = Services.uuid.generateUUID().toString();
1348         this._elementByElementId[id] = element;
1349       }
1350     }
1351     return id;
1352   },
1354   /**
1355    * Maps element identifiers to their corresponding DOM elements.
1356    * Only used when we can't get the identifier via ContentDOMReference,
1357    * for example, xpcshell test.
1358    */
1359   _elementByElementId: {},
1361   /**
1362    * Retrieves the DOM element associated with the specific identifier.
1363    * The identifier should be generated with the `getElementIdentifier` API
1364    *
1365    * @param {string} elementId The identifier of the element.
1366    * @returns {Element} The DOM element associated with the given identifier.
1367    */
1368   getElementByIdentifier(elementId) {
1369     let element;
1370     try {
1371       element = lazy.ContentDOMReference.resolve(JSON.parse(elementId));
1372     } catch {
1373       element = this._elementByElementId[elementId];
1374     }
1375     return element;
1376   },
1378   /**
1379    * This function is used to determine the frames that can also be autofilled
1380    * when users trigger autofill on the focusd frame.
1381    *
1382    * Currently we also autofill when for frames that
1383    * 1. is top-level.
1384    * 2. is same origin with the top-level.
1385    * 3. is same origin with the frame that triggers autofill.
1386    *
1387    * @param {BrowsingContext} browsingContext
1388    *        frame to be checked whether we can also autofill
1389    */
1390   isBCSameOriginWithTop(browsingContext) {
1391     return (
1392       browsingContext.top == browsingContext ||
1393       browsingContext.currentWindowGlobal.documentPrincipal.equals(
1394         browsingContext.top.currentWindowGlobal.documentPrincipal
1395       )
1396     );
1397   },
1400 ChromeUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function () {
1401   return Services.strings.createBundle(
1402     "chrome://formautofill/locale/formautofill.properties"
1403   );
1406 ChromeUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function () {
1407   return Services.strings.createBundle(
1408     "chrome://branding/locale/brand.properties"
1409   );
1412 XPCOMUtils.defineLazyPreferenceGetter(
1413   FormAutofillUtils,
1414   "_reauthEnabledByUser",
1415   "extensions.formautofill.reauth.enabled",
1416   false
1419 XPCOMUtils.defineLazyPreferenceGetter(
1420   FormAutofillUtils,
1421   "ccHeuristicsMode",
1422   "extensions.formautofill.creditCards.heuristics.mode",
1423   0
1426 XPCOMUtils.defineLazyPreferenceGetter(
1427   FormAutofillUtils,
1428   "ccFathomConfidenceThreshold",
1429   "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold",
1430   null,
1431   null,
1432   pref => parseFloat(pref)
1435 XPCOMUtils.defineLazyPreferenceGetter(
1436   FormAutofillUtils,
1437   "ccFathomHighConfidenceThreshold",
1438   "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
1439   null,
1440   null,
1441   pref => parseFloat(pref)
1444 XPCOMUtils.defineLazyPreferenceGetter(
1445   FormAutofillUtils,
1446   "ccFathomTestConfidence",
1447   "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
1448   null,
1449   null,
1450   pref => parseFloat(pref)
1453 // This is only used in iOS
1454 XPCOMUtils.defineLazyPreferenceGetter(
1455   FormAutofillUtils,
1456   "focusOnAutofill",
1457   "extensions.formautofill.focusOnAutofill",
1458   true
1461 // This is only used for testing
1462 XPCOMUtils.defineLazyPreferenceGetter(
1463   FormAutofillUtils,
1464   "ignoreVisibilityCheck",
1465   "extensions.formautofill.test.ignoreVisibilityCheck",
1466   false