Merge mozilla-central to autoland. CLOSED TREE
[gecko.git] / toolkit / components / formautofill / FormAutofillParent.sys.mjs
blobbc56baeea7fde02059bf28671d20907acfc27878
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 /*
6  * Implements a service used to access storage and communicate with content.
7  *
8  * A "fields" array is used to communicate with FormAutofillChild. Each item
9  * represents a single input field in the content page as well as its
10  * @autocomplete properties. The schema is as below. Please refer to
11  * FormAutofillChild.js for more details.
12  *
13  * [
14  *   {
15  *     section,
16  *     addressType,
17  *     contactType,
18  *     fieldName,
19  *     value,
20  *     index
21  *   },
22  *   {
23  *     // ...
24  *   }
25  * ]
26  */
28 // We expose a singleton from this module. Some tests may import the
29 // constructor via a backstage pass.
30 import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs";
31 import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
32 import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
34 const lazy = {};
36 ChromeUtils.defineESModuleGetters(lazy, {
37   AddressComponent: "resource://gre/modules/shared/AddressComponent.sys.mjs",
38   BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
39   FormAutofillPreferences:
40     "resource://autofill/FormAutofillPreferences.sys.mjs",
41   FormAutofillPrompter: "resource://autofill/FormAutofillPrompter.sys.mjs",
42   FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs",
43   LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
44   OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
45 });
47 ChromeUtils.defineLazyGetter(lazy, "log", () =>
48   FormAutofill.defineLogGetter(lazy, "FormAutofillParent")
51 const { ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF } =
52   FormAutofill;
54 const { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME } =
55   FormAutofillUtils;
57 let gMessageObservers = new Set();
59 export let FormAutofillStatus = {
60   _initialized: false,
62   /**
63    * Cache of the Form Autofill status (considering preferences and storage).
64    */
65   _active: null,
67   /**
68    * Initializes observers and registers the message handler.
69    */
70   init() {
71     if (this._initialized) {
72       return;
73     }
74     this._initialized = true;
76     Services.obs.addObserver(this, "privacy-pane-loaded");
78     // Observing the pref and storage changes
79     Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
80     Services.obs.addObserver(this, "formautofill-storage-changed");
82     // Only listen to credit card related preference if it is available
83     if (FormAutofill.isAutofillCreditCardsAvailable) {
84       Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
85     }
87     Services.telemetry.setEventRecordingEnabled("creditcard", true);
88     Services.telemetry.setEventRecordingEnabled("address", true);
89   },
91   /**
92    * Uninitializes FormAutofillStatus. This is for testing only.
93    *
94    * @private
95    */
96   uninit() {
97     lazy.gFormAutofillStorage._saveImmediately();
99     if (!this._initialized) {
100       return;
101     }
102     this._initialized = false;
104     this._active = null;
106     Services.obs.removeObserver(this, "privacy-pane-loaded");
107     Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
108     Services.wm.removeListener(this);
110     if (FormAutofill.isAutofillCreditCardsAvailable) {
111       Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
112     }
113   },
115   get formAutofillStorage() {
116     return lazy.gFormAutofillStorage;
117   },
119   /**
120    * Broadcast the status to frames when the form autofill status changes.
121    */
122   onStatusChanged() {
123     lazy.log.debug("onStatusChanged: Status changed to", this._active);
124     Services.ppmm.sharedData.set("FormAutofill:enabled", this._active);
125     // Sync autofill enabled to make sure the value is up-to-date
126     // no matter when the new content process is initialized.
127     Services.ppmm.sharedData.flush();
128   },
130   /**
131    * Query preference and storage status to determine the overall status of the
132    * form autofill feature.
133    *
134    * @returns {boolean} whether form autofill is active (enabled and has data)
135    */
136   computeStatus() {
137     const savedFieldNames = Services.ppmm.sharedData.get(
138       "FormAutofill:savedFieldNames"
139     );
141     return (
142       (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) ||
143         Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) &&
144       savedFieldNames &&
145       savedFieldNames.size > 0
146     );
147   },
149   /**
150    * Update the status and trigger onStatusChanged, if necessary.
151    */
152   updateStatus() {
153     lazy.log.debug("updateStatus");
154     let wasActive = this._active;
155     this._active = this.computeStatus();
156     if (this._active !== wasActive) {
157       this.onStatusChanged();
158     }
159   },
161   async updateSavedFieldNames() {
162     lazy.log.debug("updateSavedFieldNames");
164     let savedFieldNames;
165     const addressNames =
166       await lazy.gFormAutofillStorage.addresses.getSavedFieldNames();
168     // Don't access the credit cards store unless it is enabled.
169     if (FormAutofill.isAutofillCreditCardsAvailable) {
170       const creditCardNames =
171         await lazy.gFormAutofillStorage.creditCards.getSavedFieldNames();
172       savedFieldNames = new Set([...addressNames, ...creditCardNames]);
173     } else {
174       savedFieldNames = addressNames;
175     }
177     Services.ppmm.sharedData.set(
178       "FormAutofill:savedFieldNames",
179       savedFieldNames
180     );
181     Services.ppmm.sharedData.flush();
183     this.updateStatus();
184   },
186   async observe(subject, topic, data) {
187     lazy.log.debug("observe:", topic, "with data:", data);
188     switch (topic) {
189       case "privacy-pane-loaded": {
190         let formAutofillPreferences = new lazy.FormAutofillPreferences();
191         let document = subject.document;
192         let prefFragment = formAutofillPreferences.init(document);
193         let formAutofillGroupBox = document.getElementById(
194           "formAutofillGroupBox"
195         );
196         formAutofillGroupBox.appendChild(prefFragment);
197         break;
198       }
200       case "nsPref:changed": {
201         // Observe pref changes and update _active cache if status is changed.
202         this.updateStatus();
203         break;
204       }
206       case "formautofill-storage-changed": {
207         // Early exit if only metadata is changed
208         if (data == "notifyUsed") {
209           break;
210         }
212         await this.updateSavedFieldNames();
213         break;
214       }
216       default: {
217         throw new Error(
218           `FormAutofillStatus: Unexpected topic observed: ${topic}`
219         );
220       }
221     }
222   },
225 // Lazily load the storage JSM to avoid disk I/O until absolutely needed.
226 // Once storage is loaded we need to update saved field names and inform content processes.
227 ChromeUtils.defineLazyGetter(lazy, "gFormAutofillStorage", () => {
228   let { formAutofillStorage } = ChromeUtils.importESModule(
229     "resource://autofill/FormAutofillStorage.sys.mjs"
230   );
231   lazy.log.debug("Loading formAutofillStorage");
233   formAutofillStorage.initialize().then(() => {
234     // Update the saved field names to compute the status and update child processes.
235     FormAutofillStatus.updateSavedFieldNames();
236   });
238   return formAutofillStorage;
241 export class FormAutofillParent extends JSWindowActorParent {
242   constructor() {
243     super();
244     FormAutofillStatus.init();
245   }
247   static addMessageObserver(observer) {
248     gMessageObservers.add(observer);
249   }
251   static removeMessageObserver(observer) {
252     gMessageObservers.delete(observer);
253   }
255   /**
256    * Handles the message coming from FormAutofillChild.
257    *
258    * @param   {object} message
259    * @param   {string} message.name The name of the message.
260    * @param   {object} message.data The data of the message.
261    */
262   async receiveMessage({ name, data }) {
263     switch (name) {
264       case "FormAutofill:InitStorage": {
265         await lazy.gFormAutofillStorage.initialize();
266         await FormAutofillStatus.updateSavedFieldNames();
267         break;
268       }
269       case "FormAutofill:GetRecords": {
270         const records = await FormAutofillParent.getRecords(data);
271         return { records };
272       }
273       case "FormAutofill:OnFormSubmit": {
274         this.notifyMessageObservers("onFormSubmitted", data);
275         await this._onFormSubmit(data);
276         break;
277       }
278       case "FormAutofill:OpenPreferences": {
279         const win = lazy.BrowserWindowTracker.getTopWindow();
280         win.openPreferences("privacy-form-autofill");
281         break;
282       }
283       case "FormAutofill:GetDecryptedString": {
284         let { cipherText, reauth } = data;
285         if (!FormAutofillUtils._reauthEnabledByUser) {
286           lazy.log.debug("Reauth is disabled");
287           reauth = false;
288         }
289         let string;
290         try {
291           string = await lazy.OSKeyStore.decrypt(cipherText, reauth);
292         } catch (e) {
293           if (e.result != Cr.NS_ERROR_ABORT) {
294             throw e;
295           }
296           lazy.log.warn("User canceled encryption login");
297         }
298         return string;
299       }
300       case "FormAutofill:UpdateWarningMessage":
301         this.notifyMessageObservers("updateWarningNote", data);
302         break;
304       case "FormAutofill:FieldsIdentified":
305         this.notifyMessageObservers("fieldsIdentified", data);
306         break;
308       // The remaining Save and Remove messages are invoked only by tests.
309       case "FormAutofill:SaveAddress": {
310         if (data.guid) {
311           await lazy.gFormAutofillStorage.addresses.update(
312             data.guid,
313             data.address
314           );
315         } else {
316           await lazy.gFormAutofillStorage.addresses.add(data.address);
317         }
318         break;
319       }
320       case "FormAutofill:SaveCreditCard": {
321         if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) {
322           lazy.log.warn("User canceled encryption login");
323           return undefined;
324         }
325         await lazy.gFormAutofillStorage.creditCards.add(data.creditcard);
326         break;
327       }
328       case "FormAutofill:RemoveAddresses": {
329         data.guids.forEach(guid =>
330           lazy.gFormAutofillStorage.addresses.remove(guid)
331         );
332         break;
333       }
334       case "FormAutofill:RemoveCreditCards": {
335         data.guids.forEach(guid =>
336           lazy.gFormAutofillStorage.creditCards.remove(guid)
337         );
338         break;
339       }
340       case "PasswordManager:offerRelayIntegration": {
341         FirefoxRelayTelemetry.recordRelayOfferedEvent(
342           "clicked",
343           data.telemetry.flowId,
344           data.telemetry.scenarioName
345         );
346         return this.#offerRelayIntegration();
347       }
348       case "PasswordManager:generateRelayUsername": {
349         FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
350           "clicked",
351           data.telemetry.flowId
352         );
353         return this.#generateRelayUsername();
354       }
355     }
357     return undefined;
358   }
360   get formOrigin() {
361     return lazy.LoginHelper.getLoginOrigin(
362       this.manager.documentPrincipal?.originNoSuffix
363     );
364   }
366   getRootBrowser() {
367     return this.browsingContext.topFrameElement;
368   }
370   async #offerRelayIntegration() {
371     const browser = this.getRootBrowser();
372     return lazy.FirefoxRelay.offerRelayIntegration(browser, this.formOrigin);
373   }
375   async #generateRelayUsername() {
376     const browser = this.getRootBrowser();
377     return lazy.FirefoxRelay.generateUsername(browser, this.formOrigin);
378   }
380   notifyMessageObservers(callbackName, data) {
381     for (let observer of gMessageObservers) {
382       try {
383         if (callbackName in observer) {
384           observer[callbackName](
385             data,
386             this.manager.browsingContext.topChromeWindow
387           );
388         }
389       } catch (ex) {
390         console.error(ex);
391       }
392     }
393   }
395   /**
396    * Retrieves autocomplete entries for a given search string and data context.
397    *
398    * @param {string} searchString
399    *                 The search string used to filter autocomplete entries.
400    * @param {object} options
401    * @param {string} options.fieldName
402    *                 The name of the field for which autocomplete entries are being fetched.
403    * @param {string} options.scenarioName
404    *                 The scenario name used in the autocomplete operation to fetch external entries.
405    * @returns {Promise<object>} A promise that resolves to an object containing two properties: `records` and `externalEntries`.
406    *         `records` is an array of autofill records from the form's internal data, sorted by `timeLastUsed`.
407    *         `externalEntries` is an array of external autocomplete items fetched based on the scenario.
408    */
409   async searchAutoCompleteEntries(searchString, options) {
410     const { fieldName, scenarioName } = options;
411     const relayPromise = lazy.FirefoxRelay.autocompleteItemsAsync({
412       formOrigin: this.formOrigin,
413       scenarioName,
414       hasInput: !!searchString?.length,
415     });
417     const recordsPromise = FormAutofillParent.getRecords({
418       searchString,
419       fieldName,
420     });
421     const [records, externalEntries] = await Promise.all([
422       recordsPromise,
423       relayPromise,
424     ]);
426     // Sort addresses by timeLastUsed for showing the lastest used address at top.
427     records.sort((a, b) => b.timeLastUsed - a.timeLastUsed);
429     return { records, externalEntries };
430   }
432   /**
433    * Get the records from profile store and return results back to content
434    * process. It will decrypt the credit card number and append
435    * "cc-number-decrypted" to each record if OSKeyStore isn't set.
436    *
437    * This is static as a unit test calls this.
438    *
439    * @param  {object} data
440    * @param  {string} data.searchString
441    *         The typed string for filtering out the matched records.
442    * @param  {string} data.collectionName
443    *         The name used to specify which collection to retrieve records.
444    * @param  {string} data.fieldName
445    *         The field name to search. If not specified, return all records in
446    *         the collection
447    */
448   static async getRecords({ searchString, collectionName, fieldName }) {
449     // Derive the collection name from field name if it doesn't exist
450     collectionName ||=
451       FormAutofillUtils.getCollectionNameFromFieldName(fieldName);
453     const collection = lazy.gFormAutofillStorage[collectionName];
454     if (!collection) {
455       return [];
456     }
458     const records = await collection.getAll();
459     if (!fieldName || !records.length) {
460       return records;
461     }
463     // We don't filter "cc-number"
464     if (collectionName == CREDITCARDS_COLLECTION_NAME) {
465       if (fieldName == "cc-number") {
466         return records.filter(record => !!record["cc-number"]);
467       }
468     }
470     const lcSearchString = searchString.toLowerCase();
471     return records.filter(record => {
472       const fieldValue = record[fieldName];
473       if (!fieldValue) {
474         return false;
475       }
477       if (
478         collectionName == ADDRESSES_COLLECTION_NAME &&
479         record.country &&
480         !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
481       ) {
482         // Address autofill isn't supported for the record's country so we don't
483         // want to attempt to potentially incorrectly fill the address fields.
484         return false;
485       }
487       return (
488         !lcSearchString ||
489         String(fieldValue).toLowerCase().startsWith(lcSearchString)
490       );
491     });
492   }
494   async _onAddressSubmit(address, browser) {
495     const storage = lazy.gFormAutofillStorage.addresses;
497     // Make sure record is normalized before comparing with records in the storage
498     try {
499       storage._normalizeRecord(address.record);
500     } catch (_e) {
501       return false;
502     }
504     const newAddress = new lazy.AddressComponent(
505       address.record,
506       // Invalid address fields in the address form will not be captured.
507       { ignoreInvalid: true }
508     );
510     // Exams all stored record to determine whether to show the prompt or not.
511     let mergeableFields = [];
512     let preserveFields = [];
513     let oldRecord = {};
515     for (const record of await storage.getAll()) {
516       const savedAddress = new lazy.AddressComponent(record);
517       // filter invalid field
518       const result = newAddress.compare(savedAddress);
520       // If any of the fields in the new address are different from the corresponding fields
521       // in the saved address, the two addresses are considered different. For example, if
522       // the name, email, country are the same but the street address is different, the two
523       // addresses are not considered the same.
524       if (Object.values(result).includes("different")) {
525         continue;
526       }
528       // If none of the fields in the new address are mergeable, the new address is considered
529       // a duplicate of a local address. Therefore, we don't need to capture this address.
530       const fields = Object.entries(result)
531         .filter(v => ["superset", "similar"].includes(v[1]))
532         .map(v => v[0]);
533       if (!fields.length) {
534         lazy.log.debug(
535           "A duplicated address record is found, do not show the prompt"
536         );
537         storage.notifyUsed(record.guid);
538         return false;
539       }
541       // If the new address is neither a duplicate of the saved address nor a different address.
542       // There must be at least one field we can merge, show the update doorhanger
543       lazy.log.debug(
544         "A mergeable address record is found, show the update prompt"
545       );
547       // If one record has fewer mergeable fields compared to another, it suggests greater similarity
548       // to the merged record. In such cases, we opt for the record with the fewest mergeable fields.
549       // TODO: Bug 1830841. Add a testcase
550       if (!mergeableFields.length || mergeableFields > fields.length) {
551         mergeableFields = fields;
552         preserveFields = Object.entries(result)
553           .filter(v => ["same", "subset"].includes(v[1]))
554           .map(v => v[0]);
555         oldRecord = record;
556       }
557     }
559     // Find a mergeable old record, construct the new record by only copying mergeable fields
560     // from the new address.
561     let newRecord = {};
562     if (mergeableFields.length) {
563       // TODO: This is only temporarily, should be removed after Bug 1836438 is fixed
564       if (mergeableFields.includes("name")) {
565         mergeableFields.push("given-name", "additional-name", "family-name");
566       }
567       mergeableFields.forEach(f => {
568         if (f in newAddress.record) {
569           newRecord[f] = newAddress.record[f];
570         }
571       });
573       if (preserveFields.includes("name")) {
574         preserveFields.push("given-name", "additional-name", "family-name");
575       }
576       preserveFields.forEach(f => {
577         if (f in oldRecord) {
578           newRecord[f] = oldRecord[f];
579         }
580       });
581     } else {
582       newRecord = newAddress.record;
583     }
585     if (!this._shouldShowSaveAddressPrompt(newAddress.record)) {
586       return false;
587     }
589     return async () => {
590       await lazy.FormAutofillPrompter.promptToSaveAddress(
591         browser,
592         storage,
593         address.flowId,
594         { oldRecord, newRecord }
595       );
596     };
597   }
599   async _onCreditCardSubmit(creditCard, browser) {
600     const storage = lazy.gFormAutofillStorage.creditCards;
602     // Make sure record is normalized before comparing with records in the storage
603     try {
604       storage._normalizeRecord(creditCard.record);
605     } catch (_e) {
606       return false;
607     }
609     // If the record alreay exists in the storage, don't bother showing the prompt
610     const matchRecord = (
611       await storage.getMatchRecords(creditCard.record).next()
612     ).value;
613     if (matchRecord) {
614       storage.notifyUsed(matchRecord.guid);
615       return false;
616     }
618     // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger.
619     if (!FormAutofill.isAutofillCreditCardsEnabled) {
620       return false;
621     }
623     // Overwrite the guid if there is a duplicate
624     const duplicateRecord =
625       (await storage.getDuplicateRecords(creditCard.record).next()).value ?? {};
627     return async () => {
628       await lazy.FormAutofillPrompter.promptToSaveCreditCard(
629         browser,
630         storage,
631         creditCard.flowId,
632         { oldRecord: duplicateRecord, newRecord: creditCard.record }
633       );
634     };
635   }
637   async _onFormSubmit(data) {
638     let { address, creditCard } = data;
640     let browser = this.manager.browsingContext.top.embedderElement;
642     // Transmit the telemetry immediately in the meantime form submitted, and handle these pending
643     // doorhangers at a later.
644     await Promise.all(
645       [
646         await Promise.all(
647           address.map(addrRecord => this._onAddressSubmit(addrRecord, browser))
648         ),
649         await Promise.all(
650           creditCard.map(ccRecord =>
651             this._onCreditCardSubmit(ccRecord, browser)
652           )
653         ),
654       ]
655         .map(pendingDoorhangers => {
656           return pendingDoorhangers.filter(
657             pendingDoorhanger =>
658               !!pendingDoorhanger && typeof pendingDoorhanger == "function"
659           );
660         })
661         .map(pendingDoorhangers =>
662           (async () => {
663             for (const showDoorhanger of pendingDoorhangers) {
664               await showDoorhanger();
665             }
666           })()
667         )
668     );
669   }
671   _shouldShowSaveAddressPrompt(record) {
672     if (!FormAutofill.isAutofillAddressesCaptureEnabled) {
673       return false;
674     }
676     // Do not save address for regions that we don't support
677     if (
678       FormAutofill._isAutofillAddressesAvailable == "detect" &&
679       !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
680     ) {
681       lazy.log.debug(
682         `Do not show the address capture prompt for unsupported regions - ${record.country}`
683       );
684       return false;
685     }
687     // Display the address capture doorhanger only when the submitted form contains all
688     // the required fields. This approach is implemented to prevent excessive prompting.
689     const requiredFields = FormAutofill.addressCaptureRequiredFields ?? [];
690     if (!requiredFields.every(field => field in record)) {
691       lazy.log.debug(
692         "Do not show the address capture prompt when the submitted form doesn't contain all the required fields"
693       );
694       return false;
695     }
697     return true;
698   }