summaryrefslogtreecommitdiffstats
path: root/mobile/android/chrome/geckoview/SessionStateAggregator.js
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/chrome/geckoview/SessionStateAggregator.js')
-rw-r--r--mobile/android/chrome/geckoview/SessionStateAggregator.js677
1 files changed, 677 insertions, 0 deletions
diff --git a/mobile/android/chrome/geckoview/SessionStateAggregator.js b/mobile/android/chrome/geckoview/SessionStateAggregator.js
new file mode 100644
index 0000000000..f856231d29
--- /dev/null
+++ b/mobile/android/chrome/geckoview/SessionStateAggregator.js
@@ -0,0 +1,677 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/* eslint-env mozilla/frame-script */
+
+"use strict";
+
+const { GeckoViewChildModule } = ChromeUtils.import(
+ "resource://gre/modules/GeckoViewChildModule.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeoutWithTarget: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const NO_INDEX = Number.MAX_SAFE_INTEGER;
+const LAST_INDEX = Number.MAX_SAFE_INTEGER - 1;
+const DEFAULT_INTERVAL_MS = 1500;
+
+// This pref controls whether or not we send updates to the parent on a timeout
+// or not, and should only be used for tests or debugging.
+const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates";
+
+const PREF_INTERVAL = "browser.sessionstore.interval";
+const PREF_SESSION_COLLECTION = "browser.sessionstore.platform_collection";
+
+class Handler {
+ constructor(store) {
+ this.store = store;
+ }
+
+ get mm() {
+ return this.store.mm;
+ }
+
+ get eventDispatcher() {
+ return this.store.eventDispatcher;
+ }
+
+ get messageQueue() {
+ return this.store.messageQueue;
+ }
+
+ get stateChangeNotifier() {
+ return this.store.stateChangeNotifier;
+ }
+}
+
+/**
+ * Listens for state change notifcations from webProgress and notifies each
+ * registered observer for either the start of a page load, or its completion.
+ */
+class StateChangeNotifier extends Handler {
+ constructor(store) {
+ super(store);
+
+ this._observers = new Set();
+ const ifreq = this.mm.docShell.QueryInterface(Ci.nsIInterfaceRequestor);
+ const webProgress = ifreq.getInterface(Ci.nsIWebProgress);
+ webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ }
+
+ /**
+ * Adds a given observer |obs| to the set of observers that will be notified
+ * when when a new document starts or finishes loading.
+ *
+ * @param obs (object)
+ */
+ addObserver(obs) {
+ this._observers.add(obs);
+ }
+
+ /**
+ * Notifies all observers that implement the given |method|.
+ *
+ * @param method (string)
+ */
+ notifyObservers(method) {
+ for (const obs of this._observers) {
+ if (typeof obs[method] == "function") {
+ obs[method]();
+ }
+ }
+ }
+
+ /**
+ * @see nsIWebProgressListener.onStateChange
+ */
+ onStateChange(webProgress, request, stateFlags, status) {
+ // Ignore state changes for subframes because we're only interested in the
+ // top-document starting or stopping its load.
+ if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) {
+ return;
+ }
+
+ // onStateChange will be fired when loading the initial about:blank URI for
+ // a browser, which we don't actually care about. This is particularly for
+ // the case of unrestored background tabs, where the content has not yet
+ // been restored: we don't want to accidentally send any updates to the
+ // parent when the about:blank placeholder page has loaded.
+ if (!this.mm.docShell.hasLoadedNonBlankURI) {
+ return;
+ }
+
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ this.notifyObservers("onPageLoadStarted");
+ } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ this.notifyObservers("onPageLoadCompleted");
+ }
+ }
+}
+StateChangeNotifier.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+]);
+
+/**
+ * Listens for changes to the session history. Whenever the user navigates
+ * we will collect URLs and everything belonging to session history.
+ *
+ * Causes a SessionStore:update message to be sent that contains the current
+ * session history.
+ *
+ * Example:
+ * {entries: [{url: "about:mozilla", ...}, ...], index: 1}
+ */
+class SessionHistoryListener extends Handler {
+ constructor(store) {
+ super(store);
+
+ this._fromIdx = NO_INDEX;
+
+ // The state change observer is needed to handle initial subframe loads.
+ // It will redundantly invalidate with the SHistoryListener in some cases
+ // but these invalidations are very cheap.
+ this.stateChangeNotifier.addObserver(this);
+
+ // By adding the SHistoryListener immediately, we will unfortunately be
+ // notified of every history entry as the tab is restored. We don't bother
+ // waiting to add the listener later because these notifications are cheap.
+ // We will likely only collect once since we are batching collection on
+ // a delay.
+ this.mm.docShell
+ .QueryInterface(Ci.nsIWebNavigation)
+ .sessionHistory.legacySHistory.addSHistoryListener(this);
+
+ // Listen for page title changes.
+ this.mm.addEventListener("DOMTitleChanged", this);
+ }
+
+ uninit() {
+ const sessionHistory = this.mm.docShell.QueryInterface(
+ Ci.nsIWebNavigation
+ ).sessionHistory;
+ if (sessionHistory) {
+ sessionHistory.legacySHistory.removeSHistoryListener(this);
+ }
+ }
+
+ collect() {
+ // We want to send down a historychange even for full collects in case our
+ // session history is a partial session history, in which case we don't have
+ // enough information for a full update. collectFrom(-1) tells the collect
+ // function to collect all data avaliable in this process.
+ if (this.mm.docShell) {
+ this.collectFrom(-1);
+ }
+ }
+
+ // History can grow relatively big with the nested elements, so if we don't have to, we
+ // don't want to send the entire history all the time. For a simple optimization
+ // we keep track of the smallest index from after any change has occured and we just send
+ // the elements from that index. If something more complicated happens we just clear it
+ // and send the entire history. We always send the additional info like the current selected
+ // index (so for going back and forth between history entries we set the index to LAST_INDEX
+ // if nothing else changed send an empty array and the additonal info like the selected index)
+ collectFrom(idx) {
+ if (this._fromIdx <= idx) {
+ // If we already know that we need to update history fromn index N we can ignore any changes
+ // tha happened with an element with index larger than N.
+ // Note: initially we use NO_INDEX which is MAX_SAFE_INTEGER which means we don't ignore anything
+ // here, and in case of navigation in the history back and forth we use LAST_INDEX which ignores
+ // only the subsequent navigations, but not any new elements added.
+ return;
+ }
+
+ this._fromIdx = idx;
+ this.messageQueue.push("historychange", () => {
+ if (this._fromIdx === NO_INDEX) {
+ return null;
+ }
+
+ const history = SessionHistory.collect(this.mm.docShell, this._fromIdx);
+ this._fromIdx = NO_INDEX;
+ return history;
+ });
+ }
+
+ handleEvent(event) {
+ this.collect();
+ }
+
+ onPageLoadCompleted() {
+ this.collect();
+ }
+
+ onPageLoadStarted() {
+ this.collect();
+ }
+
+ OnHistoryNewEntry(newURI, oldIndex) {
+ // We ought to collect the previously current entry as well, see bug 1350567.
+ // TODO: Reenable partial history collection for performance
+ // this.collectFrom(oldIndex);
+ this.collect();
+ }
+
+ OnHistoryGotoIndex(index, gotoURI) {
+ // We ought to collect the previously current entry as well, see bug 1350567.
+ // TODO: Reenable partial history collection for performance
+ // this.collectFrom(LAST_INDEX);
+ this.collect();
+ }
+
+ OnHistoryPurge(numEntries) {
+ this.collect();
+ }
+
+ OnHistoryReload(reloadURI, reloadFlags) {
+ this.collect();
+ return true;
+ }
+
+ OnHistoryReplaceEntry(index) {
+ this.collect();
+ }
+}
+SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsISHistoryListener",
+ "nsISupportsWeakReference",
+]);
+
+/**
+ * Listens for scroll position changes. Whenever the user scrolls the top-most
+ * frame we update the scroll position and will restore it when requested.
+ *
+ * Causes a SessionStore:update message to be sent that contains the current
+ * scroll positions as a tree of strings. If no frame of the whole frame tree
+ * is scrolled this will return null so that we don't tack a property onto
+ * the tabData object in the parent process.
+ *
+ * Example:
+ * {scroll: "100,100", zoom: {resolution: "1.5", displaySize:
+ * {height: "1600", width: "1000"}}, children:
+ * [null, null, {scroll: "200,200"}]}
+ */
+class ScrollPositionListener extends Handler {
+ constructor(store) {
+ super(store);
+
+ SessionStoreUtils.addDynamicFrameFilteredListener(
+ this.mm,
+ "mozvisualscroll",
+ this,
+ /* capture */ false,
+ /* system group */ true
+ );
+
+ SessionStoreUtils.addDynamicFrameFilteredListener(
+ this.mm,
+ "mozvisualresize",
+ this,
+ /* capture */ false,
+ /* system group */ true
+ );
+
+ this.stateChangeNotifier.addObserver(this);
+ }
+
+ handleEvent() {
+ this.messageQueue.push("scroll", () => this.collect());
+ }
+
+ onPageLoadCompleted() {
+ this.messageQueue.push("scroll", () => this.collect());
+ }
+
+ onPageLoadStarted() {
+ this.messageQueue.push("scroll", () => null);
+ }
+
+ collect() {
+ // TODO: Keep an eye on bug 1525259; we may not have to manually store zoom
+ // Save the current document resolution.
+ let zoom = 1;
+ const scrolldata =
+ SessionStoreUtils.collectScrollPosition(this.mm.content) || {};
+ const domWindowUtils = this.mm.content.windowUtils;
+ zoom = domWindowUtils.getResolution();
+ scrolldata.zoom = {};
+ scrolldata.zoom.resolution = zoom;
+
+ // Save some data that'll help in adjusting the zoom level
+ // when restoring in a different screen orientation.
+ const displaySize = {};
+ const width = {},
+ height = {};
+ domWindowUtils.getContentViewerSize(width, height);
+
+ displaySize.width = width.value;
+ displaySize.height = height.value;
+
+ scrolldata.zoom.displaySize = displaySize;
+
+ return scrolldata;
+ }
+}
+
+/**
+ * Listens for changes to input elements. Whenever the value of an input
+ * element changes we will re-collect data for the current frame tree and send
+ * a message to the parent process.
+ *
+ * Causes a SessionStore:update message to be sent that contains the form data
+ * for all reachable frames.
+ *
+ * Example:
+ * {
+ * formdata: {url: "http://mozilla.org/", id: {input_id: "input value"}},
+ * children: [
+ * null,
+ * {url: "http://sub.mozilla.org/", id: {input_id: "input value 2"}}
+ * ]
+ * }
+ */
+class FormDataListener extends Handler {
+ constructor(store) {
+ super(store);
+
+ SessionStoreUtils.addDynamicFrameFilteredListener(
+ this.mm,
+ "input",
+ this,
+ true
+ );
+ this.stateChangeNotifier.addObserver(this);
+ }
+
+ handleEvent() {
+ this.messageQueue.push("formdata", () => this.collect());
+ }
+
+ onPageLoadStarted() {
+ this.messageQueue.push("formdata", () => null);
+ }
+
+ collect() {
+ return SessionStoreUtils.collectFormData(this.mm.content);
+ }
+}
+
+/**
+ * A message queue that takes collected data and will take care of sending it
+ * to the chrome process. It allows flushing using synchronous messages and
+ * takes care of any race conditions that might occur because of that. Changes
+ * will be batched if they're pushed in quick succession to avoid a message
+ * flood.
+ */
+class MessageQueue extends Handler {
+ constructor(store) {
+ super(store);
+
+ /**
+ * A map (string -> lazy fn) holding lazy closures of all queued data
+ * collection routines. These functions will return data collected from the
+ * docShell.
+ */
+ this._data = new Map();
+
+ /**
+ * The delay (in ms) used to delay sending changes after data has been
+ * invalidated.
+ */
+ this.BATCH_DELAY_MS = 1000;
+
+ /**
+ * The minimum idle period (in ms) we need for sending data to chrome process.
+ */
+ this.NEEDED_IDLE_PERIOD_MS = 5;
+
+ /**
+ * Timeout for waiting an idle period to send data. We will set this from
+ * the pref "browser.sessionstore.interval".
+ */
+ this._timeoutWaitIdlePeriodMs = null;
+
+ /**
+ * The current timeout ID, null if there is no queue data. We use timeouts
+ * to damp a flood of data changes and send lots of changes as one batch.
+ */
+ this._timeout = null;
+
+ /**
+ * Whether or not sending batched messages on a timer is disabled. This should
+ * only be used for debugging or testing. If you need to access this value,
+ * you should probably use the timeoutDisabled getter.
+ */
+ this._timeoutDisabled = false;
+
+ /**
+ * True if there is already a send pending idle dispatch, set to prevent
+ * scheduling more than one. If false there may or may not be one scheduled.
+ */
+ this._idleScheduled = false;
+
+ this.timeoutDisabled = Services.prefs.getBoolPref(
+ TIMEOUT_DISABLED_PREF,
+ false
+ );
+ this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(
+ PREF_INTERVAL,
+ DEFAULT_INTERVAL_MS
+ );
+
+ Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this);
+ Services.prefs.addObserver(PREF_INTERVAL, this);
+ }
+
+ /**
+ * True if batched messages are not being fired on a timer. This should only
+ * ever be true when debugging or during tests.
+ */
+ get timeoutDisabled() {
+ return this._timeoutDisabled;
+ }
+
+ /**
+ * Disables sending batched messages on a timer. Also cancels any pending
+ * timers.
+ */
+ set timeoutDisabled(val) {
+ this._timeoutDisabled = val;
+
+ if (val && this._timeout) {
+ clearTimeout(this._timeout);
+ this._timeout = null;
+ }
+ }
+
+ uninit() {
+ this.cleanupTimers();
+ }
+
+ /**
+ * Cleanup pending idle callback and timer.
+ */
+ cleanupTimers() {
+ this._idleScheduled = false;
+ if (this._timeout) {
+ clearTimeout(this._timeout);
+ this._timeout = null;
+ }
+ }
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ switch (data) {
+ case TIMEOUT_DISABLED_PREF:
+ this.timeoutDisabled = Services.prefs.getBoolPref(
+ TIMEOUT_DISABLED_PREF,
+ false
+ );
+ break;
+ case PREF_INTERVAL:
+ this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(
+ PREF_INTERVAL,
+ DEFAULT_INTERVAL_MS
+ );
+ break;
+ default:
+ debug`Received unknown message: ${data}`;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Pushes a given |value| onto the queue. The given |key| represents the type
+ * of data that is stored and can override data that has been queued before
+ * but has not been sent to the parent process, yet.
+ *
+ * @param key (string)
+ * A unique identifier specific to the type of data this is passed.
+ * @param fn (function)
+ * A function that returns the value that will be sent to the parent
+ * process.
+ */
+ push(key, fn) {
+ this._data.set(key, fn);
+
+ if (!this._timeout && !this._timeoutDisabled) {
+ // Wait a little before sending the message to batch multiple changes.
+ this._timeout = setTimeoutWithTarget(
+ () => this.sendWhenIdle(),
+ this.BATCH_DELAY_MS,
+ this.mm.tabEventTarget
+ );
+ }
+ }
+
+ /**
+ * Sends queued data when the remaining idle time is enough or waiting too
+ * long; otherwise, request an idle time again. If the |deadline| is not
+ * given, this function is going to schedule the first request.
+ *
+ * @param deadline (object)
+ * An IdleDeadline object passed by idleDispatch().
+ */
+ sendWhenIdle(deadline) {
+ if (!this.mm.content) {
+ // The frameloader is being torn down. Nothing more to do.
+ return;
+ }
+
+ if (deadline) {
+ if (
+ deadline.didTimeout ||
+ deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS
+ ) {
+ this.send();
+ return;
+ }
+ } else if (this._idleScheduled) {
+ // Bail out if there's a pending run.
+ return;
+ }
+ ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), {
+ timeout: this._timeoutWaitIdlePeriodMs,
+ });
+ this._idleScheduled = true;
+ }
+
+ /**
+ * Sends queued data to the chrome process.
+ *
+ * @param options (object)
+ * {isFinal: true} to signal this is the final message sent on unload
+ */
+ send(options = {}) {
+ // Looks like we have been called off a timeout after the tab has been
+ // closed. The docShell is gone now and we can just return here as there
+ // is nothing to do.
+ if (!this.mm.docShell) {
+ return;
+ }
+
+ this.cleanupTimers();
+
+ const data = {};
+ for (const [key, func] of this._data) {
+ const value = func();
+
+ if (value || (key != "storagechange" && key != "historychange")) {
+ data[key] = value;
+ }
+ }
+
+ this._data.clear();
+
+ try {
+ // Send all data to the parent process.
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:StateUpdated",
+ data,
+ isFinal: options.isFinal || false,
+ epoch: this.store.epoch,
+ });
+ } catch (ex) {
+ if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) {
+ warn`Failed to save session state`;
+ }
+ }
+ }
+}
+
+class SessionStateAggregator extends GeckoViewChildModule {
+ constructor(aModuleName, aMessageManager) {
+ super(aModuleName, aMessageManager);
+
+ this.mm = aMessageManager;
+ this.messageQueue = new MessageQueue(this);
+ this.stateChangeNotifier = new StateChangeNotifier(this);
+
+ this.handlers = [
+ new SessionHistoryListener(this),
+ this.stateChangeNotifier,
+ this.messageQueue,
+ ];
+
+ if (!Services.prefs.getBoolPref(PREF_SESSION_COLLECTION, false)) {
+ this.handlers.push(
+ new FormDataListener(this),
+ new ScrollPositionListener(this)
+ );
+ }
+
+ this.messageManager.addMessageListener("GeckoView:FlushSessionState", this);
+ }
+
+ receiveMessage(aMsg) {
+ debug`receiveMessage: ${aMsg.name}`;
+
+ switch (aMsg.name) {
+ case "GeckoView:FlushSessionState":
+ this.flush();
+ break;
+ }
+ }
+
+ flush() {
+ // Flush the message queue, send the latest updates.
+ this.messageQueue.send();
+ }
+
+ onUnload() {
+ // Upon frameLoader destruction, send a final update message to
+ // the parent and flush all data currently held in the child.
+ this.messageQueue.send({ isFinal: true });
+
+ for (const handler of this.handlers) {
+ if (handler.uninit) {
+ handler.uninit();
+ }
+ }
+
+ // We don't need to take care of any StateChangeNotifier observers as they
+ // will die with the content script.
+ }
+}
+
+// TODO: Bug 1648158 Move SessionAggregator to the parent process
+class DummySessionStateAggregator extends GeckoViewChildModule {
+ constructor(aModuleName, aMessageManager) {
+ super(aModuleName, aMessageManager);
+ this.messageManager.addMessageListener("GeckoView:FlushSessionState", this);
+ }
+
+ receiveMessage(aMsg) {
+ debug`receiveMessage: ${aMsg.name}`;
+
+ switch (aMsg.name) {
+ case "GeckoView:FlushSessionState":
+ // Do nothing
+ break;
+ }
+ }
+}
+
+const { debug, warn } = SessionStateAggregator.initLogging(
+ "SessionStateAggregator"
+);
+
+const module = Services.appinfo.sessionHistoryInParent
+ ? // If history is handled in the parent we don't need a session aggregator
+ // TODO: Bug 1648158 remove this and do everything in the parent
+ DummySessionStateAggregator.create(this)
+ : SessionStateAggregator.create(this);