diff options
Diffstat (limited to 'browser/components/places/Interactions.sys.mjs')
-rw-r--r-- | browser/components/places/Interactions.sys.mjs | 752 |
1 files changed, 752 insertions, 0 deletions
diff --git a/browser/components/places/Interactions.sys.mjs b/browser/components/places/Interactions.sys.mjs new file mode 100644 index 0000000000..44ceffa608 --- /dev/null +++ b/browser/components/places/Interactions.sys.mjs @@ -0,0 +1,752 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logConsole", function() { + return console.createInstance({ + prefix: "InteractionsManager", + maxLogLevel: Services.prefs.getBoolPref( + "browser.places.interactions.log", + false + ) + ? "Debug" + : "Warn", + }); +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"], +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "pageViewIdleTime", + "browser.places.interactions.pageViewIdleTime", + 60 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "saveInterval", + "browser.places.interactions.saveInterval", + 10000 +); + +const DOMWINDOW_OPENED_TOPIC = "domwindowopened"; + +/** + * Returns a monotonically increasing timestamp, that is critical to distinguish + * database entries by creation time. + */ +let gLastTime = 0; +function monotonicNow() { + let time = Date.now(); + if (time == gLastTime) { + time++; + } + return (gLastTime = time); +} + +/** + * @typedef {object} DocumentInfo + * DocumentInfo is used to pass document information from the child process + * to _Interactions. + * @property {boolean} isActive + * Set to true if the document is active, i.e. visible. + * @property {string} url + * The url of the page that was interacted with. + */ + +/** + * @typedef {object} InteractionInfo + * InteractionInfo is used to store information associated with interactions. + * @property {number} totalViewTime + * Time in milliseconds that the page has been actively viewed for. + * @property {string} url + * The url of the page that was interacted with. + * @property {Interactions.DOCUMENT_TYPE} documentType + * The type of the document. + * @property {number} typingTime + * Time in milliseconds that the user typed on the page + * @property {number} keypresses + * The number of keypresses made on the page + * @property {number} scrollingTime + * Time in milliseconds that the user spent scrolling the page + * @property {number} scrollingDistance + * The distance, in pixels, that the user scrolled the page + * @property {number} created_at + * Creation time as the number of milliseconds since the epoch. + * @property {number} updated_at + * Last updated time as the number of milliseconds since the epoch. + * @property {string} referrer + * The referrer to the url of the page that was interacted with (may be empty) + * + */ + +/** + * The Interactions object sets up listeners and other approriate tools for + * obtaining interaction information and passing it to the InteractionsManager. + */ +class _Interactions { + DOCUMENT_TYPE = { + // Used when the document type is unknown. + GENERIC: 0, + // Used for pages serving media, e.g. videos. + MEDIA: 1, + }; + + /** + * This is used to store potential interactions. It maps the browser + * to the current interaction information. + * The current interaction is updated to the database when it transitions + * to non-active, which occurs before a browser tab is closed, hence this + * can be a weak map. + * + * @type {WeakMap<browser, InteractionInfo>} + */ + #interactions = new WeakMap(); + + /** + * Tracks the currently active window so that we can avoid recording + * interactions in non-active windows. + * + * @type {DOMWindow} + */ + #activeWindow = undefined; + + /** + * Tracks if the user is idle. + * + * @type {boolean} + */ + #userIsIdle = false; + + /** + * This stores the page view start time of the current page view. + * For any single page view, this may be moved multiple times as the + * associated interaction is updated for the current total page view time. + * + * @type {number} + */ + _pageViewStartTime = Cu.now(); + + /** + * Stores interactions in the database, see the {@link InteractionsStore} + * class. This is created lazily, see the `store` getter. + * + * @type {InteractionsStore | undefined} + */ + #store = undefined; + + /** + * Whether the component has been initialized. + */ + #initialized = false; + + /** + * Initializes, sets up actors and observers. + */ + init() { + if ( + !Services.prefs.getBoolPref("browser.places.interactions.enabled", false) + ) { + return; + } + + ChromeUtils.registerWindowActor("Interactions", { + parent: { + esModuleURI: "resource:///actors/InteractionsParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/InteractionsChild.sys.mjs", + events: { + DOMContentLoaded: {}, + pagehide: { mozSystemGroup: true }, + }, + }, + messageManagerGroups: ["browsers"], + }); + + this.#activeWindow = Services.wm.getMostRecentBrowserWindow(); + + for (let win of lazy.BrowserWindowTracker.orderedWindows) { + if (!win.closed) { + this.#registerWindow(win); + } + } + Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true); + lazy.idleService.addIdleObserver(this, lazy.pageViewIdleTime); + this.#initialized = true; + } + + /** + * Uninitializes, removes any observers that need cleaning up manually. + */ + uninit() { + if (this.#initialized) { + lazy.idleService.removeIdleObserver(this, lazy.pageViewIdleTime); + } + } + + /** + * Resets any stored user or interaction state. + * Used by tests. + */ + async reset() { + lazy.logConsole.debug("Database reset"); + this.#interactions = new WeakMap(); + this.#userIsIdle = false; + this._pageViewStartTime = Cu.now(); + ChromeUtils.consumeInteractionData(); + await _Interactions.interactionUpdatePromise; + await this.store.reset(); + } + + /** + * Retrieve the underlying InteractionsStore object. This exists for testing + * purposes and should not be abused by production code (for example it'd be + * a bad idea to force flushes). + * + * @returns {InteractionsStore} + */ + get store() { + if (!this.#store) { + this.#store = new InteractionsStore(); + } + return this.#store; + } + + /** + * Registers the start of a new interaction. + * + * @param {Browser} browser + * The browser object associated with the interaction. + * @param {DocumentInfo} docInfo + * The document information of the page associated with the interaction. + */ + registerNewInteraction(browser, docInfo) { + if (!browser) { + // The browser may have already gone away. + return; + } + let interaction = this.#interactions.get(browser); + if (interaction && interaction.url != docInfo.url) { + this.registerEndOfInteraction(browser); + } + + if (lazy.InteractionsBlocklist.isUrlBlocklisted(docInfo.url)) { + lazy.logConsole.debug( + "Ignoring a page as the URL is blocklisted", + docInfo + ); + return; + } + + lazy.logConsole.debug("Tracking a new interaction", docInfo); + let now = monotonicNow(); + interaction = { + url: docInfo.url, + referrer: docInfo.referrer, + totalViewTime: 0, + typingTime: 0, + keypresses: 0, + scrollingTime: 0, + scrollingDistance: 0, + created_at: now, + updated_at: now, + }; + this.#interactions.set(browser, interaction); + + // Only reset the time if this is being loaded in the active tab of the + // active window. + if (docInfo.isActive && browser.ownerGlobal == this.#activeWindow) { + this._pageViewStartTime = Cu.now(); + } + } + + /** + * Registers the end of an interaction, e.g. if the user navigates away + * from the page. This will store the final interaction details and clear + * the current interaction. + * + * @param {Browser} browser + * The browser object associated with the interaction. + */ + registerEndOfInteraction(browser) { + // Not having a browser passed to us probably means the tab has gone away + // before we received the notification - due to the tab being a background + // tab. Since that will be a non-active tab, it is acceptable that we don't + // update the interaction. When switching away from active tabs, a TabSelect + // notification is generated which we handle elsewhere. + if (!browser) { + return; + } + lazy.logConsole.debug("Saw the end of an interaction"); + + this.#updateInteraction(browser); + this.#interactions.delete(browser); + } + + /** + * Updates the current interaction + * + * @param {Browser} [browser] + * The browser object that has triggered the update, if known. This is + * used to check if the browser is in the active window, and as an + * optimization to avoid obtaining the browser object. + */ + #updateInteraction(browser = undefined) { + _Interactions.#updateInteraction_async( + browser, + this.#activeWindow, + this.#userIsIdle, + this.#interactions, + this._pageViewStartTime, + this.store + ); + } + + /** + * Stores the promise created in updateInteraction_async so that we can await its fulfillment + * when sychronization is needed. + */ + static interactionUpdatePromise = Promise.resolve(); + + /** + * Returns the interactions update promise to be used when sychronization is needed from tests. + * + * @returns {Promise<void>} + */ + get interactionUpdatePromise() { + return _Interactions.interactionUpdatePromise; + } + + /** + * Updates the current interaction on fulfillment of the asynchronous collection of scrolling interactions. + * + * @param {Browser} browser + * The browser object that has triggered the update, if known. + * @param {DOMWindow} activeWindow + * The active window. + * @param {boolean} userIsIdle + * Whether the user is idle. + * @param {WeakMap<Browser, InteractionInfo>} interactions + * A map of interactions for each browser instance + * @param {number} pageViewStartTime + * The time the page was loaded. + * @param {InteractionsStore} store + * The interactions store. + */ + static async #updateInteraction_async( + browser, + activeWindow, + userIsIdle, + interactions, + pageViewStartTime, + store + ) { + if (!activeWindow || (browser && browser.ownerGlobal != activeWindow)) { + lazy.logConsole.debug( + "Not updating interaction as there is no active window" + ); + return; + } + + // We do not update the interaction when the user is idle, since we will + // have already updated it when idle was signalled. + // Sometimes an interaction may be signalled before idle is cleared, however + // worst case we'd only loose approx 2 seconds of interaction detail. + if (userIsIdle) { + lazy.logConsole.debug("Not updating interaction as the user is idle"); + return; + } + + if (!browser) { + browser = activeWindow.gBrowser.selectedTab.linkedBrowser; + } + + let interaction = interactions.get(browser); + if (!interaction) { + lazy.logConsole.debug("No interaction to update"); + return; + } + + interaction.totalViewTime += Cu.now() - pageViewStartTime; + Interactions._pageViewStartTime = Cu.now(); + + const interactionData = ChromeUtils.consumeInteractionData(); + const typing = interactionData.Typing; + if (typing) { + interaction.typingTime += typing.interactionTimeInMilliseconds; + interaction.keypresses += typing.interactionCount; + } + + // Collect the scrolling data and add the interaction to the store on completion + _Interactions.interactionUpdatePromise = _Interactions.interactionUpdatePromise + .then(async () => ChromeUtils.collectScrollingData()) + .then( + result => { + interaction.scrollingTime += result.interactionTimeInMilliseconds; + interaction.scrollingDistance += result.scrollingDistanceInPixels; + }, + reason => { + console.error(reason); + } + ) + .then(() => { + interaction.updated_at = monotonicNow(); + + lazy.logConsole.debug("Add to store: ", interaction); + store.add(interaction); + }); + } + + /** + * Handles a window becoming active. + * + * @param {DOMWindow} win + * The window that has become active. + */ + #onActivateWindow(win) { + lazy.logConsole.debug("Window activated"); + + if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + return; + } + + this.#activeWindow = win; + this._pageViewStartTime = Cu.now(); + } + + /** + * Handles a window going inactive. + * + * @param {DOMWindow} win + * The window that is going inactive. + */ + #onDeactivateWindow(win) { + lazy.logConsole.debug("Window deactivate"); + + this.#updateInteraction(); + this.#activeWindow = undefined; + } + + /** + * Handles the TabSelect notification. Updates the current interaction and + * then switches it to the interaction for the new tab. The new interaction + * may be null if it doesn't exist. + * + * @param {Browser} previousBrowser + * The instance of the browser that the user switched away from. + */ + #onTabSelect(previousBrowser) { + lazy.logConsole.debug("Tab switched"); + + this.#updateInteraction(previousBrowser); + this._pageViewStartTime = Cu.now(); + } + + /** + * Handles various events and forwards them to appropriate functions. + * + * @param {DOMEvent} event + * The event that will be handled + */ + handleEvent(event) { + switch (event.type) { + case "TabSelect": + this.#onTabSelect(event.detail.previousTab.linkedBrowser); + break; + case "activate": + this.#onActivateWindow(event.target); + break; + case "deactivate": + this.#onDeactivateWindow(event.target); + break; + case "unload": + this.#unregisterWindow(event.target); + break; + } + } + + /** + * Handles notifications from the observer service. + * + * @param {nsISupports} subject + * The subject of the notification. + * @param {string} topic + * The topic of the notification. + * @param {string} data + * The data attached to the notification. + */ + observe(subject, topic, data) { + switch (topic) { + case DOMWINDOW_OPENED_TOPIC: + this.#onWindowOpen(subject); + break; + case "idle": + lazy.logConsole.debug("User went idle"); + // We save the state of the current interaction when we are notified + // that the user is idle. + this.#updateInteraction(); + this.#userIsIdle = true; + break; + case "active": + lazy.logConsole.debug("User became active"); + this.#userIsIdle = false; + this._pageViewStartTime = Cu.now(); + break; + } + } + + /** + * Handles registration of listeners in a new window. + * + * @param {DOMWindow} win + * The window to register in. + */ + #registerWindow(win) { + if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + return; + } + + win.addEventListener("TabSelect", this, true); + win.addEventListener("deactivate", this, true); + win.addEventListener("activate", this, true); + } + + /** + * Handles removing of listeners from a window. + * + * @param {DOMWindow} win + * The window to remove listeners from. + */ + #unregisterWindow(win) { + win.removeEventListener("TabSelect", this, true); + win.removeEventListener("deactivate", this, true); + win.removeEventListener("activate", this, true); + } + + /** + * Handles a new window being opened, waits for load and checks that + * it is a browser window, then adds listeners. + * + * @param {DOMWindow} win + * The window being opened. + */ + #onWindowOpen(win) { + win.addEventListener( + "load", + () => { + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + this.#registerWindow(win); + }, + { once: true } + ); + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); +} + +export const Interactions = new _Interactions(); + +/** + * Store interactions data in the Places database. + * To improve performance the writes are buffered every `saveInterval` + * milliseconds. Even if this means we could be trying to write interaction for + * pages that in the meanwhile have been removed, that's not a problem because + * we won't be able to insert entries having a NULL place_id, they will just be + * ignored. + * Use .add(interaction) to request storing of an interaction. + * Use .pendingPromise to await for any pending writes to have happened. + */ +class InteractionsStore { + /** + * Timer to run database updates on. + */ + #timer = undefined; + /** + * Tracks interactions replicating the unique index in the underlying schema. + * Interactions are keyed by url and then created_at. + * + * @type {Map<string, Map<number, InteractionInfo>>} + */ + #interactions = new Map(); + /** + * Used to unblock the queue of promises when the timer is cleared. + */ + #timerResolve = undefined; + + constructor() { + // Block async shutdown to ensure the last write goes through. + this.progress = {}; + lazy.PlacesUtils.history.shutdownClient.jsclient.addBlocker( + "Interactions.jsm:: store", + async () => this.flush(), + { fetchState: () => this.progress } + ); + + // Can be used to wait for the last pending write to have happened. + this.pendingPromise = Promise.resolve(); + } + + /** + * Synchronizes the pending interactions with the storage device. + * + * @returns {Promise} resolved when the pending data is on disk. + */ + async flush() { + if (this.#timer) { + lazy.clearTimeout(this.#timer); + this.#timerResolve(); + await this.#updateDatabase(); + } + } + + /** + * Completely clears the store and any pending writes. + * This exists for testing purposes. + */ + async reset() { + await lazy.PlacesUtils.withConnectionWrapper( + "Interactions.jsm::reset", + async db => { + await db.executeCached(`DELETE FROM moz_places_metadata`); + } + ); + if (this.#timer) { + lazy.clearTimeout(this.#timer); + this.#timer = undefined; + this.#timerResolve(); + this.#interactions.clear(); + } + } + + /** + * Registers an interaction to be stored persistently. At the end of the call + * the interaction has not yet been added to the store, tests can await + * flushStore() for that. + * + * @param {InteractionInfo} interaction + * The document information to write. + */ + add(interaction) { + lazy.logConsole.debug("Preparing interaction for storage", interaction); + + let interactionsForUrl = this.#interactions.get(interaction.url); + if (!interactionsForUrl) { + interactionsForUrl = new Map(); + this.#interactions.set(interaction.url, interactionsForUrl); + } + interactionsForUrl.set(interaction.created_at, interaction); + + if (!this.#timer) { + let promise = new Promise(resolve => { + this.#timerResolve = resolve; + this.#timer = lazy.setTimeout(() => { + this.#updateDatabase() + .catch(Cu.reportError) + .then(resolve); + }, lazy.saveInterval); + }); + this.pendingPromise = this.pendingPromise.then(() => promise); + } + } + + async #updateDatabase() { + this.#timer = undefined; + + // Reset the buffer. + let interactions = this.#interactions; + if (!interactions.size) { + return; + } + // Don't clear() this, since that would also clear interactions. + this.#interactions = new Map(); + + let params = {}; + let SQLInsertFragments = []; + let i = 0; + for (let interactionsForUrl of interactions.values()) { + for (let interaction of interactionsForUrl.values()) { + params[`url${i}`] = interaction.url; + params[`referrer${i}`] = interaction.referrer; + params[`created_at${i}`] = interaction.created_at; + params[`updated_at${i}`] = interaction.updated_at; + params[`document_type${i}`] = + interaction.documentType ?? Interactions.DOCUMENT_TYPE.GENERIC; + params[`total_view_time${i}`] = + Math.round(interaction.totalViewTime) || 0; + params[`typing_time${i}`] = Math.round(interaction.typingTime) || 0; + params[`key_presses${i}`] = interaction.keypresses || 0; + params[`scrolling_time${i}`] = + Math.round(interaction.scrollingTime) || 0; + params[`scrolling_distance${i}`] = + Math.round(interaction.scrollingDistance) || 0; + SQLInsertFragments.push(`( + (SELECT id FROM moz_places_metadata + WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}) + AND created_at = :created_at${i}), + (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}), + (SELECT id FROM moz_places WHERE url_hash = hash(:referrer${i}) AND url = :referrer${i} AND :referrer${i} != :url${i}), + :created_at${i}, + :updated_at${i}, + :document_type${i}, + :total_view_time${i}, + :typing_time${i}, + :key_presses${i}, + :scrolling_time${i}, + :scrolling_distance${i} + )`); + i++; + } + } + + lazy.logConsole.debug(`Storing ${i} entries in the database`); + + this.progress.pendingUpdates = i; + await lazy.PlacesUtils.withConnectionWrapper( + "Interactions.jsm::updateDatabase", + async db => { + await db.executeCached( + ` + WITH inserts (id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance) AS ( + VALUES ${SQLInsertFragments.join(", ")} + ) + INSERT OR REPLACE INTO moz_places_metadata ( + id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance + ) SELECT * FROM inserts WHERE place_id NOT NULL; + `, + params + ); + } + ); + this.progress.pendingUpdates = 0; + + Services.obs.notifyObservers(null, "places-metadata-updated"); + } +} |