Bug 836443 - Automatically stop and restart downloads. r=enn

This commit is contained in:
Paolo Amadini 2013-08-16 11:02:18 +02:00
Родитель 9d8c642be5
Коммит 9981fae81f
4 изменённых файлов: 359 добавлений и 24 удалений

Просмотреть файл

@ -203,7 +203,7 @@ Download.prototype = {
*
* This property is relevant while the download is in progress, and also if it
* failed or has been canceled. If the download has been completed
* successfully, this property is not relevant anymore.
* successfully, this property is always false.
*
* Whether partial data can actually be retained depends on the saver and the
* download source, and may not be known before the download is started.
@ -382,6 +382,7 @@ Download.prototype = {
// Update the status properties for a successful download.
this.progress = 100;
this.succeeded = true;
this.hasPartialData = false;
} catch (ex) {
// Fail with a generic status code on cancellation, so that the caller
// is forced to actually check the status properties to see if the
@ -622,6 +623,47 @@ Download.prototype = {
return this._deferSucceeded.promise;
},
/**
* Updates the state of a finished, failed, or canceled download based on the
* current state in the file system. If the download is in progress or it has
* been finalized, this method has no effect, and it returns a resolved
* promise.
*
* This allows the properties of the download to be updated in case the user
* moved or deleted the target file or its associated ".part" file.
*
* @return {Promise}
* @resolves When the operation has completed.
* @rejects Never.
*/
refresh: function ()
{
return Task.spawn(function () {
if (!this.stopped || this._finalized) {
return;
}
// Update the current progress from disk if we retained partial data.
if (this.hasPartialData && this.target.partFilePath) {
let stat = yield OS.File.stat(this.target.partFilePath);
// Ignore the result if the state has changed meanwhile.
if (!this.stopped || this._finalized) {
return;
}
// Update the bytes transferred and the related progress properties.
this.currentBytes = stat.size;
if (this.totalBytes > 0) {
this.hasProgress = true;
this.progress = Math.floor(this.currentBytes /
this.totalBytes * 100);
}
this._notifyChange();
}
}.bind(this)).then(null, Cu.reportError);
},
/**
* True if the "finalize" method has been called. This prevents the download
* from starting again after having been stopped.
@ -761,20 +803,54 @@ Download.prototype = {
serializable.saver = saver;
}
if (this.launcherPath) {
serializable.launcherPath = this.launcherPath;
if (!this.stopped) {
serializable.stopped = false;
}
if (this.launchWhenSucceeded) {
serializable.launchWhenSucceeded = true;
if (this.error && ("message" in this.error)) {
serializable.error = { message: this.error.message };
}
if (this.contentType) {
serializable.contentType = this.contentType;
// These are serialized unless they are false, null, or empty strings.
let propertiesToSerialize = [
"succeeded",
"canceled",
"startTime",
"totalBytes",
"hasPartialData",
"tryToKeepPartialData",
"launcherPath",
"launchWhenSucceeded",
"contentType",
];
for (let property of propertiesToSerialize) {
if (this[property]) {
serializable[property] = this[property];
}
}
return serializable;
},
/**
* Returns a value that changes only when one of the properties of a Download
* object that should be saved into a file also change. This excludes
* properties whose value doesn't usually change during the download lifetime.
*
* This function is used to determine whether the download should be
* serialized after a property change notification has been received.
*
* @return String representing the relevant download state.
*/
getSerializationHash: function ()
{
// The "succeeded", "canceled", "error", and startTime properties are not
// taken into account because they all change before the "stopped" property
// changes, and are not altered in other cases.
return this.stopped + "," + this.totalBytes + "," + this.hasPartialData +
"," + this.contentType;
},
};
/**
@ -816,16 +892,28 @@ Download.fromSerializable = function (aSerializable) {
}
download.saver.download = download;
if ("launchWhenSucceeded" in aSerializable) {
download.launchWhenSucceeded = !!aSerializable.launchWhenSucceeded;
let propertiesToDeserialize = [
"startTime",
"totalBytes",
"hasPartialData",
"tryToKeepPartialData",
"launcherPath",
"launchWhenSucceeded",
"contentType",
];
// If the download should not be restarted automatically, update its state to
// reflect success or failure during a previous session.
if (!("stopped" in aSerializable) || aSerializable.stopped) {
propertiesToDeserialize.push("succeeded");
propertiesToDeserialize.push("canceled");
propertiesToDeserialize.push("error");
}
if ("contentType" in aSerializable) {
download.contentType = aSerializable.contentType;
for (let property of propertiesToDeserialize) {
if (property in aSerializable) {
download[property] = aSerializable[property];
}
if ("launcherPath" in aSerializable) {
download.launcherPath = aSerializable.launcherPath;
}
return download;
@ -1190,6 +1278,13 @@ DownloadCopySaver.prototype = {
*/
_canceled: false,
/**
* String corresponding to the entityID property of the nsIResumableChannel
* used to execute the download, or null if the channel was not resumable or
* the saver was instructed not to keep partially downloaded data.
*/
entityID: null,
/**
* Implements "DownloadSaver.execute".
*/
@ -1428,8 +1523,13 @@ DownloadCopySaver.prototype = {
*/
toSerializable: function ()
{
// Simplify the representation since we don't have other details for now.
// Simplify the representation if we don't have other details.
if (!this.entityID) {
return "copy";
}
return { type: "copy",
entityID: this.entityID };
},
};
@ -1443,8 +1543,11 @@ DownloadCopySaver.prototype = {
* @return The newly created DownloadCopySaver object.
*/
DownloadCopySaver.fromSerializable = function (aSerializable) {
// We don't have other state details for now.
return new DownloadCopySaver();
let saver = new DownloadCopySaver();
if ("entityID" in aSerializable) {
saver.entityID = aSerializable.entityID;
}
return saver;
};
////////////////////////////////////////////////////////////////////////////////
@ -1574,6 +1677,13 @@ DownloadLegacySaver.prototype = {
*/
copySaver: null,
/**
* String corresponding to the entityID property of the nsIResumableChannel
* used to execute the download, or null if the channel was not resumable or
* the saver was instructed not to keep partially downloaded data.
*/
entityID: null,
/**
* Implements "DownloadSaver.execute".
*/
@ -1674,7 +1784,7 @@ DownloadLegacySaver.prototype = {
// thus it cannot be rebuilt during deserialization. To support resuming
// across different browser sessions, this object is transformed into a
// DownloadCopySaver for the purpose of serialization.
return "copy";
return DownloadCopySaver.prototype.toSerializable.call(this);
},
};

Просмотреть файл

@ -64,6 +64,31 @@ XPCOMUtils.defineLazyGetter(this, "gStringBundle", function() {
createBundle("chrome://mozapps/locale/downloads/downloads.properties");
});
const Timer = Components.Constructor("@mozilla.org/timer;1", "nsITimer",
"initWithCallback");
/**
* Indicates the delay between a change to the downloads data and the related
* save operation. This value is the result of a delicate trade-off, assuming
* the host application uses the browser history instead of the download store
* to save completed downloads.
*
* If a download takes less than this interval to complete (for example, saving
* a page that is already displayed), then no input/output is triggered by the
* download store except for an existence check, resulting in the best possible
* efficiency.
*
* Conversely, if the browser is closed before this interval has passed, the
* download will not be saved. This prevents it from being restored in the next
* session, and if there is partial data associated with it, then the ".part"
* file will not be deleted when the browser starts again.
*
* In all cases, for best efficiency, this value should be high enough that the
* input/output for opening or closing the target file does not overlap with the
* one for saving the list of downloads.
*/
const kSaveDelayMs = 1500;
////////////////////////////////////////////////////////////////////////////////
//// DownloadIntegration
@ -105,7 +130,7 @@ this.DownloadIntegration = {
* @param aList
* DownloadList object to be populated with the download objects
* serialized from the previous session. This list will be persisted
* to disk during the session lifetime or when the session terminates.
* to disk during the session lifetime.
*
* @return {Promise}
* @resolves When the list has been populated.
@ -124,7 +149,39 @@ this.DownloadIntegration = {
this._store = new DownloadStore(aList, OS.Path.join(
OS.Constants.Path.profileDir,
"downloads.json"));
return this._store.load();
this._store.onsaveitem = this.shouldPersistDownload.bind(this);
// Load the list of persistent downloads, then add the DownloadAutoSaveView
// even if the load operation failed.
return this._store.load().then(null, Cu.reportError).then(() => {
new DownloadAutoSaveView(aList, this._store);
});
},
/**
* Determines if a Download object from the list of persistent downloads
* should be saved into a file, so that it can be restored across sessions.
*
* This function allows filtering out downloads that the host application is
* not interested in persisting across sessions, for example downloads that
* finished successfully.
*
* @param aDownload
* The Download object to be inspected. This is originally taken from
* the global DownloadList object for downloads that were not started
* from a private browsing window. The item may have been removed
* from the list since the save operation started, though in this case
* the save operation will be repeated later.
*
* @return True to save the download, false otherwise.
*/
shouldPersistDownload: function (aDownload)
{
// In the default implementation, we save all the downloads currently in
// progress, as well as stopped downloads for which we retained partially
// downloaded data. Stopped downloads for which we don't need to track the
// presence of a ".part" file are only retained in the browser history.
return aDownload.hasPartialData || !aDownload.stopped;
},
/**
@ -493,6 +550,10 @@ this.DownloadIntegration = {
* @resolves When the views and observers are added.
*/
addListObservers: function DI_addListObservers(aList, aIsPrivate) {
if (this.dontLoad) {
return Promise.resolve();
}
DownloadObserver.registerView(aList, aIsPrivate);
if (!DownloadObserver.observersAdded) {
DownloadObserver.observersAdded = true;
@ -504,7 +565,10 @@ this.DownloadIntegration = {
}
};
let DownloadObserver = {
////////////////////////////////////////////////////////////////////////////////
//// DownloadObserver
this.DownloadObserver = {
/**
* Flag to determine if the observers have been added previously.
*/
@ -657,3 +721,134 @@ let DownloadObserver = {
QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
Ci.nsISupportsWeakReference])
};
////////////////////////////////////////////////////////////////////////////////
//// DownloadAutoSaveView
/**
* This view can be added to a DownloadList object to trigger a save operation
* in the given DownloadStore object when a relevant change occurs.
*
* @param aStore
* The DownloadStore object used for saving.
*/
function DownloadAutoSaveView(aList, aStore) {
this._store = aStore;
this._downloadsMap = new Map();
// We set _initialized to true after adding the view, so that onDownloadAdded
// doesn't cause a save to occur.
aList.addView(this);
this._initialized = true;
}
DownloadAutoSaveView.prototype = {
/**
* True when the initial state of the downloads has been loaded.
*/
_initialized: false,
/**
* The DownloadStore object used for saving.
*/
_store: null,
/**
* This map contains only Download objects that should be saved to disk, and
* associates them with the result of their getSerializationHash function, for
* the purpose of detecting changes to the relevant properties.
*/
_downloadsMap: null,
/**
* This is set to true when the save operation should be triggered. This is
* required so that a new operation can be scheduled while the current one is
* in progress, without re-entering the save method.
*/
_shouldSave: false,
/**
* nsITimer used for triggering the save operation after a delay, or null if
* saving has finished and there is no operation scheduled for execution.
*
* The logic here is different from the DeferredTask module in that multiple
* requests will never delay the operation for longer than the expected time
* (no grace delay), and the operation is never re-entered during execution.
*/
_timer: null,
/**
* Timer callback used to serialize the list of downloads.
*/
_save: function ()
{
Task.spawn(function () {
// Any save request received during execution will be handled later.
this._shouldSave = false;
// Execute the asynchronous save operation.
try {
yield this._store.save();
} catch (ex) {
Cu.reportError(ex);
}
// Handle requests received during the operation.
this._timer = null;
if (this._shouldSave) {
this.saveSoon();
}
}.bind(this)).then(null, Cu.reportError);
},
/**
* Called when the list of downloads changed, this triggers the asynchronous
* serialization of the list of downloads.
*/
saveSoon: function ()
{
this._shouldSave = true;
if (!this._timer) {
this._timer = new Timer(this._save.bind(this), kSaveDelayMs,
Ci.nsITimer.TYPE_ONE_SHOT);
}
},
//////////////////////////////////////////////////////////////////////////////
//// DownloadList view
onDownloadAdded: function (aDownload)
{
if (DownloadIntegration.shouldPersistDownload(aDownload)) {
this._downloadsMap.set(aDownload, aDownload.getSerializationHash());
if (this._initialized) {
this.saveSoon();
}
}
},
onDownloadChanged: function (aDownload)
{
if (!DownloadIntegration.shouldPersistDownload(aDownload)) {
if (this._downloadsMap.has(aDownload)) {
this._downloadsMap.delete(aDownload);
this.saveSoon();
}
return;
}
let hash = aDownload.getSerializationHash();
if (this._downloadsMap.get(aDownload) != hash) {
this._downloadsMap.set(aDownload, hash);
this.saveSoon();
}
},
onDownloadRemoved: function (aDownload)
{
if (this._downloadsMap.has(aDownload)) {
this._downloadsMap.delete(aDownload);
this.saveSoon();
}
},
};

Просмотреть файл

@ -171,6 +171,11 @@ DownloadList.prototype = {
* // Called after aDownload is removed from the list.
* },
* }
*
* @note The onDownloadAdded notifications are sent synchronously. This
* allows for a complete initialization of the view used for detecting
* changes to downloads to be persisted, before other callers get a
* chance to modify them.
*/
addView: function DL_addView(aView)
{

Просмотреть файл

@ -88,6 +88,12 @@ DownloadStore.prototype = {
*/
path: "",
/**
* This function is called with a Download object as its first argument, and
* should return true if the item should be saved.
*/
onsaveitem: () => true,
/**
* Loads persistent downloads from the file to the list.
*
@ -111,7 +117,23 @@ DownloadStore.prototype = {
// Create live downloads based on the static snapshot.
for (let downloadData of storeData.list) {
try {
this.list.add(yield Downloads.createDownload(downloadData));
let download = yield Downloads.createDownload(downloadData);
try {
if (("stopped" in downloadData) && !downloadData.stopped) {
// Try to restart the download if it was in progress during the
// previous session.
download.start();
} else {
// If the download was not in progress, try to update the current
// progress from disk. This is relevant in case we retained
// partially downloaded data.
yield download.refresh();
}
} finally {
// Add the download to the list if we succeeded in creating it,
// after we have updated its initial state.
this.list.add(download);
}
} catch (ex) {
// If an item is unrecognized, don't prevent others from being loaded.
Cu.reportError(ex);
@ -139,6 +161,9 @@ DownloadStore.prototype = {
let atLeastOneDownload = false;
for (let download of downloads) {
try {
if (!this.onsaveitem(download)) {
continue;
}
storeData.list.push(download.toSerializable());
atLeastOneDownload = true;
} catch (ex) {