michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: /** michael@0: * This file is in transition. It was originally conceived to fulfill the michael@0: * needs of only Firefox Health Report. It is slowly being morphed into michael@0: * fulfilling the needs of all data reporting facilities in Gecko applications. michael@0: * As a result, some things feel a bit weird. michael@0: * michael@0: * DataReportingPolicy is both a driver for data reporting notification michael@0: * (a true policy) and the driver for FHR data submission. The latter should michael@0: * eventually be split into its own type and module. michael@0: */ michael@0: michael@0: "use strict"; michael@0: michael@0: #ifndef MERGED_COMPARTMENT michael@0: michael@0: this.EXPORTED_SYMBOLS = [ michael@0: "DataSubmissionRequest", // For test use only. michael@0: "DataReportingPolicy", michael@0: ]; michael@0: michael@0: const {classes: Cc, interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: #endif michael@0: michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/Promise.jsm"); michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://services-common/utils.js"); michael@0: Cu.import("resource://gre/modules/UpdateChannel.jsm"); michael@0: michael@0: const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; michael@0: michael@0: // Used as a sanity lower bound for dates stored in prefs. This module was michael@0: // implemented in 2012, so any earlier dates indicate an incorrect clock. michael@0: const OLDEST_ALLOWED_YEAR = 2012; michael@0: michael@0: /** michael@0: * Represents a request to display data policy. michael@0: * michael@0: * Instances of this are created when the policy is requesting the user's michael@0: * approval to agree to the data submission policy. michael@0: * michael@0: * Receivers of these instances are expected to call one or more of the on* michael@0: * functions when events occur. michael@0: * michael@0: * When one of these requests is received, the first thing a callee should do michael@0: * is present notification to the user of the data policy. When the notice michael@0: * is displayed to the user, the callee should call `onUserNotifyComplete`. michael@0: * This begins a countdown timer that upon completion will signal implicit michael@0: * acceptance of the policy. If for whatever reason the callee could not michael@0: * display a notice, it should call `onUserNotifyFailed`. michael@0: * michael@0: * Once the user is notified of the policy, the callee has the option of michael@0: * signaling explicit user acceptance or rejection of the policy. They do this michael@0: * by calling `onUserAccept` or `onUserReject`, respectively. These functions michael@0: * are essentially proxies to michael@0: * DataReportingPolicy.{recordUserAcceptance,recordUserRejection}. michael@0: * michael@0: * If the user never explicitly accepts or rejects the policy, it will be michael@0: * implicitly accepted after a specified duration of time. The notice is michael@0: * expected to remain displayed even after implicit acceptance (in case the michael@0: * user is away from the device). So, no event signaling implicit acceptance michael@0: * is exposed. michael@0: * michael@0: * Receivers of instances of this type should treat it as a black box with michael@0: * the exception of the on* functions. michael@0: * michael@0: * @param policy michael@0: * (DataReportingPolicy) The policy instance this request came from. michael@0: * @param deferred michael@0: * (deferred) The promise that will be fulfilled when display occurs. michael@0: */ michael@0: function NotifyPolicyRequest(policy, deferred) { michael@0: this.policy = policy; michael@0: this.deferred = deferred; michael@0: } michael@0: NotifyPolicyRequest.prototype = { michael@0: /** michael@0: * Called when the user is notified of the policy. michael@0: * michael@0: * This starts a countdown timer that will eventually signify implicit michael@0: * acceptance of the data policy. michael@0: */ michael@0: onUserNotifyComplete: function onUserNotified() { michael@0: this.deferred.resolve(); michael@0: return this.deferred.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Called when there was an error notifying the user about the policy. michael@0: * michael@0: * @param error michael@0: * (Error) Explains what went wrong. michael@0: */ michael@0: onUserNotifyFailed: function onUserNotifyFailed(error) { michael@0: this.deferred.reject(error); michael@0: }, michael@0: michael@0: /** michael@0: * Called when the user agreed to the data policy. michael@0: * michael@0: * @param reason michael@0: * (string) How the user agreed to the policy. michael@0: */ michael@0: onUserAccept: function onUserAccept(reason) { michael@0: this.policy.recordUserAcceptance(reason); michael@0: }, michael@0: michael@0: /** michael@0: * Called when the user rejected the data policy. michael@0: * michael@0: * @param reason michael@0: * (string) How the user rejected the policy. michael@0: */ michael@0: onUserReject: function onUserReject(reason) { michael@0: this.policy.recordUserRejection(reason); michael@0: }, michael@0: }; michael@0: michael@0: Object.freeze(NotifyPolicyRequest.prototype); michael@0: michael@0: /** michael@0: * Represents a request to submit data. michael@0: * michael@0: * Instances of this are created when the policy requests data upload or michael@0: * deletion. michael@0: * michael@0: * Receivers are expected to call one of the provided on* functions to signal michael@0: * completion of the request. michael@0: * michael@0: * Instances of this type should not be instantiated outside of this file. michael@0: * Receivers of instances of this type should not attempt to do anything with michael@0: * the instance except call one of the on* methods. michael@0: */ michael@0: this.DataSubmissionRequest = function (promise, expiresDate, isDelete) { michael@0: this.promise = promise; michael@0: this.expiresDate = expiresDate; michael@0: this.isDelete = isDelete; michael@0: michael@0: this.state = null; michael@0: this.reason = null; michael@0: } michael@0: michael@0: this.DataSubmissionRequest.prototype = Object.freeze({ michael@0: NO_DATA_AVAILABLE: "no-data-available", michael@0: SUBMISSION_SUCCESS: "success", michael@0: SUBMISSION_FAILURE_SOFT: "failure-soft", michael@0: SUBMISSION_FAILURE_HARD: "failure-hard", michael@0: UPLOAD_IN_PROGRESS: "upload-in-progress", michael@0: michael@0: /** michael@0: * No submission was attempted because no data was available. michael@0: * michael@0: * In the case of upload, this means there is no data to upload (perhaps michael@0: * it isn't available yet). In case of remote deletion, it means that there michael@0: * is no remote data to delete. michael@0: */ michael@0: onNoDataAvailable: function onNoDataAvailable() { michael@0: this.state = this.NO_DATA_AVAILABLE; michael@0: this.promise.resolve(this); michael@0: return this.promise.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Data submission has completed successfully. michael@0: * michael@0: * In case of upload, this means the upload completed successfully. In case michael@0: * of deletion, the data was deleted successfully. michael@0: * michael@0: * @param date michael@0: * (Date) When data submission occurred. michael@0: */ michael@0: onSubmissionSuccess: function onSubmissionSuccess(date) { michael@0: this.state = this.SUBMISSION_SUCCESS; michael@0: this.submissionDate = date; michael@0: this.promise.resolve(this); michael@0: return this.promise.promise; michael@0: }, michael@0: michael@0: /** michael@0: * There was a recoverable failure when submitting data. michael@0: * michael@0: * Perhaps the server was down. Perhaps the network wasn't available. The michael@0: * policy may request submission again after a short delay. michael@0: * michael@0: * @param reason michael@0: * (string) Why the failure occurred. For logging purposes only. michael@0: */ michael@0: onSubmissionFailureSoft: function onSubmissionFailureSoft(reason=null) { michael@0: this.state = this.SUBMISSION_FAILURE_SOFT; michael@0: this.reason = reason; michael@0: this.promise.resolve(this); michael@0: return this.promise.promise; michael@0: }, michael@0: michael@0: /** michael@0: * There was an unrecoverable failure when submitting data. michael@0: * michael@0: * Perhaps the client is misconfigured. Perhaps the server rejected the data. michael@0: * Attempts at performing submission again will yield the same result. So, michael@0: * the policy should not try again (until the next day). michael@0: * michael@0: * @param reason michael@0: * (string) Why the failure occurred. For logging purposes only. michael@0: */ michael@0: onSubmissionFailureHard: function onSubmissionFailureHard(reason=null) { michael@0: this.state = this.SUBMISSION_FAILURE_HARD; michael@0: this.reason = reason; michael@0: this.promise.resolve(this); michael@0: return this.promise.promise; michael@0: }, michael@0: michael@0: /** michael@0: * The request was aborted because an upload was already in progress. michael@0: */ michael@0: onUploadInProgress: function (reason=null) { michael@0: this.state = this.UPLOAD_IN_PROGRESS; michael@0: this.reason = reason; michael@0: this.promise.resolve(this); michael@0: return this.promise.promise; michael@0: }, michael@0: }); michael@0: michael@0: /** michael@0: * Manages scheduling of Firefox Health Report data submission. michael@0: * michael@0: * The rules of data submission are as follows: michael@0: * michael@0: * 1. Do not submit data more than once every 24 hours. michael@0: * 2. Try to submit as close to 24 hours apart as possible. michael@0: * 3. Do not submit too soon after application startup so as to not negatively michael@0: * impact performance at startup. michael@0: * 4. Before first ever data submission, the user should be notified about michael@0: * data collection practices. michael@0: * 5. User should have opportunity to react to this notification before michael@0: * data submission. michael@0: * 6. Display of notification without any explicit user action constitutes michael@0: * implicit consent after a certain duration of time. michael@0: * 7. If data submission fails, try at most 2 additional times before giving michael@0: * up on that day's submission. michael@0: * michael@0: * The listener passed into the instance must have the following properties michael@0: * (which are callbacks that will be invoked at certain key events): michael@0: * michael@0: * * onRequestDataUpload(request) - Called when the policy is requesting michael@0: * data to be submitted. The function is passed a `DataSubmissionRequest`. michael@0: * The listener should call one of the special resolving functions on that michael@0: * instance (see the documentation for that type). michael@0: * michael@0: * * onRequestRemoteDelete(request) - Called when the policy is requesting michael@0: * deletion of remotely stored data. The function is passed a michael@0: * `DataSubmissionRequest`. The listener should call one of the special michael@0: * resolving functions on that instance (just like `onRequestDataUpload`). michael@0: * michael@0: * * onNotifyDataPolicy(request) - Called when the policy is requesting the michael@0: * user to be notified that data submission will occur. The function michael@0: * receives a `NotifyPolicyRequest` instance. The callee should call one or michael@0: * more of the functions on that instance when specific events occur. See michael@0: * the documentation for that type for more. michael@0: * michael@0: * Note that the notification method is abstracted. Different applications michael@0: * can have different mechanisms by which they notify the user of data michael@0: * submission practices. michael@0: * michael@0: * @param policyPrefs michael@0: * (Preferences) Handle on preferences branch on which state will be michael@0: * queried and stored. michael@0: * @param healthReportPrefs michael@0: * (Preferences) Handle on preferences branch holding Health Report state. michael@0: * @param listener michael@0: * (object) Object with callbacks that will be invoked at certain key michael@0: * events. michael@0: */ michael@0: this.DataReportingPolicy = function (prefs, healthReportPrefs, listener) { michael@0: this._log = Log.repository.getLogger("Services.DataReporting.Policy"); michael@0: this._log.level = Log.Level["Debug"]; michael@0: michael@0: for (let handler of this.REQUIRED_LISTENERS) { michael@0: if (!listener[handler]) { michael@0: throw new Error("Passed listener does not contain required handler: " + michael@0: handler); michael@0: } michael@0: } michael@0: michael@0: this._prefs = prefs; michael@0: this._healthReportPrefs = healthReportPrefs; michael@0: this._listener = listener; michael@0: michael@0: // If the policy version has changed, reset all preferences, so that michael@0: // the notification reappears. michael@0: let acceptedVersion = this._prefs.get("dataSubmissionPolicyAcceptedVersion"); michael@0: if (typeof(acceptedVersion) == "number" && michael@0: acceptedVersion < this.minimumPolicyVersion) { michael@0: this._log.info("policy version has changed - resetting all prefs"); michael@0: // We don't want to delay the notification in this case. michael@0: let firstRunToRestore = this.firstRunDate; michael@0: this._prefs.resetBranch(); michael@0: this.firstRunDate = firstRunToRestore.getTime() ? michael@0: firstRunToRestore : this.now(); michael@0: } else if (!this.firstRunDate.getTime()) { michael@0: // If we've never run before, record the current time. michael@0: this.firstRunDate = this.now(); michael@0: } michael@0: michael@0: // Install an observer so that we can act on changes from external michael@0: // code (such as Android UI). michael@0: // Use a function because this is the only place where the Preferences michael@0: // abstraction is way less usable than nsIPrefBranch. michael@0: // michael@0: // Hang on to the observer here so that tests can reach it. michael@0: this.uploadEnabledObserver = function onUploadEnabledChanged() { michael@0: if (this.pendingDeleteRemoteData || this.healthReportUploadEnabled) { michael@0: // Nothing to do: either we're already deleting because the caller michael@0: // came through the front door (rHRUE), or they set the flag to true. michael@0: return; michael@0: } michael@0: this._log.info("uploadEnabled pref changed. Scheduling deletion."); michael@0: this.deleteRemoteData(); michael@0: }.bind(this); michael@0: michael@0: healthReportPrefs.observe("uploadEnabled", this.uploadEnabledObserver); michael@0: michael@0: // Ensure we are scheduled to submit. michael@0: if (!this.nextDataSubmissionDate.getTime()) { michael@0: this.nextDataSubmissionDate = this._futureDate(MILLISECONDS_PER_DAY); michael@0: } michael@0: michael@0: // Date at which we performed user notification of acceptance. michael@0: // This is an instance variable because implicit acceptance should only michael@0: // carry forward through a single application instance. michael@0: this._dataSubmissionPolicyNotifiedDate = null; michael@0: michael@0: // Record when we last requested for submitted data to be sent. This is michael@0: // to avoid having multiple outstanding requests. michael@0: this._inProgressSubmissionRequest = null; michael@0: }; michael@0: michael@0: this.DataReportingPolicy.prototype = Object.freeze({ michael@0: /** michael@0: * How long after first run we should notify about data submission. michael@0: */ michael@0: SUBMISSION_NOTIFY_INTERVAL_MSEC: 12 * 60 * 60 * 1000, michael@0: michael@0: /** michael@0: * Time that must elapse with no user action for implicit acceptance. michael@0: * michael@0: * THERE ARE POTENTIAL LEGAL IMPLICATIONS OF CHANGING THIS VALUE. Check with michael@0: * Privacy and/or Legal before modifying. michael@0: */ michael@0: IMPLICIT_ACCEPTANCE_INTERVAL_MSEC: 8 * 60 * 60 * 1000, michael@0: michael@0: /** michael@0: * How often to poll to see if we need to do something. michael@0: * michael@0: * The interval needs to be short enough such that short-lived applications michael@0: * have an opportunity to submit data. But, it also needs to be long enough michael@0: * to not negatively impact performance. michael@0: * michael@0: * The random bit is to ensure that other systems scheduling around the same michael@0: * interval don't all get scheduled together. michael@0: */ michael@0: POLL_INTERVAL_MSEC: (60 * 1000) + Math.floor(2.5 * 1000 * Math.random()), michael@0: michael@0: /** michael@0: * How long individual data submission requests live before expiring. michael@0: * michael@0: * Data submission requests have this long to complete before we give up on michael@0: * them and try again. michael@0: * michael@0: * We want this to be short enough that we retry frequently enough but long michael@0: * enough to give slow networks and systems time to handle it. michael@0: */ michael@0: SUBMISSION_REQUEST_EXPIRE_INTERVAL_MSEC: 10 * 60 * 1000, michael@0: michael@0: /** michael@0: * Our backoff schedule in case of submission failure. michael@0: * michael@0: * This dictates both the number of times we retry a daily submission and michael@0: * when to retry after each failure. michael@0: * michael@0: * Each element represents how long to wait after each recoverable failure. michael@0: * After the first failure, we wait the time in element 0 before trying michael@0: * again. After the second failure, we wait the time in element 1. Once michael@0: * we run out of values in this array, we give up on that day's submission michael@0: * and schedule for a day out. michael@0: */ michael@0: FAILURE_BACKOFF_INTERVALS: [ michael@0: 15 * 60 * 1000, michael@0: 60 * 60 * 1000, michael@0: ], michael@0: michael@0: /** michael@0: * State of user notification of data submission. michael@0: */ michael@0: STATE_NOTIFY_UNNOTIFIED: "not-notified", michael@0: STATE_NOTIFY_WAIT: "waiting", michael@0: STATE_NOTIFY_COMPLETE: "ok", michael@0: michael@0: REQUIRED_LISTENERS: [ michael@0: "onRequestDataUpload", michael@0: "onRequestRemoteDelete", michael@0: "onNotifyDataPolicy", michael@0: ], michael@0: michael@0: /** michael@0: * The first time the health report policy came into existence. michael@0: * michael@0: * This is used for scheduling of the initial submission. michael@0: */ michael@0: get firstRunDate() { michael@0: return CommonUtils.getDatePref(this._prefs, "firstRunTime", 0, this._log, michael@0: OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: set firstRunDate(value) { michael@0: this._log.debug("Setting first-run date: " + value); michael@0: CommonUtils.setDatePref(this._prefs, "firstRunTime", value, michael@0: OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: /** michael@0: * Short circuit policy checking and always assume acceptance. michael@0: * michael@0: * This shuld never be set by the user. Instead, it is a per-application or michael@0: * per-deployment default pref. michael@0: */ michael@0: get dataSubmissionPolicyBypassAcceptance() { michael@0: return this._prefs.get("dataSubmissionPolicyBypassAcceptance", false); michael@0: }, michael@0: michael@0: /** michael@0: * When the user was notified that data submission could occur. michael@0: * michael@0: * This is used for logging purposes. this._dataSubmissionPolicyNotifiedDate michael@0: * is what's used internally. michael@0: */ michael@0: get dataSubmissionPolicyNotifiedDate() { michael@0: return CommonUtils.getDatePref(this._prefs, michael@0: "dataSubmissionPolicyNotifiedTime", 0, michael@0: this._log, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: set dataSubmissionPolicyNotifiedDate(value) { michael@0: this._log.debug("Setting user notified date: " + value); michael@0: CommonUtils.setDatePref(this._prefs, "dataSubmissionPolicyNotifiedTime", michael@0: value, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: /** michael@0: * When the user accepted or rejected the data submission policy. michael@0: * michael@0: * If there was implicit acceptance, this will be set to the time of that. michael@0: */ michael@0: get dataSubmissionPolicyResponseDate() { michael@0: return CommonUtils.getDatePref(this._prefs, michael@0: "dataSubmissionPolicyResponseTime", michael@0: 0, this._log, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: set dataSubmissionPolicyResponseDate(value) { michael@0: this._log.debug("Setting user notified reaction date: " + value); michael@0: CommonUtils.setDatePref(this._prefs, michael@0: "dataSubmissionPolicyResponseTime", michael@0: value, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: /** michael@0: * Records the result of user notification of data submission policy. michael@0: * michael@0: * This is used for logging and diagnostics purposes. It can answer the michael@0: * question "how was data submission agreed to on this profile?" michael@0: * michael@0: * Not all values are defined by this type and can come from other systems. michael@0: * michael@0: * The value must be a string and should be something machine readable. e.g. michael@0: * "accept-user-clicked-ok-button-in-info-bar" michael@0: */ michael@0: get dataSubmissionPolicyResponseType() { michael@0: return this._prefs.get("dataSubmissionPolicyResponseType", michael@0: "none-recorded"); michael@0: }, michael@0: michael@0: set dataSubmissionPolicyResponseType(value) { michael@0: if (typeof(value) != "string") { michael@0: throw new Error("Value must be a string. Got " + typeof(value)); michael@0: } michael@0: michael@0: this._prefs.set("dataSubmissionPolicyResponseType", value); michael@0: }, michael@0: michael@0: /** michael@0: * Whether submission of data is allowed. michael@0: * michael@0: * This is the master switch for remote server communication. If it is michael@0: * false, we never request upload or deletion. michael@0: */ michael@0: get dataSubmissionEnabled() { michael@0: // Default is true because we are opt-out. michael@0: return this._prefs.get("dataSubmissionEnabled", true); michael@0: }, michael@0: michael@0: set dataSubmissionEnabled(value) { michael@0: this._prefs.set("dataSubmissionEnabled", !!value); michael@0: }, michael@0: michael@0: /** michael@0: * The minimum policy version which for dataSubmissionPolicyAccepted to michael@0: * to be valid. michael@0: */ michael@0: get minimumPolicyVersion() { michael@0: // First check if the current channel has an ove michael@0: let channel = UpdateChannel.get(false); michael@0: let channelPref = this._prefs.get("minimumPolicyVersion.channel-" + channel); michael@0: return channelPref !== undefined ? michael@0: channelPref : this._prefs.get("minimumPolicyVersion", 1); michael@0: }, michael@0: michael@0: /** michael@0: * Whether the user has accepted that data submission can occur. michael@0: * michael@0: * This overrides dataSubmissionEnabled. michael@0: */ michael@0: get dataSubmissionPolicyAccepted() { michael@0: // Be conservative and default to false. michael@0: return this._prefs.get("dataSubmissionPolicyAccepted", false); michael@0: }, michael@0: michael@0: set dataSubmissionPolicyAccepted(value) { michael@0: this._prefs.set("dataSubmissionPolicyAccepted", !!value); michael@0: if (!!value) { michael@0: let currentPolicyVersion = this._prefs.get("currentPolicyVersion", 1); michael@0: this._prefs.set("dataSubmissionPolicyAcceptedVersion", currentPolicyVersion); michael@0: } else { michael@0: this._prefs.reset("dataSubmissionPolicyAcceptedVersion"); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * The state of user notification of the data policy. michael@0: * michael@0: * This must be DataReportingPolicy.STATE_NOTIFY_COMPLETE before data michael@0: * submission can occur. michael@0: * michael@0: * @return DataReportingPolicy.STATE_NOTIFY_* constant. michael@0: */ michael@0: get notifyState() { michael@0: if (this.dataSubmissionPolicyResponseDate.getTime()) { michael@0: return this.STATE_NOTIFY_COMPLETE; michael@0: } michael@0: michael@0: // We get the local state - not the state from prefs - because we don't want michael@0: // a value from a previous application run to interfere. This prevents michael@0: // a scenario where notification occurs just before application shutdown and michael@0: // notification is displayed for shorter than the policy requires. michael@0: if (!this._dataSubmissionPolicyNotifiedDate) { michael@0: return this.STATE_NOTIFY_UNNOTIFIED; michael@0: } michael@0: michael@0: return this.STATE_NOTIFY_WAIT; michael@0: }, michael@0: michael@0: /** michael@0: * When this policy last requested data submission. michael@0: * michael@0: * This is used mainly for forensics purposes and should have no bearing michael@0: * on scheduling or run-time behavior. michael@0: */ michael@0: get lastDataSubmissionRequestedDate() { michael@0: return CommonUtils.getDatePref(this._healthReportPrefs, michael@0: "lastDataSubmissionRequestedTime", 0, michael@0: this._log, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: set lastDataSubmissionRequestedDate(value) { michael@0: CommonUtils.setDatePref(this._healthReportPrefs, michael@0: "lastDataSubmissionRequestedTime", michael@0: value, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: /** michael@0: * When the last data submission actually occurred. michael@0: * michael@0: * This is used mainly for forensics purposes and should have no bearing on michael@0: * actual scheduling. michael@0: */ michael@0: get lastDataSubmissionSuccessfulDate() { michael@0: return CommonUtils.getDatePref(this._healthReportPrefs, michael@0: "lastDataSubmissionSuccessfulTime", 0, michael@0: this._log, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: set lastDataSubmissionSuccessfulDate(value) { michael@0: CommonUtils.setDatePref(this._healthReportPrefs, michael@0: "lastDataSubmissionSuccessfulTime", michael@0: value, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: /** michael@0: * When we last encountered a submission failure. michael@0: * michael@0: * This is used for forensics purposes and should have no bearing on michael@0: * scheduling. michael@0: */ michael@0: get lastDataSubmissionFailureDate() { michael@0: return CommonUtils.getDatePref(this._healthReportPrefs, michael@0: "lastDataSubmissionFailureTime", michael@0: 0, this._log, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: set lastDataSubmissionFailureDate(value) { michael@0: CommonUtils.setDatePref(this._healthReportPrefs, michael@0: "lastDataSubmissionFailureTime", michael@0: value, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: /** michael@0: * When the next data submission is scheduled to occur. michael@0: * michael@0: * This is maintained internally by this type. External users should not michael@0: * mutate this value. michael@0: */ michael@0: get nextDataSubmissionDate() { michael@0: return CommonUtils.getDatePref(this._healthReportPrefs, michael@0: "nextDataSubmissionTime", 0, michael@0: this._log, OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: set nextDataSubmissionDate(value) { michael@0: CommonUtils.setDatePref(this._healthReportPrefs, michael@0: "nextDataSubmissionTime", value, michael@0: OLDEST_ALLOWED_YEAR); michael@0: }, michael@0: michael@0: /** michael@0: * The number of submission failures for this day's upload. michael@0: * michael@0: * This is used to drive backoff and scheduling. michael@0: */ michael@0: get currentDaySubmissionFailureCount() { michael@0: let v = this._healthReportPrefs.get("currentDaySubmissionFailureCount", 0); michael@0: michael@0: if (!Number.isInteger(v)) { michael@0: v = 0; michael@0: } michael@0: michael@0: return v; michael@0: }, michael@0: michael@0: set currentDaySubmissionFailureCount(value) { michael@0: if (!Number.isInteger(value)) { michael@0: throw new Error("Value must be integer: " + value); michael@0: } michael@0: michael@0: this._healthReportPrefs.set("currentDaySubmissionFailureCount", value); michael@0: }, michael@0: michael@0: /** michael@0: * Whether a request to delete remote data is awaiting completion. michael@0: * michael@0: * If this is true, the policy will request that remote data be deleted. michael@0: * Furthermore, no new data will be uploaded (if it's even allowed) until michael@0: * the remote deletion is fulfilled. michael@0: */ michael@0: get pendingDeleteRemoteData() { michael@0: return !!this._healthReportPrefs.get("pendingDeleteRemoteData", false); michael@0: }, michael@0: michael@0: set pendingDeleteRemoteData(value) { michael@0: this._healthReportPrefs.set("pendingDeleteRemoteData", !!value); michael@0: }, michael@0: michael@0: /** michael@0: * Whether upload of Firefox Health Report data is enabled. michael@0: */ michael@0: get healthReportUploadEnabled() { michael@0: return !!this._healthReportPrefs.get("uploadEnabled", true); michael@0: }, michael@0: michael@0: // External callers should update this via `recordHealthReportUploadEnabled` michael@0: // to ensure appropriate side-effects are performed. michael@0: set healthReportUploadEnabled(value) { michael@0: this._healthReportPrefs.set("uploadEnabled", !!value); michael@0: }, michael@0: michael@0: /** michael@0: * Whether the FHR upload enabled setting is locked and can't be changed. michael@0: */ michael@0: get healthReportUploadLocked() { michael@0: return this._healthReportPrefs.locked("uploadEnabled"); michael@0: }, michael@0: michael@0: /** michael@0: * Record user acceptance of data submission policy. michael@0: * michael@0: * Data submission will not be allowed to occur until this is called. michael@0: * michael@0: * This is typically called through the `onUserAccept` property attached to michael@0: * the promise passed to `onUserNotify` in the policy listener. But, it can michael@0: * be called through other interfaces at any time and the call will have michael@0: * an impact on future data submissions. michael@0: * michael@0: * @param reason michael@0: * (string) How the user accepted the data submission policy. michael@0: */ michael@0: recordUserAcceptance: function recordUserAcceptance(reason="no-reason") { michael@0: this._log.info("User accepted data submission policy: " + reason); michael@0: this.dataSubmissionPolicyResponseDate = this.now(); michael@0: this.dataSubmissionPolicyResponseType = "accepted-" + reason; michael@0: this.dataSubmissionPolicyAccepted = true; michael@0: }, michael@0: michael@0: /** michael@0: * Record user rejection of submission policy. michael@0: * michael@0: * Data submission will not be allowed to occur if this is called. michael@0: * michael@0: * This is typically called through the `onUserReject` property attached to michael@0: * the promise passed to `onUserNotify` in the policy listener. But, it can michael@0: * be called through other interfaces at any time and the call will have an michael@0: * impact on future data submissions. michael@0: */ michael@0: recordUserRejection: function recordUserRejection(reason="no-reason") { michael@0: this._log.info("User rejected data submission policy: " + reason); michael@0: this.dataSubmissionPolicyResponseDate = this.now(); michael@0: this.dataSubmissionPolicyResponseType = "rejected-" + reason; michael@0: this.dataSubmissionPolicyAccepted = false; michael@0: }, michael@0: michael@0: /** michael@0: * Record the user's intent for whether FHR should upload data. michael@0: * michael@0: * This is the preferred way for XUL applications to record a user's michael@0: * preference on whether Firefox Health Report should upload data to michael@0: * a server. michael@0: * michael@0: * If upload is disabled through this API, a request for remote data michael@0: * deletion is initiated automatically. michael@0: * michael@0: * If upload is being disabled and this operation is scheduled to michael@0: * occur immediately, a promise will be returned. This promise will be michael@0: * fulfilled when the deletion attempt finishes. If upload is being michael@0: * disabled and a promise is not returned, callers must poll michael@0: * `haveRemoteData` on the HealthReporter instance to see if remote michael@0: * data has been deleted. michael@0: * michael@0: * @param flag michael@0: * (bool) Whether data submission is enabled or disabled. michael@0: * @param reason michael@0: * (string) Why this value is being adjusted. For logging michael@0: * purposes only. michael@0: */ michael@0: recordHealthReportUploadEnabled: function (flag, reason="no-reason") { michael@0: let result = null; michael@0: if (!flag) { michael@0: result = this.deleteRemoteData(reason); michael@0: } michael@0: michael@0: this.healthReportUploadEnabled = flag; michael@0: return result; michael@0: }, michael@0: michael@0: /** michael@0: * Request that remote data be deleted. michael@0: * michael@0: * This will record an intent that previously uploaded data is to be deleted. michael@0: * The policy will eventually issue a request to the listener for data michael@0: * deletion. It will keep asking for deletion until the listener acknowledges michael@0: * that data has been deleted. michael@0: */ michael@0: deleteRemoteData: function deleteRemoteData(reason="no-reason") { michael@0: this._log.info("Remote data deletion requested: " + reason); michael@0: michael@0: this.pendingDeleteRemoteData = true; michael@0: michael@0: // We want delete deletion to occur as soon as possible. Move up any michael@0: // pending scheduled data submission and try to trigger. michael@0: this.nextDataSubmissionDate = this.now(); michael@0: return this.checkStateAndTrigger(); michael@0: }, michael@0: michael@0: /** michael@0: * Start background polling for activity. michael@0: * michael@0: * This will set up a recurring timer that will periodically check if michael@0: * activity is warranted. michael@0: * michael@0: * You typically call this function for each constructed instance. michael@0: */ michael@0: startPolling: function startPolling() { michael@0: this.stopPolling(); michael@0: michael@0: this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); michael@0: this._timer.initWithCallback({ michael@0: notify: function notify() { michael@0: this.checkStateAndTrigger(); michael@0: }.bind(this) michael@0: }, this.POLL_INTERVAL_MSEC, this._timer.TYPE_REPEATING_SLACK); michael@0: }, michael@0: michael@0: /** michael@0: * Stop background polling for activity. michael@0: * michael@0: * This should be called when the instance is no longer needed. michael@0: */ michael@0: stopPolling: function stopPolling() { michael@0: if (this._timer) { michael@0: this._timer.cancel(); michael@0: this._timer = null; michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Abstraction for obtaining current time. michael@0: * michael@0: * The purpose of this is to facilitate testing. Testing code can monkeypatch michael@0: * this on instances instead of modifying the singleton Date object. michael@0: */ michael@0: now: function now() { michael@0: return new Date(); michael@0: }, michael@0: michael@0: /** michael@0: * Check state and trigger actions, if necessary. michael@0: * michael@0: * This is what enforces the submission and notification policy detailed michael@0: * above. You can think of this as the driver for health report data michael@0: * submission. michael@0: * michael@0: * Typically this function is called automatically by the background polling. michael@0: * But, it can safely be called manually as needed. michael@0: */ michael@0: checkStateAndTrigger: function checkStateAndTrigger() { michael@0: // If the master data submission kill switch is toggled, we have nothing michael@0: // to do. We don't notify about data policies because this would have michael@0: // no effect. michael@0: if (!this.dataSubmissionEnabled) { michael@0: this._log.debug("Data submission is disabled. Doing nothing."); michael@0: return; michael@0: } michael@0: michael@0: let now = this.now(); michael@0: let nowT = now.getTime(); michael@0: let nextSubmissionDate = this.nextDataSubmissionDate; michael@0: michael@0: // If the system clock were ever set to a time in the distant future, michael@0: // it's possible our next schedule date is far out as well. We know michael@0: // we shouldn't schedule for more than a day out, so we reset the next michael@0: // scheduled date appropriately. 3 days was chosen arbitrarily. michael@0: if (nextSubmissionDate.getTime() >= nowT + 3 * MILLISECONDS_PER_DAY) { michael@0: this._log.warn("Next data submission time is far away. Was the system " + michael@0: "clock recently readjusted? " + nextSubmissionDate); michael@0: michael@0: // It shouldn't really matter what we set this to. 1 day in the future michael@0: // should be pretty safe. michael@0: this._moveScheduleForward24h(); michael@0: michael@0: // Fall through since we may have other actions. michael@0: } michael@0: michael@0: // Tend to any in progress work. michael@0: if (this._processInProgressSubmission()) { michael@0: return; michael@0: } michael@0: michael@0: // Requests to delete remote data take priority above everything else. michael@0: if (this.pendingDeleteRemoteData) { michael@0: if (nowT < nextSubmissionDate.getTime()) { michael@0: this._log.debug("Deletion request is scheduled for the future: " + michael@0: nextSubmissionDate); michael@0: return; michael@0: } michael@0: michael@0: return this._dispatchSubmissionRequest("onRequestRemoteDelete", true); michael@0: } michael@0: michael@0: if (!this.healthReportUploadEnabled) { michael@0: this._log.debug("Data upload is disabled. Doing nothing."); michael@0: return; michael@0: } michael@0: michael@0: // If the user hasn't responded to the data policy, don't do anything. michael@0: if (!this.ensureNotifyResponse(now)) { michael@0: return; michael@0: } michael@0: michael@0: // User has opted out of data submission. michael@0: if (!this.dataSubmissionPolicyAccepted && !this.dataSubmissionPolicyBypassAcceptance) { michael@0: this._log.debug("Data submission has been disabled per user request."); michael@0: return; michael@0: } michael@0: michael@0: // User has responded to data policy and data submission is enabled. Now michael@0: // comes the scheduling part. michael@0: michael@0: if (nowT < nextSubmissionDate.getTime()) { michael@0: this._log.debug("Next data submission is scheduled in the future: " + michael@0: nextSubmissionDate); michael@0: return; michael@0: } michael@0: michael@0: return this._dispatchSubmissionRequest("onRequestDataUpload", false); michael@0: }, michael@0: michael@0: /** michael@0: * Ensure user has responded to data submission policy. michael@0: * michael@0: * This must be called before data submission. If the policy has not been michael@0: * responded to, data submission must not occur. michael@0: * michael@0: * @return bool Whether user has responded to data policy. michael@0: */ michael@0: ensureNotifyResponse: function ensureNotifyResponse(now) { michael@0: if (this.dataSubmissionPolicyBypassAcceptance) { michael@0: return true; michael@0: } michael@0: michael@0: let notifyState = this.notifyState; michael@0: michael@0: if (notifyState == this.STATE_NOTIFY_UNNOTIFIED) { michael@0: let notifyAt = new Date(this.firstRunDate.getTime() + michael@0: this.SUBMISSION_NOTIFY_INTERVAL_MSEC); michael@0: michael@0: if (now.getTime() < notifyAt.getTime()) { michael@0: this._log.debug("Don't have to notify about data submission yet."); michael@0: return false; michael@0: } michael@0: michael@0: let onComplete = function onComplete() { michael@0: this._log.info("Data submission notification presented."); michael@0: let now = this.now(); michael@0: michael@0: this._dataSubmissionPolicyNotifiedDate = now; michael@0: this.dataSubmissionPolicyNotifiedDate = now; michael@0: }.bind(this); michael@0: michael@0: let deferred = Promise.defer(); michael@0: michael@0: deferred.promise.then(onComplete, (error) => { michael@0: this._log.warn("Data policy notification presentation failed: " + michael@0: CommonUtils.exceptionStr(error)); michael@0: }); michael@0: michael@0: this._log.info("Requesting display of data policy."); michael@0: let request = new NotifyPolicyRequest(this, deferred); michael@0: michael@0: try { michael@0: this._listener.onNotifyDataPolicy(request); michael@0: } catch (ex) { michael@0: this._log.warn("Exception when calling onNotifyDataPolicy: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: return false; michael@0: } michael@0: michael@0: // We're waiting for user action or implicit acceptance after display. michael@0: if (notifyState == this.STATE_NOTIFY_WAIT) { michael@0: // Check for implicit acceptance. michael@0: let implicitAcceptance = michael@0: this._dataSubmissionPolicyNotifiedDate.getTime() + michael@0: this.IMPLICIT_ACCEPTANCE_INTERVAL_MSEC; michael@0: michael@0: this._log.debug("Now: " + now.getTime()); michael@0: this._log.debug("Will accept: " + implicitAcceptance); michael@0: if (now.getTime() < implicitAcceptance) { michael@0: this._log.debug("Still waiting for reaction or implicit acceptance. " + michael@0: "Now: " + now.getTime() + " < " + michael@0: "Accept: " + implicitAcceptance); michael@0: return false; michael@0: } michael@0: michael@0: this.recordUserAcceptance("implicit-time-elapsed"); michael@0: return true; michael@0: } michael@0: michael@0: // If this happens, we have a coding error in this file. michael@0: if (notifyState != this.STATE_NOTIFY_COMPLETE) { michael@0: throw new Error("Unknown notification state: " + notifyState); michael@0: } michael@0: michael@0: return true; michael@0: }, michael@0: michael@0: _processInProgressSubmission: function _processInProgressSubmission() { michael@0: if (!this._inProgressSubmissionRequest) { michael@0: return false; michael@0: } michael@0: michael@0: let now = this.now().getTime(); michael@0: if (this._inProgressSubmissionRequest.expiresDate.getTime() > now) { michael@0: this._log.info("Waiting on in-progress submission request to finish."); michael@0: return true; michael@0: } michael@0: michael@0: this._log.warn("Old submission request has expired from no activity."); michael@0: this._inProgressSubmissionRequest.promise.reject(new Error("Request has expired.")); michael@0: this._inProgressSubmissionRequest = null; michael@0: this._handleSubmissionFailure(); michael@0: michael@0: return false; michael@0: }, michael@0: michael@0: _dispatchSubmissionRequest: function _dispatchSubmissionRequest(handler, isDelete) { michael@0: let now = this.now(); michael@0: michael@0: // We're past our scheduled next data submission date, so let's do it! michael@0: this.lastDataSubmissionRequestedDate = now; michael@0: let deferred = Promise.defer(); michael@0: let requestExpiresDate = michael@0: this._futureDate(this.SUBMISSION_REQUEST_EXPIRE_INTERVAL_MSEC); michael@0: this._inProgressSubmissionRequest = new DataSubmissionRequest(deferred, michael@0: requestExpiresDate, michael@0: isDelete); michael@0: michael@0: let onSuccess = function onSuccess(result) { michael@0: this._inProgressSubmissionRequest = null; michael@0: this._handleSubmissionResult(result); michael@0: }.bind(this); michael@0: michael@0: let onError = function onError(error) { michael@0: this._log.error("Error when handling data submission result: " + michael@0: CommonUtils.exceptionStr(error)); michael@0: this._inProgressSubmissionRequest = null; michael@0: this._handleSubmissionFailure(); michael@0: }.bind(this); michael@0: michael@0: let chained = deferred.promise.then(onSuccess, onError); michael@0: michael@0: this._log.info("Requesting data submission. Will expire at " + michael@0: requestExpiresDate); michael@0: try { michael@0: this._listener[handler](this._inProgressSubmissionRequest); michael@0: } catch (ex) { michael@0: this._log.warn("Exception when calling " + handler + ": " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: this._inProgressSubmissionRequest = null; michael@0: this._handleSubmissionFailure(); michael@0: return; michael@0: } michael@0: michael@0: return chained; michael@0: }, michael@0: michael@0: _handleSubmissionResult: function _handleSubmissionResult(request) { michael@0: let state = request.state; michael@0: let reason = request.reason || "no reason"; michael@0: this._log.info("Got submission request result: " + state); michael@0: michael@0: if (state == request.SUBMISSION_SUCCESS) { michael@0: if (request.isDelete) { michael@0: this.pendingDeleteRemoteData = false; michael@0: this._log.info("Successful data delete reported."); michael@0: } else { michael@0: this._log.info("Successful data upload reported."); michael@0: } michael@0: michael@0: this.lastDataSubmissionSuccessfulDate = request.submissionDate; michael@0: michael@0: let nextSubmissionDate = michael@0: new Date(request.submissionDate.getTime() + MILLISECONDS_PER_DAY); michael@0: michael@0: // Schedule pending deletes immediately. This has potential to overload michael@0: // the server. However, the frequency of delete requests across all michael@0: // clients should be low, so this shouldn't pose a problem. michael@0: if (this.pendingDeleteRemoteData) { michael@0: nextSubmissionDate = this.now(); michael@0: } michael@0: michael@0: this.nextDataSubmissionDate = nextSubmissionDate; michael@0: this.currentDaySubmissionFailureCount = 0; michael@0: return; michael@0: } michael@0: michael@0: if (state == request.NO_DATA_AVAILABLE) { michael@0: if (request.isDelete) { michael@0: this._log.info("Remote data delete requested but no remote data was stored."); michael@0: this.pendingDeleteRemoteData = false; michael@0: return; michael@0: } michael@0: michael@0: this._log.info("No data was available to submit. May try later."); michael@0: this._handleSubmissionFailure(); michael@0: return; michael@0: } michael@0: michael@0: // We don't special case request.isDelete for these failures because it michael@0: // likely means there was a server error. michael@0: michael@0: if (state == request.SUBMISSION_FAILURE_SOFT) { michael@0: this._log.warn("Soft error submitting data: " + reason); michael@0: this.lastDataSubmissionFailureDate = this.now(); michael@0: this._handleSubmissionFailure(); michael@0: return; michael@0: } michael@0: michael@0: if (state == request.SUBMISSION_FAILURE_HARD) { michael@0: this._log.warn("Hard error submitting data: " + reason); michael@0: this.lastDataSubmissionFailureDate = this.now(); michael@0: this._moveScheduleForward24h(); michael@0: return; michael@0: } michael@0: michael@0: throw new Error("Unknown state on DataSubmissionRequest: " + request.state); michael@0: }, michael@0: michael@0: _handleSubmissionFailure: function _handleSubmissionFailure() { michael@0: if (this.currentDaySubmissionFailureCount >= this.FAILURE_BACKOFF_INTERVALS.length) { michael@0: this._log.warn("Reached the limit of daily submission attempts. " + michael@0: "Rescheduling for tomorrow."); michael@0: this._moveScheduleForward24h(); michael@0: return false; michael@0: } michael@0: michael@0: let offset = this.FAILURE_BACKOFF_INTERVALS[this.currentDaySubmissionFailureCount]; michael@0: this.nextDataSubmissionDate = this._futureDate(offset); michael@0: this.currentDaySubmissionFailureCount++; michael@0: return true; michael@0: }, michael@0: michael@0: _moveScheduleForward24h: function _moveScheduleForward24h() { michael@0: let d = this._futureDate(MILLISECONDS_PER_DAY); michael@0: this._log.info("Setting next scheduled data submission for " + d); michael@0: michael@0: this.nextDataSubmissionDate = d; michael@0: this.currentDaySubmissionFailureCount = 0; michael@0: }, michael@0: michael@0: _futureDate: function _futureDate(offset) { michael@0: return new Date(this.now().getTime() + offset); michael@0: }, michael@0: }); michael@0: