fune/browser/extensions/formautofill/FormAutofillContent.jsm
steveck-chung 26f88232ff Bug 1341569 - Add the form created time in handler and telemetry probe for form filling duration. r=benjamin+7044,MattN
MozReview-Commit-ID: 6mU606zEtT4

--HG--
extra : rebase_source : e55fa94c70927b9374b09d9c8ad277f274f77fb2
2017-09-11 10:25:09 +08:00

579 lines
20 KiB
JavaScript

/* 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/. */
/*
* Form Autofill content process module.
*/
/* eslint-disable no-use-before-define */
"use strict";
this.EXPORTED_SYMBOLS = ["FormAutofillContent"];
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr, manager: Cm} = Components;
Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://formautofill/FormAutofillUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AddressResult",
"resource://formautofill/ProfileAutoCompleteResult.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "CreditCardResult",
"resource://formautofill/ProfileAutoCompleteResult.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FormAutofillHandler",
"resource://formautofill/FormAutofillHandler.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FormLikeFactory",
"resource://gre/modules/FormLikeFactory.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "InsecurePasswordUtils",
"resource://gre/modules/InsecurePasswordUtils.jsm");
const formFillController = Cc["@mozilla.org/satchel/form-fill-controller;1"]
.getService(Ci.nsIFormFillController);
const {ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME} = FormAutofillUtils;
// Register/unregister a constructor as a factory.
function AutocompleteFactory() {}
AutocompleteFactory.prototype = {
register(targetConstructor) {
let proto = targetConstructor.prototype;
this._classID = proto.classID;
let factory = XPCOMUtils._getFactory(targetConstructor);
this._factory = factory;
let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
registrar.registerFactory(proto.classID, proto.classDescription,
proto.contractID, factory);
if (proto.classID2) {
this._classID2 = proto.classID2;
registrar.registerFactory(proto.classID2, proto.classDescription,
proto.contractID2, factory);
}
},
unregister() {
let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
registrar.unregisterFactory(this._classID, this._factory);
if (this._classID2) {
registrar.unregisterFactory(this._classID2, this._factory);
}
this._factory = null;
},
};
/**
* @constructor
*
* @implements {nsIAutoCompleteSearch}
*/
function AutofillProfileAutoCompleteSearch() {
FormAutofillUtils.defineLazyLogGetter(this, "AutofillProfileAutoCompleteSearch");
}
AutofillProfileAutoCompleteSearch.prototype = {
classID: Components.ID("4f9f1e4c-7f2c-439e-9c9e-566b68bc187d"),
contractID: "@mozilla.org/autocomplete/search;1?name=autofill-profiles",
classDescription: "AutofillProfileAutoCompleteSearch",
QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteSearch]),
// Begin nsIAutoCompleteSearch implementation
/**
* Searches for a given string and notifies a listener (either synchronously
* or asynchronously) of the result
*
* @param {string} searchString the string to search for
* @param {string} searchParam
* @param {Object} previousResult a previous result to use for faster searchinig
* @param {Object} listener the listener to notify when the search is complete
*/
startSearch(searchString, searchParam, previousResult, listener) {
this.log.debug("startSearch: for", searchString, "with input", formFillController.focusedInput);
this.forceStop = false;
let savedFieldNames = FormAutofillContent.savedFieldNames;
let focusedInput = formFillController.focusedInput;
let info = FormAutofillContent.getInputDetails(focusedInput);
let isAddressField = FormAutofillUtils.isAddressField(info.fieldName);
let handler = FormAutofillContent.getFormHandler(focusedInput);
let allFieldNames = handler.allFieldNames;
let filledRecordGUID = isAddressField ? handler.address.filledRecordGUID : handler.creditCard.filledRecordGUID;
let searchPermitted = isAddressField ?
FormAutofillUtils.isAutofillAddressesEnabled :
FormAutofillUtils.isAutofillCreditCardsEnabled;
// Fallback to form-history if ...
// - specified autofill feature is pref off.
// - no profile can fill the currently-focused input.
// - the current form has already been populated.
// - (address only) less than 3 inputs are covered by all saved fields in the storage.
if (!searchPermitted || !savedFieldNames.has(info.fieldName) || filledRecordGUID || (isAddressField &&
allFieldNames.filter(field => savedFieldNames.has(field)).length < FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD)) {
let formHistory = Cc["@mozilla.org/autocomplete/search;1?name=form-history"]
.createInstance(Ci.nsIAutoCompleteSearch);
formHistory.startSearch(searchString, searchParam, previousResult, {
onSearchResult: (search, result) => {
listener.onSearchResult(this, result);
ProfileAutocomplete.setProfileAutoCompleteResult(result);
},
});
return;
}
let infoWithoutElement = Object.assign({}, info);
delete infoWithoutElement.elementWeakRef;
let data = {
collectionName: isAddressField ? ADDRESSES_COLLECTION_NAME : CREDITCARDS_COLLECTION_NAME,
info: infoWithoutElement,
searchString,
};
this._getRecords(data).then((records) => {
if (this.forceStop) {
return;
}
// Sort addresses by timeLastUsed for showing the lastest used address at top.
records.sort((a, b) => b.timeLastUsed - a.timeLastUsed);
let adaptedRecords = handler.getAdaptedProfiles(records);
let result = null;
if (isAddressField) {
result = new AddressResult(searchString,
info.fieldName,
allFieldNames,
adaptedRecords,
{});
} else {
let isSecure = InsecurePasswordUtils.isFormSecure(handler.form);
result = new CreditCardResult(searchString,
info.fieldName,
allFieldNames,
adaptedRecords,
{isSecure});
}
listener.onSearchResult(this, result);
ProfileAutocomplete.setProfileAutoCompleteResult(result);
});
},
/**
* Stops an asynchronous search that is in progress
*/
stopSearch() {
ProfileAutocomplete.setProfileAutoCompleteResult(null);
this.forceStop = true;
},
/**
* Get the records from parent process for AutoComplete result.
*
* @private
* @param {Object} data
* Parameters for querying the corresponding result.
* @param {string} data.collectionName
* The name used to specify which collection to retrieve records.
* @param {string} data.searchString
* The typed string for filtering out the matched records.
* @param {string} data.info
* The input autocomplete property's information.
* @returns {Promise}
* Promise that resolves when addresses returned from parent process.
*/
_getRecords(data) {
this.log.debug("_getRecords with data:", data);
return new Promise((resolve) => {
Services.cpmm.addMessageListener("FormAutofill:Records", function getResult(result) {
Services.cpmm.removeMessageListener("FormAutofill:Records", getResult);
resolve(result.data);
});
Services.cpmm.sendAsyncMessage("FormAutofill:GetRecords", data);
});
},
};
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([AutofillProfileAutoCompleteSearch]);
let ProfileAutocomplete = {
QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
_lastAutoCompleteResult: null,
_lastAutoCompleteFocusedInput: null,
_registered: false,
_factory: null,
ensureRegistered() {
if (this._registered) {
return;
}
FormAutofillUtils.defineLazyLogGetter(this, "ProfileAutocomplete");
this.log.debug("ensureRegistered");
this._factory = new AutocompleteFactory();
this._factory.register(AutofillProfileAutoCompleteSearch);
this._registered = true;
Services.obs.addObserver(this, "autocomplete-will-enter-text");
},
ensureUnregistered() {
if (!this._registered) {
return;
}
this.log.debug("ensureUnregistered");
this._factory.unregister();
this._factory = null;
this._registered = false;
this._lastAutoCompleteResult = null;
Services.obs.removeObserver(this, "autocomplete-will-enter-text");
},
getProfileAutoCompleteResult() {
return this._lastAutoCompleteResult;
},
setProfileAutoCompleteResult(result) {
this._lastAutoCompleteResult = result;
this._lastAutoCompleteFocusedInput = formFillController.focusedInput;
},
observe(subject, topic, data) {
switch (topic) {
case "autocomplete-will-enter-text": {
if (!formFillController.focusedInput) {
// The observer notification is for autocomplete in a different process.
break;
}
this._fillFromAutocompleteRow(formFillController.focusedInput);
break;
}
}
},
_frameMMFromWindow(contentWindow) {
return contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDocShell)
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIContentFrameMessageManager);
},
_getSelectedIndex(contentWindow) {
let mm = this._frameMMFromWindow(contentWindow);
let selectedIndexResult = mm.sendSyncMessage("FormAutoComplete:GetSelectedIndex", {});
if (selectedIndexResult.length != 1 || !Number.isInteger(selectedIndexResult[0])) {
throw new Error("Invalid autocomplete selectedIndex");
}
return selectedIndexResult[0];
},
_fillFromAutocompleteRow(focusedInput) {
this.log.debug("_fillFromAutocompleteRow:", focusedInput);
let formDetails = FormAutofillContent.getFormDetails(focusedInput);
if (!formDetails) {
// The observer notification is for a different frame.
return;
}
let selectedIndex = this._getSelectedIndex(focusedInput.ownerGlobal);
if (selectedIndex == -1 ||
!this._lastAutoCompleteResult ||
this._lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile") {
return;
}
let profile = JSON.parse(this._lastAutoCompleteResult.getCommentAt(selectedIndex));
let formHandler = FormAutofillContent.getFormHandler(focusedInput);
formHandler.autofillFormFields(profile, focusedInput);
},
_clearProfilePreview() {
let focusedInput = formFillController.focusedInput || this._lastAutoCompleteFocusedInput;
if (!focusedInput || !FormAutofillContent.getFormDetails(focusedInput)) {
return;
}
let formHandler = FormAutofillContent.getFormHandler(focusedInput);
formHandler.clearPreviewedFormFields(focusedInput);
},
_previewSelectedProfile(selectedIndex) {
let focusedInput = formFillController.focusedInput;
if (!focusedInput || !FormAutofillContent.getFormDetails(focusedInput)) {
// The observer notification is for a different process/frame.
return;
}
if (!this._lastAutoCompleteResult ||
this._lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile") {
return;
}
let profile = JSON.parse(this._lastAutoCompleteResult.getCommentAt(selectedIndex));
let formHandler = FormAutofillContent.getFormHandler(focusedInput);
formHandler.previewFormFields(profile, focusedInput);
},
};
/**
* Handles content's interactions for the process.
*
* NOTE: Declares it by "var" to make it accessible in unit tests.
*/
var FormAutofillContent = {
QueryInterface: XPCOMUtils.generateQI([Ci.nsIFormSubmitObserver]),
/**
* @type {WeakMap} mapping FormLike root HTML elements to FormAutofillHandler objects.
*/
_formsDetails: new WeakMap(),
/**
* @type {Set} Set of the fields with usable values in any saved profile.
*/
savedFieldNames: null,
init() {
FormAutofillUtils.defineLazyLogGetter(this, "FormAutofillContent");
Services.cpmm.addMessageListener("FormAutofill:enabledStatus", this);
Services.cpmm.addMessageListener("FormAutofill:savedFieldNames", this);
Services.obs.addObserver(this, "earlyformsubmit");
let autofillEnabled = Services.cpmm.initialProcessData.autofillEnabled;
// If storage hasn't be initialized yet autofillEnabled is undefined but we need to ensure
// autocomplete is registered before the focusin so register it in this case as long as the
// pref is true.
let shouldEnableAutofill = autofillEnabled === undefined &&
(FormAutofillUtils.isAutofillAddressesEnabled ||
FormAutofillUtils.isAutofillCreditCardsEnabled);
if (autofillEnabled || shouldEnableAutofill) {
ProfileAutocomplete.ensureRegistered();
}
this.savedFieldNames =
Services.cpmm.initialProcessData.autofillSavedFieldNames;
},
/**
* Send the profile to parent for doorhanger and storage saving/updating.
*
* @param {Object} profile Submitted form's address/creditcard guid and record.
* @param {Object} domWin Current content window.
* @param {int} timeStartedFillingMS Time of form filling started.
*/
_onFormSubmit(profile, domWin, timeStartedFillingMS) {
let mm = this._messageManagerFromWindow(domWin);
mm.sendAsyncMessage("FormAutofill:OnFormSubmit",
{profile, timeStartedFillingMS});
},
/**
* Handle earlyformsubmit event and early return when:
* 1. In private browsing mode.
* 2. Could not map any autofill handler by form element.
* 3. Number of filled fields is less than autofill threshold
*
* @param {HTMLElement} formElement Root element which receives earlyformsubmit event.
* @param {Object} domWin Content window
* @returns {boolean} Should always return true so form submission isn't canceled.
*/
notify(formElement, domWin) {
this.log.debug("Notifying form early submission");
if (domWin && PrivateBrowsingUtils.isContentWindowPrivate(domWin)) {
this.log.debug("Ignoring submission in a private window");
return true;
}
let handler = this._formsDetails.get(formElement);
if (!handler) {
this.log.debug("Form element could not map to an existing handler");
return true;
}
let records = handler.createRecords();
if (!Object.keys(records).length) {
return true;
}
this._onFormSubmit(records, domWin, handler.timeStartedFillingMS);
return true;
},
receiveMessage({name, data}) {
switch (name) {
case "FormAutofill:enabledStatus": {
if (data) {
ProfileAutocomplete.ensureRegistered();
} else {
ProfileAutocomplete.ensureUnregistered();
}
break;
}
case "FormAutofill:savedFieldNames": {
this.savedFieldNames = data;
}
}
},
/**
* Get the input's information from cache which is created after page identified.
*
* @param {HTMLInputElement} element Focused input which triggered profile searching
* @returns {Object|null}
* Return target input's information that cloned from content cache
* (or return null if the information is not found in the cache).
*/
getInputDetails(element) {
let formDetails = this.getFormDetails(element);
for (let detail of formDetails) {
let detailElement = detail.elementWeakRef.get();
if (detailElement && element == detailElement) {
return detail;
}
}
return null;
},
/**
* Get the form's handler from cache which is created after page identified.
*
* @param {HTMLInputElement} element Focused input which triggered profile searching
* @returns {Array<Object>|null}
* Return target form's handler from content cache
* (or return null if the information is not found in the cache).
*
*/
getFormHandler(element) {
let rootElement = FormLikeFactory.findRootForField(element);
return this._formsDetails.get(rootElement);
},
/**
* Get the form's information from cache which is created after page identified.
*
* @param {HTMLInputElement} element Focused input which triggered profile searching
* @returns {Array<Object>|null}
* Return target form's information from content cache
* (or return null if the information is not found in the cache).
*
*/
getFormDetails(element) {
let formHandler = this.getFormHandler(element);
return formHandler ? formHandler.fieldDetails : null;
},
getAllFieldNames(element) {
let formHandler = this.getFormHandler(element);
return formHandler ? formHandler.allFieldNames : null;
},
identifyAutofillFields(element) {
this.log.debug("identifyAutofillFields:", "" + element.ownerDocument.location);
if (!this.savedFieldNames) {
this.log.debug("identifyAutofillFields: savedFieldNames are not known yet");
Services.cpmm.sendAsyncMessage("FormAutofill:InitStorage");
}
let formHandler = this.getFormHandler(element);
if (!formHandler) {
let formLike = FormLikeFactory.createFromField(element);
formHandler = new FormAutofillHandler(formLike);
} else if (!formHandler.isFormChangedSinceLastCollection) {
this.log.debug("No control is removed or inserted since last collection.");
return;
}
let validDetails = formHandler.collectFormFields();
this._formsDetails.set(formHandler.form.rootElement, formHandler);
this.log.debug("Adding form handler to _formsDetails:", formHandler);
validDetails.forEach(detail =>
this._markAsAutofillField(detail.elementWeakRef.get())
);
},
previewProfile(doc) {
let docWin = doc.ownerGlobal;
let selectedIndex = ProfileAutocomplete._getSelectedIndex(docWin);
let lastAutoCompleteResult = ProfileAutocomplete.getProfileAutoCompleteResult();
let focusedInput = formFillController.focusedInput;
let mm = this._messageManagerFromWindow(docWin);
if (selectedIndex === -1 ||
!focusedInput ||
!lastAutoCompleteResult ||
lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile") {
mm.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {});
ProfileAutocomplete._clearProfilePreview();
} else {
let focusedInputDetails = this.getInputDetails(focusedInput);
let profile = JSON.parse(lastAutoCompleteResult.getCommentAt(selectedIndex));
let allFieldNames = FormAutofillContent.getAllFieldNames(focusedInput);
let profileFields = allFieldNames.filter(fieldName => !!profile[fieldName]);
let focusedCategory = FormAutofillUtils.getCategoryFromFieldName(focusedInputDetails.fieldName);
let categories = FormAutofillUtils.getCategoriesFromFieldNames(profileFields);
mm.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {
focusedCategory,
categories,
});
ProfileAutocomplete._previewSelectedProfile(selectedIndex);
}
},
_markAsAutofillField(field) {
// Since Form Autofill popup is only for input element, any non-Input
// element should be excluded here.
if (!field || !(field instanceof Ci.nsIDOMHTMLInputElement)) {
return;
}
formFillController.markAsAutofillField(field);
},
_messageManagerFromWindow(win) {
return win.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIDocShell)
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIContentFrameMessageManager);
},
_onKeyDown(e) {
let lastAutoCompleteResult = ProfileAutocomplete.getProfileAutoCompleteResult();
let focusedInput = formFillController.focusedInput;
if (e.keyCode != Ci.nsIDOMKeyEvent.DOM_VK_RETURN || !lastAutoCompleteResult || !focusedInput) {
return;
}
let selectedIndex = ProfileAutocomplete._getSelectedIndex(e.target.ownerGlobal);
let selectedRowStyle = lastAutoCompleteResult.getStyleAt(selectedIndex);
if (selectedRowStyle == "autofill-footer") {
focusedInput.addEventListener("DOMAutoComplete", () => {
Services.cpmm.sendAsyncMessage("FormAutofill:OpenPreferences");
}, {once: true});
}
},
};
FormAutofillContent.init();